feat(management): cross-org management MCP registry (Org API Key) #32
@@ -95,6 +95,101 @@ You: "What skills does the coding agent have?"
|
||||
Agent: [calls get_workspace, reads agent_card.skills]
|
||||
```
|
||||
|
||||
## Management MCP (cross-org / org-lifecycle surface)
|
||||
|
||||
The default registry above is **single-tenant workspace-ops** against one
|
||||
tenant's workspace-server. The server also ships a **management registry** —
|
||||
the org-lifecycle / management surface — selected with
|
||||
`MOLECULE_MCP_MODE=management`. It is the *same* server and conventions, run in
|
||||
a distinct mode (the two registries are mutually exclusive in one process
|
||||
because several tool names overlap).
|
||||
|
||||
### Tools (§5(a))
|
||||
|
||||
| Group | Tools |
|
||||
|-------|-------|
|
||||
| Workspaces | `list_workspaces`, `get_workspace`, `provision_workspace`, `deprovision_workspace`, `restart_workspace`, `pause_workspace`, `resume_workspace` |
|
||||
| Secrets | `set_workspace_secret`, `list_workspace_secrets`, `delete_workspace_secret`, `set_org_secret`, `list_org_secrets`, `delete_org_secret` |
|
||||
| Budget / billing | `set_workspace_budget`, `set_llm_billing_mode` |
|
||||
| Templates / org import | `list_org_templates`, `create_org_from_template`, `list_templates`, `import_template` |
|
||||
| Tokens | `mint_org_token`, `list_org_tokens`, `revoke_org_token`, `mint_workspace_token` |
|
||||
| Plugin governance | `get_org_plugin_allowlist`, `set_org_plugin_allowlist` |
|
||||
| Bundles | `export_bundle`, `import_bundle` |
|
||||
| Audit | `list_org_events`, `list_pending_approvals` |
|
||||
| **CP-tier (gated)** | `list_orgs`, `get_org` |
|
||||
|
||||
Each tool's input schema, endpoint, and request body are derived from the
|
||||
canonical tenant router/handler source
|
||||
(`molecule-core/workspace-server/internal/router/router.go` +
|
||||
`internal/handlers/*`) — the same source the management OpenAPI is being
|
||||
authored from.
|
||||
|
||||
### Auth model — Org API Key (tenant credential)
|
||||
|
||||
The management tools authenticate with the **Org API Key** (dashboard → "Org
|
||||
API Keys"), presented to the **per-org tenant host**
|
||||
(`<slug>.moleculesai.app`) as:
|
||||
|
||||
```
|
||||
Authorization: Bearer ${MOLECULE_ORG_API_KEY}
|
||||
X-Molecule-Org-Id: ${MOLECULE_ORG_ID}
|
||||
```
|
||||
|
||||
The Org API Key is `org_api_tokens` (sha256-hashed, prefixed, revocable). It
|
||||
satisfies the tenant `AdminAuth` / `WorkspaceAuth` gates, and the tenant
|
||||
`TenantGuard` requires the `X-Molecule-Org-Id` header to match the EC2 the
|
||||
request lands on.
|
||||
|
||||
> **⚠ Security — the Org API Key is full-tenant-admin AND self-minting.** It
|
||||
> authorizes the entire tenant-admin surface of its own org (workspaces,
|
||||
> secrets, templates, bundles) and can mint/revoke *more* Org API Keys via
|
||||
> `mint_org_token` / `revoke_org_token`. **A management MCP holding one holds
|
||||
> tenant root.** There is no scope-down below full-admin today; per-role /
|
||||
> per-workspace scoping is a planned follow-up. Treat `MOLECULE_ORG_API_KEY`
|
||||
> as a root credential — store it in a secrets manager, never in source.
|
||||
|
||||
### CP-tier caveat (`list_orgs` / `get_org`)
|
||||
|
||||
The Org API Key is a **tenant** credential and **cannot reach the control
|
||||
plane** — CP `/api/v1/orgs/*` (org create/delete/export/members/billing)
|
||||
401/403 the org key. `list_orgs` / `get_org` are therefore kept in a clearly
|
||||
separated CP-admin module and **gated** on `CP_ADMIN_API_TOKEN`. When that
|
||||
token is absent they return a structured `CP_TIER_NOT_CONFIGURED` result (not
|
||||
a silent failure) and make no network call. Member/billing management tools
|
||||
need the same CP session tier and are intentionally out of scope for the
|
||||
org-key MCP.
|
||||
|
||||
### Management env vars
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `MOLECULE_MCP_MODE` | Yes | Set to `management` to run the management registry |
|
||||
| `MOLECULE_API_URL` | Yes | The **tenant host** base URL (`https://<slug>.moleculesai.app`) |
|
||||
| `MOLECULE_ORG_API_KEY` | Yes | Org API Key (full-tenant-admin; see security note) |
|
||||
| `MOLECULE_ORG_ID` | Yes | Org id for the `X-Molecule-Org-Id` tenant-guard header |
|
||||
| `MOLECULE_ORG_SLUG` | No | Optional `X-Molecule-Org-Slug` header |
|
||||
| `CP_ADMIN_API_TOKEN` | No | CP admin bearer — required only for the CP-tier `list_orgs` / `get_org` tools |
|
||||
| `MOLECULE_CP_URL` | No | Control-plane base URL (default `https://api.moleculesai.app`) |
|
||||
|
||||
### Management host config
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"molecule-management": {
|
||||
"command": "node",
|
||||
"args": ["./mcp-server/dist/index.js"],
|
||||
"env": {
|
||||
"MOLECULE_MCP_MODE": "management",
|
||||
"MOLECULE_API_URL": "https://agents-team.moleculesai.app",
|
||||
"MOLECULE_ORG_API_KEY": "<org-api-key>",
|
||||
"MOLECULE_ORG_ID": "<org-id>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Remote Agents (Phase 30)
|
||||
|
||||
For agents running outside the platform's Docker network, the `get_remote_agent_setup_command`
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Unit tests for the management tool registry (Org API Key, tenant host).
|
||||
*
|
||||
* The HTTP layer is mocked via global.fetch so no real requests are made.
|
||||
* Tests assert the exact URL + method + body + auth headers each tool sends,
|
||||
* the auth-gating when the Org API Key is absent, and the CP-tier gating.
|
||||
*/
|
||||
|
||||
jest.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
|
||||
McpServer: class {
|
||||
registeredToolNames: string[] = [];
|
||||
tool(name: string) {
|
||||
this.registeredToolNames.push(name);
|
||||
}
|
||||
connect() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
}));
|
||||
jest.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
||||
StdioServerTransport: class {},
|
||||
}));
|
||||
|
||||
import {
|
||||
registerManagementTools,
|
||||
handleDeprovisionWorkspace,
|
||||
handleSetWorkspaceSecret,
|
||||
handleListWorkspaceSecrets,
|
||||
handleDeleteWorkspaceSecret,
|
||||
handleSetOrgSecret,
|
||||
handleListOrgSecrets,
|
||||
handleDeleteOrgSecret,
|
||||
handleSetWorkspaceBudget,
|
||||
handleSetLlmBillingMode,
|
||||
handleMintOrgToken,
|
||||
handleListOrgTokens,
|
||||
handleRevokeOrgToken,
|
||||
handleMintWorkspaceToken,
|
||||
handleGetOrgPluginAllowlist,
|
||||
handleSetOrgPluginAllowlist,
|
||||
handleListOrgs,
|
||||
handleGetOrg,
|
||||
isManagementMode,
|
||||
createServer,
|
||||
} from "../index.js";
|
||||
import {
|
||||
handleProvisionWorkspace as mgmtProvisionWorkspace,
|
||||
handleListWorkspaces as mgmtListWorkspaces,
|
||||
handleGetWorkspace,
|
||||
handleRestartWorkspace,
|
||||
handlePauseWorkspace,
|
||||
handleResumeWorkspace,
|
||||
handleExportBundle,
|
||||
handleListOrgEvents,
|
||||
} from "../tools/management/index.js";
|
||||
|
||||
const ORG_KEY = "org_testkey_abcdef";
|
||||
const ORG_ID = "org-11111111";
|
||||
const HOST = "https://agents-team.moleculesai.app";
|
||||
|
||||
/** Mock fetch returning a JSON payload; records the last call args. */
|
||||
function mockFetch(payload: unknown, ok = true, status = 200) {
|
||||
return jest.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
text: jest.fn().mockResolvedValue(JSON.stringify(payload)),
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse the JSON blob a handler returns inside the MCP envelope. */
|
||||
function parsed(res: { content: { text: string }[] }) {
|
||||
return JSON.parse(res.content[0].text);
|
||||
}
|
||||
|
||||
function lastCall(fetchMock: jest.Mock) {
|
||||
const [url, init] = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
|
||||
return { url: url as string, init: init as RequestInit };
|
||||
}
|
||||
|
||||
function headersOf(init: RequestInit): Record<string, string> {
|
||||
return (init.headers as Record<string, string>) || {};
|
||||
}
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
process.env.MOLECULE_API_URL = HOST;
|
||||
process.env.MOLECULE_ORG_API_KEY = ORG_KEY;
|
||||
process.env.MOLECULE_ORG_ID = ORG_ID;
|
||||
delete process.env.MOLECULE_MCP_MODE;
|
||||
delete process.env.CP_ADMIN_API_TOKEN;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
describe("management auth model", () => {
|
||||
it("sends Bearer Org API Key + X-Molecule-Org-Id to the tenant host", async () => {
|
||||
const f = mockFetch([{ id: "w1" }]);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await mgmtListWorkspaces();
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces`);
|
||||
expect(init.method).toBe("GET");
|
||||
const h = headersOf(init);
|
||||
expect(h.Authorization).toBe(`Bearer ${ORG_KEY}`);
|
||||
expect(h["X-Molecule-Org-Id"]).toBe(ORG_ID);
|
||||
});
|
||||
|
||||
it("returns AUTH_ERROR (no fetch) when MOLECULE_ORG_API_KEY is absent", async () => {
|
||||
delete process.env.MOLECULE_ORG_API_KEY;
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
const res = parsed(await handleListWorkspaceSecrets({ workspace_id: "w1" }));
|
||||
expect(res.error).toBe("AUTH_ERROR");
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a 401 to AUTH_ERROR", async () => {
|
||||
const f = mockFetch({ error: "unauthorized" }, false, 401);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
const res = parsed(await handleListWorkspaceSecrets({ workspace_id: "w1" }));
|
||||
expect(res.error).toBe("AUTH_ERROR");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("maps a 429 to RATE_LIMITED", async () => {
|
||||
const f = mockFetch({ error: "slow down" }, false, 429);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
const res = parsed(await handleListOrgTokens());
|
||||
expect(res.error).toBe("RATE_LIMITED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace secret tools", () => {
|
||||
it("set_workspace_secret POSTs key+value to /workspaces/:id/secrets", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetWorkspaceSecret({ workspace_id: "w1", key: "ANTHROPIC_API_KEY", value: "sk-x" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces/w1/secrets`);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ key: "ANTHROPIC_API_KEY", value: "sk-x" });
|
||||
});
|
||||
|
||||
it("list_workspace_secrets GETs /workspaces/:id/secrets", async () => {
|
||||
const f = mockFetch([{ key: "FOO" }]);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleListWorkspaceSecrets({ workspace_id: "w1" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces/w1/secrets`);
|
||||
expect(init.method).toBe("GET");
|
||||
});
|
||||
|
||||
it("delete_workspace_secret DELETEs and url-encodes the key", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleDeleteWorkspaceSecret({ workspace_id: "w1", key: "A/B KEY" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces/w1/secrets/A%2FB%20KEY`);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("rejects a missing required key with INVALID_ARGUMENTS (no fetch)", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(handleSetWorkspaceSecret({ workspace_id: "w1", value: "x" })).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("org secret tools", () => {
|
||||
it("set_org_secret POSTs to /settings/secrets", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetOrgSecret({ key: "GITHUB_TOKEN", value: "ghp_x" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/settings/secrets`);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ key: "GITHUB_TOKEN", value: "ghp_x" });
|
||||
});
|
||||
|
||||
it("list_org_secrets GETs /settings/secrets", async () => {
|
||||
const f = mockFetch([]);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleListOrgSecrets();
|
||||
expect(lastCall(f).url).toBe(`${HOST}/settings/secrets`);
|
||||
});
|
||||
|
||||
it("delete_org_secret DELETEs /settings/secrets/:key", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleDeleteOrgSecret({ key: "GITHUB_TOKEN" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/settings/secrets/GITHUB_TOKEN`);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace lifecycle tools", () => {
|
||||
it("provision_workspace POSTs to /workspaces with the supplied fields", async () => {
|
||||
const f = mockFetch({ id: "w-new" });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await mgmtProvisionWorkspace({ name: "Researcher", runtime: "claude-code", tier: 2 });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces`);
|
||||
expect(init.method).toBe("POST");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.name).toBe("Researcher");
|
||||
expect(body.runtime).toBe("claude-code");
|
||||
expect(body.tier).toBe(2);
|
||||
});
|
||||
|
||||
it("deprovision_workspace DELETEs /workspaces/:id", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleDeprovisionWorkspace({ workspace_id: "w1" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces/w1`);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget + billing tools", () => {
|
||||
it("set_workspace_budget PATCHes budget_limits to /workspaces/:id/budget", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetWorkspaceBudget({ workspace_id: "w1", budget_limits: { monthly: 50000 } });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/workspaces/w1/budget`);
|
||||
expect(init.method).toBe("PATCH");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ budget_limits: { monthly: 50000 } });
|
||||
});
|
||||
|
||||
it("set_workspace_budget rejects an unknown period (no fetch)", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(
|
||||
handleSetWorkspaceBudget({ workspace_id: "w1", budget_limits: { yearly: 1 } as never }),
|
||||
).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("set_workspace_budget rejects when neither field is given (no fetch)", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(handleSetWorkspaceBudget({ workspace_id: "w1" })).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("set_llm_billing_mode PUTs {mode} to the billing-mode route", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetLlmBillingMode({ workspace_id: "w1", mode: "byok" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/admin/workspaces/w1/llm-billing-mode`);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ mode: "byok" });
|
||||
});
|
||||
|
||||
it("set_llm_billing_mode passes mode:null through to clear the override", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetLlmBillingMode({ workspace_id: "w1", mode: null });
|
||||
expect(JSON.parse(lastCall(f).init.body as string)).toEqual({ mode: null });
|
||||
});
|
||||
|
||||
it("set_llm_billing_mode rejects an invalid mode (no fetch)", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(
|
||||
handleSetLlmBillingMode({ workspace_id: "w1", mode: "free" as never }),
|
||||
).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("token tools", () => {
|
||||
it("mint_org_token POSTs {name} to /org/tokens", async () => {
|
||||
const f = mockFetch({ auth_token: "org_xyz", id: "t1" });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleMintOrgToken({ name: "ci-bot" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/org/tokens`);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ name: "ci-bot" });
|
||||
});
|
||||
|
||||
it("list_org_tokens GETs /org/tokens", async () => {
|
||||
const f = mockFetch([]);
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleListOrgTokens();
|
||||
expect(lastCall(f).url).toBe(`${HOST}/org/tokens`);
|
||||
});
|
||||
|
||||
it("revoke_org_token DELETEs /org/tokens/:id (url-encoded)", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleRevokeOrgToken({ id: "abc/def" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/org/tokens/abc%2Fdef`);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("mint_workspace_token POSTs to /admin/workspaces/:id/tokens", async () => {
|
||||
const f = mockFetch({ auth_token: "ws_xyz" });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleMintWorkspaceToken({ workspace_id: "w1" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/admin/workspaces/w1/tokens`);
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("mint_org_token rejects an over-long name (no fetch)", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(handleMintOrgToken({ name: "x".repeat(101) })).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin allowlist tools", () => {
|
||||
it("get_org_plugin_allowlist GETs /orgs/:id/plugins/allowlist (default org id)", async () => {
|
||||
const f = mockFetch({ plugins: [] });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleGetOrgPluginAllowlist({});
|
||||
expect(lastCall(f).url).toBe(`${HOST}/orgs/${ORG_ID}/plugins/allowlist`);
|
||||
});
|
||||
|
||||
it("set_org_plugin_allowlist PUTs the plugins array", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleSetOrgPluginAllowlist({ plugins: ["a", "b"], enabled_by: "w1" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe(`${HOST}/orgs/${ORG_ID}/plugins/allowlist`);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({ plugins: ["a", "b"], enabled_by: "w1" });
|
||||
});
|
||||
|
||||
it("set_org_plugin_allowlist rejects a missing enabled_by (no fetch)", async () => {
|
||||
// The tenant PutAllowlist handler hard-requires enabled_by (400
|
||||
// "enabled_by is required"); the schema must reject it client-side.
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await expect(handleSetOrgPluginAllowlist({ plugins: ["a", "b"] })).rejects.toThrow();
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("get_org_plugin_allowlist surfaces INVALID_ARGUMENTS when no org id resolvable (no fetch)", async () => {
|
||||
delete process.env.MOLECULE_ORG_ID;
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
const res = parsed(await handleGetOrgPluginAllowlist({}));
|
||||
expect(res.error).toBe("INVALID_ARGUMENTS");
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CP-tier tools (separated, gated)", () => {
|
||||
it("list_orgs returns CP_TIER_NOT_CONFIGURED and makes no call when CP token absent", async () => {
|
||||
const f = mockFetch({});
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
const res = parsed(await handleListOrgs());
|
||||
expect(res.error).toBe("CP_TIER_NOT_CONFIGURED");
|
||||
expect(f).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("get_org hits the CP base URL with the admin bearer when configured", async () => {
|
||||
process.env.CP_ADMIN_API_TOKEN = "cp_admin_token";
|
||||
process.env.MOLECULE_CP_URL = "https://api.moleculesai.app";
|
||||
const f = mockFetch({ slug: "agents-team" });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleGetOrg({ slug: "agents-team" });
|
||||
const { url, init } = lastCall(f);
|
||||
expect(url).toBe("https://api.moleculesai.app/api/v1/orgs/agents-team");
|
||||
expect(headersOf(init).Authorization).toBe("Bearer cp_admin_token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration + mode", () => {
|
||||
it("isManagementMode reflects MOLECULE_MCP_MODE=management", () => {
|
||||
process.env.MOLECULE_MCP_MODE = "management";
|
||||
expect(isManagementMode()).toBe(true);
|
||||
process.env.MOLECULE_MCP_MODE = "";
|
||||
expect(isManagementMode()).toBe(false);
|
||||
});
|
||||
|
||||
it("registerManagementTools registers the full §5(a) toolset including CP-tier", () => {
|
||||
const srv = { registeredToolNames: [] as string[], tool(n: string) { this.registeredToolNames.push(n); } };
|
||||
registerManagementTools(srv as never);
|
||||
const names = srv.registeredToolNames;
|
||||
for (const expected of [
|
||||
"list_orgs", "get_org",
|
||||
"list_workspaces", "get_workspace", "provision_workspace", "deprovision_workspace",
|
||||
"restart_workspace", "pause_workspace", "resume_workspace",
|
||||
"set_workspace_secret", "list_workspace_secrets", "delete_workspace_secret",
|
||||
"set_org_secret", "list_org_secrets", "delete_org_secret",
|
||||
"set_workspace_budget", "set_llm_billing_mode",
|
||||
"list_org_templates", "create_org_from_template", "list_templates", "import_template",
|
||||
"mint_org_token", "list_org_tokens", "revoke_org_token", "mint_workspace_token",
|
||||
"get_org_plugin_allowlist", "set_org_plugin_allowlist",
|
||||
"export_bundle", "import_bundle",
|
||||
"list_org_events", "list_pending_approvals",
|
||||
]) {
|
||||
expect(names).toContain(expected);
|
||||
}
|
||||
// No duplicate registrations.
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
it("createServer in management mode registers only the management surface", () => {
|
||||
process.env.MOLECULE_MCP_MODE = "management";
|
||||
const srv = createServer() as unknown as { registeredToolNames: string[] };
|
||||
expect(srv.registeredToolNames).toContain("provision_workspace");
|
||||
// Legacy-only tools (chat_with_agent) must NOT be present in mgmt mode.
|
||||
expect(srv.registeredToolNames).not.toContain("chat_with_agent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("path segment escaping", () => {
|
||||
it("escapes workspace_id in get_workspace", async () => {
|
||||
const f = mockFetch({ id: "w1" });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await mgmtListWorkspaces(); // warm-up not needed; call directly
|
||||
await handleSetWorkspaceSecret({ workspace_id: "a/b", key: "K", value: "V" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/a%2Fb/secrets`);
|
||||
});
|
||||
|
||||
it("escapes workspace_id across lifecycle verbs", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
|
||||
await handleGetWorkspace({ workspace_id: "w/x" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fx`);
|
||||
|
||||
await handleDeprovisionWorkspace({ workspace_id: "w/x" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fx`);
|
||||
|
||||
await handleRestartWorkspace({ workspace_id: "w/x" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fx/restart`);
|
||||
|
||||
await handlePauseWorkspace({ workspace_id: "w/x" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fx/pause`);
|
||||
|
||||
await handleResumeWorkspace({ workspace_id: "w/x" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fx/resume`);
|
||||
});
|
||||
|
||||
it("escapes workspace_id in secrets, budget, billing-mode, and token mint", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
|
||||
await handleListWorkspaceSecrets({ workspace_id: "w/y" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fy/secrets`);
|
||||
|
||||
await handleDeleteWorkspaceSecret({ workspace_id: "w/y", key: "K" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fy/secrets/K`);
|
||||
|
||||
await handleSetWorkspaceBudget({ workspace_id: "w/y", budget_limits: { monthly: 1 } });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w%2Fy/budget`);
|
||||
|
||||
await handleSetLlmBillingMode({ workspace_id: "w/y", mode: "disabled" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/admin/workspaces/w%2Fy/llm-billing-mode`);
|
||||
|
||||
await handleMintWorkspaceToken({ workspace_id: "w/y" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/admin/workspaces/w%2Fy/tokens`);
|
||||
});
|
||||
|
||||
it("escapes workspace_id in bundle export and events filter", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
|
||||
await handleExportBundle({ workspace_id: "w/z" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/bundles/export/w%2Fz`);
|
||||
|
||||
await handleListOrgEvents({ workspace_id: "w/z" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/events/w%2Fz`);
|
||||
});
|
||||
|
||||
it("does NOT double-encode already-safe ids", async () => {
|
||||
const f = mockFetch({ ok: true });
|
||||
global.fetch = f as unknown as typeof fetch;
|
||||
await handleGetWorkspace({ workspace_id: "w1" });
|
||||
expect(lastCall(f).url).toBe(`${HOST}/workspaces/w1`);
|
||||
});
|
||||
});
|
||||
+63
-2
@@ -26,6 +26,7 @@ import { registerScheduleTools } from "./tools/schedules.js";
|
||||
import { registerApprovalTools } from "./tools/approvals.js";
|
||||
import { registerDiscoveryTools } from "./tools/discovery.js";
|
||||
import { registerRemoteAgentTools } from "./tools/remote_agents.js";
|
||||
import { registerManagementTools } from "./tools/management/index.js";
|
||||
|
||||
// Re-exports so existing importers (tests, SDK consumers) keep working.
|
||||
// Explicit names (not `export *`) so tree-shakers and TS readers can see
|
||||
@@ -194,12 +195,65 @@ export {
|
||||
handleCheckRemoteAgentFreshness,
|
||||
} from "./tools/remote_agents.js";
|
||||
|
||||
// Management registry — the cross-org / org-lifecycle management surface
|
||||
// (Org API Key, tenant host). Enabled by MOLECULE_MCP_MODE=management; see
|
||||
// createServer() and tools/management/. Exported for tests + SDK consumers.
|
||||
// Note: handleProvisionWorkspace + handleListPendingApprovals are NOT
|
||||
// re-exported here — those identifiers are already owned by the legacy
|
||||
// workspaces/approvals export blocks above. The management variants are
|
||||
// reachable via the "./tools/management/index.js" module path and are
|
||||
// wired into the server through registerManagementTools.
|
||||
export {
|
||||
registerManagementTools,
|
||||
handleDeprovisionWorkspace,
|
||||
handleSetWorkspaceSecret,
|
||||
handleListWorkspaceSecrets,
|
||||
handleDeleteWorkspaceSecret,
|
||||
handleSetOrgSecret,
|
||||
handleListOrgSecrets,
|
||||
handleDeleteOrgSecret,
|
||||
handleSetWorkspaceBudget,
|
||||
handleSetLlmBillingMode,
|
||||
handleCreateOrgFromTemplate,
|
||||
handleMintOrgToken,
|
||||
handleListOrgTokens,
|
||||
handleRevokeOrgToken,
|
||||
handleMintWorkspaceToken,
|
||||
handleGetOrgPluginAllowlist,
|
||||
handleSetOrgPluginAllowlist,
|
||||
} from "./tools/management/index.js";
|
||||
export { mgmtCall, mgmtGet, managementUrl } from "./tools/management/client.js";
|
||||
export { registerCpAdminTools, handleListOrgs, handleGetOrg, cpUrl, cpConfigured } from "./tools/management/cp_admin.js";
|
||||
|
||||
/**
|
||||
* Returns true when the server should run as the MANAGEMENT server (the
|
||||
* cross-org / org-lifecycle surface) rather than the legacy single-tenant
|
||||
* workspace-ops surface. Driven by MOLECULE_MCP_MODE=management.
|
||||
*
|
||||
* The two registries are mutually exclusive in one server instance because
|
||||
* several tool names overlap (list_workspaces, get_workspace, restart/pause/
|
||||
* resume_workspace) and the MCP SDK throws on duplicate tool names. The
|
||||
* management registry is the SAME codebase + conventions, not a fork — it's
|
||||
* a distinct mode of this one server (SSOT).
|
||||
*/
|
||||
export function isManagementMode(): boolean {
|
||||
return (process.env.MOLECULE_MCP_MODE || "").toLowerCase() === "management";
|
||||
}
|
||||
|
||||
export function createServer() {
|
||||
const srv = new McpServer({
|
||||
name: "molecule",
|
||||
name: isManagementMode() ? "molecule-management" : "molecule",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
if (isManagementMode()) {
|
||||
// Management registry — Org API Key, tenant host. CP-tier tools
|
||||
// (list_orgs/get_org) are registered by registerManagementTools via the
|
||||
// separate cp_admin module and gated on CP_ADMIN_API_TOKEN.
|
||||
registerManagementTools(srv);
|
||||
return srv;
|
||||
}
|
||||
|
||||
registerWorkspaceTools(srv);
|
||||
registerAgentTools(srv);
|
||||
registerSecretTools(srv);
|
||||
@@ -237,7 +291,14 @@ async function main() {
|
||||
const server = createServer();
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 });
|
||||
if (isManagementMode()) {
|
||||
logInfo("Molecule AI MANAGEMENT MCP server running on stdio (Org API Key, tenant host)", {
|
||||
transport: "stdio",
|
||||
mode: "management",
|
||||
});
|
||||
} else {
|
||||
logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 });
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-start when run directly (not when imported for testing).
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Management-registry HTTP client.
|
||||
*
|
||||
* The legacy workspace-ops surface (src/api.ts) talks to ONE tenant whose
|
||||
* workspace-server is fail-open / co-located, so it sends no Authorization
|
||||
* header. The management registry is different: it targets a HARDENED remote
|
||||
* tenant host and must present the Org API Key on every call.
|
||||
*
|
||||
* Auth model (see PLATFORM-MANAGEMENT-API.md §1 / §5 and the tenant router
|
||||
* `internal/router/router.go`):
|
||||
* - `Authorization: Bearer ${MOLECULE_ORG_API_KEY}` — the dashboard
|
||||
* "Org API Keys" credential. It is `org_api_tokens` (sha256-hashed,
|
||||
* prefixed, revocable) and is FULL TENANT-ADMIN for its own org. It
|
||||
* satisfies the tenant `AdminAuth` and `WorkspaceAuth` gates.
|
||||
* - `X-Molecule-Org-Id: ${MOLECULE_ORG_ID}` — the tenant `TenantGuard`
|
||||
* rejects any request whose org id doesn't match the EC2 it lands on.
|
||||
*
|
||||
* SECURITY: the Org API Key is full-tenant-admin AND self-minting (it can
|
||||
* mint/revoke more org tokens via /org/tokens). A management MCP holding one
|
||||
* holds tenant root. There is no scope-down below full-admin today.
|
||||
*
|
||||
* This client deliberately reuses the ApiError shape + toMcpResult/toMcpText
|
||||
* envelopes from ../../api.js so the management tools return the exact same
|
||||
* structured output as every other tool (SSOT for the response envelope).
|
||||
*/
|
||||
|
||||
import { error as logError } from "../../utils/logger.js";
|
||||
import type { ApiError } from "../../api.js";
|
||||
|
||||
/**
|
||||
* The tenant host the management tools talk to. Same env precedence as the
|
||||
* legacy surface so a single server config drives both, but documented here
|
||||
* because the management tools point at the PER-ORG tenant host
|
||||
* (`<slug>.moleculesai.app`), not the control plane.
|
||||
*
|
||||
* Resolved at CALL time (not module-load) so the host can be configured /
|
||||
* overridden after import — and so the value is correct regardless of import
|
||||
* ordering.
|
||||
*/
|
||||
export function managementUrl(): string {
|
||||
return (
|
||||
process.env.MOLECULE_API_URL ||
|
||||
process.env.MOLECULE_URL ||
|
||||
process.env.PLATFORM_URL ||
|
||||
"http://localhost:8080"
|
||||
);
|
||||
}
|
||||
|
||||
/** The org id management writes route to (X-Molecule-Org-Id). */
|
||||
export function defaultOrgId(): string | undefined {
|
||||
return process.env.MOLECULE_ORG_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the auth headers for a tenant-host request. Returns an ApiError
|
||||
* (never throws) when the Org API Key is absent so the tool surfaces a clean
|
||||
* AUTH_ERROR instead of a confusing upstream 401.
|
||||
*/
|
||||
function managementHeaders(): Record<string, string> | ApiError {
|
||||
const tok = process.env.MOLECULE_ORG_API_KEY;
|
||||
if (!tok) {
|
||||
return {
|
||||
error: "AUTH_ERROR",
|
||||
detail:
|
||||
"MOLECULE_ORG_API_KEY is not set. The management tools require an Org " +
|
||||
"API Key (dashboard → Org API Keys) presented as a tenant credential.",
|
||||
};
|
||||
}
|
||||
const h: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tok}`,
|
||||
};
|
||||
const orgId = process.env.MOLECULE_ORG_ID;
|
||||
if (orgId) h["X-Molecule-Org-Id"] = orgId;
|
||||
const slug = process.env.MOLECULE_ORG_SLUG;
|
||||
if (slug) h["X-Molecule-Org-Slug"] = slug;
|
||||
return h;
|
||||
}
|
||||
|
||||
function isHeaders(v: Record<string, string> | ApiError): v is Record<string, string> {
|
||||
return !("error" in v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated request against the tenant host. Never throws — returns the
|
||||
* decoded JSON body on success or a structured ApiError on failure, exactly
|
||||
* like ../../api.js::apiCall.
|
||||
*/
|
||||
export async function mgmtCall<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T | ApiError> {
|
||||
const headers = managementHeaders();
|
||||
if (!isHeaders(headers)) return headers;
|
||||
try {
|
||||
const base = managementUrl();
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { error: "AUTH_ERROR", detail: text, status: res.status };
|
||||
}
|
||||
if (res.status === 429) {
|
||||
return { error: "RATE_LIMITED", detail: text, status: res.status };
|
||||
}
|
||||
return { error: `HTTP ${res.status}`, detail: text, status: res.status };
|
||||
}
|
||||
const text = await res.text();
|
||||
if (text.length === 0) return { raw: "", status: res.status } as ApiError;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return { raw: text, status: res.status } as ApiError;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logError(err, `Management API error (${method} ${path})`, { url: managementUrl() });
|
||||
return { error: `Tenant host unreachable at ${managementUrl()}`, detail: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience GET wrapper. */
|
||||
export async function mgmtGet<T = unknown>(path: string): Promise<T | ApiError> {
|
||||
return mgmtCall<T>("GET", path);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* CP-admin tools — the control-plane tier of the management surface.
|
||||
*
|
||||
* WHY THIS IS A SEPARATE MODULE (PLATFORM-MANAGEMENT-API.md §1 / §5):
|
||||
* The Org API Key is a TENANT credential. It authorizes the entire
|
||||
* tenant-admin surface of its own org but reaches NOTHING on the control
|
||||
* plane — CP `/api/v1/orgs/*` (org create/delete/export/members/billing)
|
||||
* 401/403 the org key. `list_orgs` / `get_org` are CP-tier reads that need
|
||||
* a WorkOS session cookie OR the CP admin bearer (`CP_ADMIN_API_TOKEN`).
|
||||
*
|
||||
* Rather than register these against the tenant host (where they would
|
||||
* silently 404/401 with the org key), they live here and:
|
||||
* - point at the control plane (`MOLECULE_CP_URL` / `api.moleculesai.app`),
|
||||
* - authenticate with `CP_ADMIN_API_TOKEN` (admin bearer),
|
||||
* - are GATED on that token being present: when it's absent the tool
|
||||
* returns a clear, structured "not configured / CP-tier" message
|
||||
* instead of a confusing upstream auth error.
|
||||
*
|
||||
* This keeps the CP-admin surface clearly separated and never silently
|
||||
* broken — per §5's instruction.
|
||||
*/
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { toMcpResult } from "../../api.js";
|
||||
import { validate } from "../../utils/validation.js";
|
||||
import { error as logError } from "../../utils/logger.js";
|
||||
import type { ApiError } from "../../api.js";
|
||||
|
||||
/**
|
||||
* Control-plane base URL. Distinct from the per-org tenant host. Resolved at
|
||||
* call time so it can be configured after import (and is order-independent).
|
||||
*/
|
||||
export function cpUrl(): string {
|
||||
return (
|
||||
process.env.MOLECULE_CP_URL ||
|
||||
process.env.CP_API_URL ||
|
||||
"https://api.moleculesai.app"
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a CP admin bearer is configured. */
|
||||
export function cpConfigured(): boolean {
|
||||
return !!process.env.CP_ADMIN_API_TOKEN;
|
||||
}
|
||||
|
||||
function cpNotConfigured(tool: string): ApiError {
|
||||
return {
|
||||
error: "CP_TIER_NOT_CONFIGURED",
|
||||
detail:
|
||||
`'${tool}' is a control-plane tier tool. The Org API Key cannot reach the CP. ` +
|
||||
"Set CP_ADMIN_API_TOKEN (CP admin bearer) to enable it. This is gated, not broken.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Authenticated CP request. Never throws. */
|
||||
async function cpCall<T = unknown>(method: string, path: string): Promise<T | ApiError> {
|
||||
const tok = process.env.CP_ADMIN_API_TOKEN;
|
||||
if (!tok) return cpNotConfigured(path) as ApiError;
|
||||
try {
|
||||
const base = cpUrl();
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${tok}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return { error: "AUTH_ERROR", detail: text, status: res.status };
|
||||
}
|
||||
return { error: `HTTP ${res.status}`, detail: text, status: res.status };
|
||||
}
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return { raw: text, status: res.status } as ApiError;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logError(err, `CP admin API error (${method} ${path})`, { url: cpUrl() });
|
||||
return { error: `Control plane unreachable at ${cpUrl()}`, detail: msg };
|
||||
}
|
||||
}
|
||||
|
||||
const GetOrgSchema = z.object({
|
||||
slug: z.string().describe("Org slug (e.g. 'agents-team')"),
|
||||
});
|
||||
|
||||
export async function handleListOrgs() {
|
||||
if (!cpConfigured()) return toMcpResult(cpNotConfigured("list_orgs"));
|
||||
// GET /api/v1/admin/orgs — admin-tier list of all orgs.
|
||||
return toMcpResult(await cpCall("GET", "/api/v1/admin/orgs"));
|
||||
}
|
||||
|
||||
export async function handleGetOrg(args: unknown) {
|
||||
const p = validate(args, GetOrgSchema);
|
||||
if (!cpConfigured()) return toMcpResult(cpNotConfigured("get_org"));
|
||||
// GET /api/v1/orgs/:slug — org detail (session+ownership or admin bearer).
|
||||
return toMcpResult(await cpCall("GET", `/api/v1/orgs/${encodeURIComponent(p.slug)}`));
|
||||
}
|
||||
|
||||
export function registerCpAdminTools(srv: McpServer) {
|
||||
srv.tool(
|
||||
"list_orgs",
|
||||
"Management (CP-TIER): list all orgs. Requires CP_ADMIN_API_TOKEN — the Org API Key CANNOT reach the control plane.",
|
||||
{},
|
||||
handleListOrgs,
|
||||
);
|
||||
srv.tool(
|
||||
"get_org",
|
||||
"Management (CP-TIER): get an org by slug. Requires CP session/admin — the Org API Key CANNOT reach the control plane.",
|
||||
{ slug: z.string().describe("Org slug") },
|
||||
handleGetOrg,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* Management tool registry — the cross-org / org-lifecycle management surface
|
||||
* the legacy single-tenant workspace-ops registry lacks.
|
||||
*
|
||||
* Auth: Org API Key (full tenant-admin) against the PER-ORG tenant host. See
|
||||
* ./client.ts for the auth model and the security caveat (org key = tenant
|
||||
* root, self-minting). The few CP-tier tools (list_orgs / get_org) live in
|
||||
* ./cp_admin.ts because the Org API Key CANNOT reach the control plane.
|
||||
*
|
||||
* Every endpoint + request body below is derived from the canonical tenant
|
||||
* router/handler source (molecule-core/workspace-server/internal/router/
|
||||
* router.go + internal/handlers/*), which is the same source the management
|
||||
* OpenAPI is being authored from. Tool names + param names align to that
|
||||
* contract.
|
||||
*/
|
||||
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { toMcpResult } from "../../api.js";
|
||||
import { validate } from "../../utils/validation.js";
|
||||
import { mgmtCall, mgmtGet, defaultOrgId } from "./client.js";
|
||||
import { registerCpAdminTools } from "./cp_admin.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas (aligned to the tenant handler request shapes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GetWorkspaceSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
});
|
||||
|
||||
const ProvisionWorkspaceSchema = z.object({
|
||||
name: z.string().describe("Workspace name"),
|
||||
role: z.string().optional().describe("Role description"),
|
||||
template: z.string().optional().describe("Template name from the org's config templates"),
|
||||
runtime: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Runtime: claude-code, langgraph, deepagents, autogen, crewai, hermes, codex, google-adk, external"),
|
||||
tier: z.number().int().min(1).max(4).optional().describe("Tier (1=basic, 2=browser, 3=desktop, 4=VM)"),
|
||||
parent_id: z.string().optional().describe("Parent workspace UUID for nesting"),
|
||||
model: z.string().optional().describe("LLM model id"),
|
||||
});
|
||||
|
||||
const DeprovisionWorkspaceSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
});
|
||||
|
||||
const WorkspaceLifecycleSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
});
|
||||
|
||||
// Secrets ------------------------------------------------------------------
|
||||
|
||||
const SetWorkspaceSecretSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
key: z.string().describe("Secret key (e.g. ANTHROPIC_API_KEY). Workspace env vars ARE secrets."),
|
||||
value: z.string().describe("Secret value"),
|
||||
});
|
||||
const ListWorkspaceSecretsSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
});
|
||||
const DeleteWorkspaceSecretSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
key: z.string().describe("Secret key"),
|
||||
});
|
||||
const SetOrgSecretSchema = z.object({
|
||||
key: z.string().describe("Secret key (e.g. GITHUB_TOKEN). Org-wide, available to all workspaces."),
|
||||
value: z.string().describe("Secret value"),
|
||||
});
|
||||
const DeleteOrgSecretSchema = z.object({
|
||||
key: z.string().describe("Secret key"),
|
||||
});
|
||||
|
||||
// Budget / billing ---------------------------------------------------------
|
||||
|
||||
const BUDGET_PERIODS = ["hourly", "daily", "weekly", "monthly"] as const;
|
||||
const SetWorkspaceBudgetSchema = z
|
||||
.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
budget_limits: z
|
||||
.record(z.enum(BUDGET_PERIODS), z.number().int().min(0).nullable())
|
||||
.optional()
|
||||
.describe("Map of period→USD-cents limit. null clears a period. e.g. {\"monthly\":50000}"),
|
||||
budget_limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Legacy single monthly limit (USD cents). Prefer budget_limits."),
|
||||
})
|
||||
.refine((v) => v.budget_limits !== undefined || v.budget_limit !== undefined, {
|
||||
message: "budget_limits or budget_limit is required",
|
||||
});
|
||||
|
||||
const SetLlmBillingModeSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
mode: z
|
||||
.enum(["platform_managed", "byok", "disabled"])
|
||||
.nullable()
|
||||
.describe("Billing mode override. null clears the override (inherit org default)."),
|
||||
});
|
||||
|
||||
// Templates / org import ---------------------------------------------------
|
||||
|
||||
const CreateOrgFromTemplateSchema = z
|
||||
.object({
|
||||
dir: z.string().optional().describe("Org template directory name (e.g. 'molecule-dev')"),
|
||||
template: z.record(z.unknown()).optional().describe("Inline org template object (alternative to dir)"),
|
||||
mode: z
|
||||
.enum(["merge", "reconcile"])
|
||||
.optional()
|
||||
.describe("merge (default, additive) or reconcile (additive + cascade-delete zombies)"),
|
||||
})
|
||||
.refine((v) => v.dir !== undefined || v.template !== undefined, {
|
||||
message: "dir or template is required",
|
||||
});
|
||||
|
||||
const ImportTemplateSchema = z.object({
|
||||
name: z.string().describe("Template name"),
|
||||
files: z.record(z.string()).describe("Map of file path → content"),
|
||||
});
|
||||
|
||||
// Tokens -------------------------------------------------------------------
|
||||
|
||||
const MintOrgTokenSchema = z.object({
|
||||
name: z.string().max(100).optional().describe("Human label for the token (max 100 chars)"),
|
||||
});
|
||||
const RevokeOrgTokenSchema = z.object({
|
||||
id: z.string().describe("Org token id to revoke"),
|
||||
});
|
||||
const MintWorkspaceTokenSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID to mint a bearer token for"),
|
||||
});
|
||||
|
||||
// Plugin allowlist ---------------------------------------------------------
|
||||
|
||||
const GetOrgPluginAllowlistSchema = z.object({
|
||||
org_id: z.string().optional().describe("Org id (defaults to MOLECULE_ORG_ID)"),
|
||||
});
|
||||
const SetOrgPluginAllowlistSchema = z.object({
|
||||
org_id: z.string().optional().describe("Org id (defaults to MOLECULE_ORG_ID)"),
|
||||
plugins: z.array(z.string()).describe("Full allowlist of approved plugin names (replaces existing)"),
|
||||
// REQUIRED: the tenant PutAllowlist handler 400s ("enabled_by is required")
|
||||
// when this is empty, so reject it client-side rather than round-trip a 400.
|
||||
enabled_by: z.string().min(1).describe("Workspace id of the admin making the change (audit)"),
|
||||
});
|
||||
|
||||
// Bundles ------------------------------------------------------------------
|
||||
|
||||
const ExportBundleSchema = z.object({
|
||||
workspace_id: z.string().describe("Workspace UUID to export as a portable bundle"),
|
||||
});
|
||||
const ImportBundleSchema = z.object({
|
||||
bundle: z.record(z.unknown()).describe("Bundle JSON object"),
|
||||
});
|
||||
|
||||
// Events -------------------------------------------------------------------
|
||||
|
||||
const ListOrgEventsSchema = z.object({
|
||||
workspace_id: z.string().optional().describe("Filter to one workspace, or omit for the whole org"),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Workspaces lifecycle -----------------------------------------------------
|
||||
|
||||
export async function handleListWorkspaces() {
|
||||
return toMcpResult(await mgmtGet("/workspaces"));
|
||||
}
|
||||
|
||||
export async function handleGetWorkspace(args: unknown) {
|
||||
const p = validate(args, GetWorkspaceSchema);
|
||||
return toMcpResult(await mgmtGet(`/workspaces/${encodeURIComponent(p.workspace_id)}`));
|
||||
}
|
||||
|
||||
export async function handleProvisionWorkspace(args: unknown) {
|
||||
const p = validate(args, ProvisionWorkspaceSchema);
|
||||
// Tenant POST /workspaces (AdminAuth — the Org API Key satisfies it).
|
||||
// This is the org-key-reachable provision lever; the CP /cp/workspaces/
|
||||
// provision path needs the provision-secret tier (see cp_admin.ts note).
|
||||
return toMcpResult(
|
||||
await mgmtCall("POST", "/workspaces", {
|
||||
name: p.name,
|
||||
role: p.role,
|
||||
template: p.template,
|
||||
runtime: p.runtime,
|
||||
tier: p.tier,
|
||||
parent_id: p.parent_id,
|
||||
model: p.model,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleDeprovisionWorkspace(args: unknown) {
|
||||
const p = validate(args, DeprovisionWorkspaceSchema);
|
||||
return toMcpResult(await mgmtCall("DELETE", `/workspaces/${encodeURIComponent(p.workspace_id)}`));
|
||||
}
|
||||
|
||||
export async function handleRestartWorkspace(args: unknown) {
|
||||
const p = validate(args, WorkspaceLifecycleSchema);
|
||||
return toMcpResult(await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/restart`, {}));
|
||||
}
|
||||
|
||||
export async function handlePauseWorkspace(args: unknown) {
|
||||
const p = validate(args, WorkspaceLifecycleSchema);
|
||||
return toMcpResult(await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/pause`, {}));
|
||||
}
|
||||
|
||||
export async function handleResumeWorkspace(args: unknown) {
|
||||
const p = validate(args, WorkspaceLifecycleSchema);
|
||||
return toMcpResult(await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/resume`, {}));
|
||||
}
|
||||
|
||||
// Secrets ------------------------------------------------------------------
|
||||
|
||||
export async function handleSetWorkspaceSecret(args: unknown) {
|
||||
const p = validate(args, SetWorkspaceSecretSchema);
|
||||
// POST /workspaces/:id/secrets upserts AES-256-GCM + auto-restarts the ws.
|
||||
return toMcpResult(
|
||||
await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/secrets`, { key: p.key, value: p.value }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleListWorkspaceSecrets(args: unknown) {
|
||||
const p = validate(args, ListWorkspaceSecretsSchema);
|
||||
return toMcpResult(await mgmtGet(`/workspaces/${encodeURIComponent(p.workspace_id)}/secrets`));
|
||||
}
|
||||
|
||||
export async function handleDeleteWorkspaceSecret(args: unknown) {
|
||||
const p = validate(args, DeleteWorkspaceSecretSchema);
|
||||
return toMcpResult(
|
||||
await mgmtCall("DELETE", `/workspaces/${encodeURIComponent(p.workspace_id)}/secrets/${encodeURIComponent(p.key)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleSetOrgSecret(args: unknown) {
|
||||
const p = validate(args, SetOrgSecretSchema);
|
||||
// POST /settings/secrets (AdminAuth) — canonical org-wide secret path.
|
||||
return toMcpResult(await mgmtCall("POST", "/settings/secrets", { key: p.key, value: p.value }));
|
||||
}
|
||||
|
||||
export async function handleListOrgSecrets() {
|
||||
return toMcpResult(await mgmtGet("/settings/secrets"));
|
||||
}
|
||||
|
||||
export async function handleDeleteOrgSecret(args: unknown) {
|
||||
const p = validate(args, DeleteOrgSecretSchema);
|
||||
return toMcpResult(await mgmtCall("DELETE", `/settings/secrets/${encodeURIComponent(p.key)}`));
|
||||
}
|
||||
|
||||
// Budget / billing ---------------------------------------------------------
|
||||
|
||||
export async function handleSetWorkspaceBudget(args: unknown) {
|
||||
const p = validate(args, SetWorkspaceBudgetSchema);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (p.budget_limits !== undefined) body.budget_limits = p.budget_limits;
|
||||
if (p.budget_limit !== undefined) body.budget_limit = p.budget_limit;
|
||||
// PATCH /workspaces/:id/budget (AdminAuth — agents cannot self-clear).
|
||||
return toMcpResult(await mgmtCall("PATCH", `/workspaces/${encodeURIComponent(p.workspace_id)}/budget`, body));
|
||||
}
|
||||
|
||||
export async function handleSetLlmBillingMode(args: unknown) {
|
||||
const p = validate(args, SetLlmBillingModeSchema);
|
||||
// PUT /admin/workspaces/:id/llm-billing-mode. mode:null = clear override.
|
||||
return toMcpResult(
|
||||
await mgmtCall("PUT", `/admin/workspaces/${encodeURIComponent(p.workspace_id)}/llm-billing-mode`, { mode: p.mode }),
|
||||
);
|
||||
}
|
||||
|
||||
// Templates / org import ---------------------------------------------------
|
||||
|
||||
export async function handleListOrgTemplates() {
|
||||
return toMcpResult(await mgmtGet("/org/templates"));
|
||||
}
|
||||
|
||||
export async function handleCreateOrgFromTemplate(args: unknown) {
|
||||
const p = validate(args, CreateOrgFromTemplateSchema);
|
||||
const body: Record<string, unknown> = {};
|
||||
if (p.dir !== undefined) body.dir = p.dir;
|
||||
if (p.template !== undefined) body.template = p.template;
|
||||
if (p.mode !== undefined) body.mode = p.mode;
|
||||
// POST /org/import — creates an entire workspace hierarchy from a template.
|
||||
return toMcpResult(await mgmtCall("POST", "/org/import", body));
|
||||
}
|
||||
|
||||
export async function handleListTemplates() {
|
||||
return toMcpResult(await mgmtGet("/templates"));
|
||||
}
|
||||
|
||||
export async function handleImportTemplate(args: unknown) {
|
||||
const p = validate(args, ImportTemplateSchema);
|
||||
return toMcpResult(await mgmtCall("POST", "/templates/import", { name: p.name, files: p.files }));
|
||||
}
|
||||
|
||||
// Tokens -------------------------------------------------------------------
|
||||
|
||||
export async function handleMintOrgToken(args: unknown) {
|
||||
const p = validate(args, MintOrgTokenSchema);
|
||||
// POST /org/tokens — mints a full-tenant-admin org key. Plaintext shown ONCE.
|
||||
return toMcpResult(await mgmtCall("POST", "/org/tokens", { name: p.name }));
|
||||
}
|
||||
|
||||
export async function handleListOrgTokens() {
|
||||
return toMcpResult(await mgmtGet("/org/tokens"));
|
||||
}
|
||||
|
||||
export async function handleRevokeOrgToken(args: unknown) {
|
||||
const p = validate(args, RevokeOrgTokenSchema);
|
||||
return toMcpResult(await mgmtCall("DELETE", `/org/tokens/${encodeURIComponent(p.id)}`));
|
||||
}
|
||||
|
||||
export async function handleMintWorkspaceToken(args: unknown) {
|
||||
const p = validate(args, MintWorkspaceTokenSchema);
|
||||
// POST /admin/workspaces/:id/tokens — mints a workspace-scoped bearer token.
|
||||
return toMcpResult(await mgmtCall("POST", `/admin/workspaces/${encodeURIComponent(p.workspace_id)}/tokens`, {}));
|
||||
}
|
||||
|
||||
// Plugin allowlist ---------------------------------------------------------
|
||||
|
||||
function resolveOrgId(explicit?: string): string | undefined {
|
||||
return explicit ?? defaultOrgId();
|
||||
}
|
||||
|
||||
export async function handleGetOrgPluginAllowlist(args: unknown) {
|
||||
const p = validate(args, GetOrgPluginAllowlistSchema);
|
||||
const orgId = resolveOrgId(p.org_id);
|
||||
if (!orgId) {
|
||||
return toMcpResult({
|
||||
error: "INVALID_ARGUMENTS",
|
||||
detail: "org_id is required (or set MOLECULE_ORG_ID)",
|
||||
});
|
||||
}
|
||||
return toMcpResult(await mgmtGet(`/orgs/${encodeURIComponent(orgId)}/plugins/allowlist`));
|
||||
}
|
||||
|
||||
export async function handleSetOrgPluginAllowlist(args: unknown) {
|
||||
const p = validate(args, SetOrgPluginAllowlistSchema);
|
||||
const orgId = resolveOrgId(p.org_id);
|
||||
if (!orgId) {
|
||||
return toMcpResult({
|
||||
error: "INVALID_ARGUMENTS",
|
||||
detail: "org_id is required (or set MOLECULE_ORG_ID)",
|
||||
});
|
||||
}
|
||||
// enabled_by is required (validated by the schema) — always send it; the
|
||||
// tenant handler hard-requires it (400 "enabled_by is required" otherwise).
|
||||
const body: Record<string, unknown> = { plugins: p.plugins, enabled_by: p.enabled_by };
|
||||
return toMcpResult(
|
||||
await mgmtCall("PUT", `/orgs/${encodeURIComponent(orgId)}/plugins/allowlist`, body),
|
||||
);
|
||||
}
|
||||
|
||||
// Bundles ------------------------------------------------------------------
|
||||
|
||||
export async function handleExportBundle(args: unknown) {
|
||||
const p = validate(args, ExportBundleSchema);
|
||||
return toMcpResult(await mgmtGet(`/bundles/export/${encodeURIComponent(p.workspace_id)}`));
|
||||
}
|
||||
|
||||
export async function handleImportBundle(args: unknown) {
|
||||
const p = validate(args, ImportBundleSchema);
|
||||
return toMcpResult(await mgmtCall("POST", "/bundles/import", p.bundle));
|
||||
}
|
||||
|
||||
// Events / approvals -------------------------------------------------------
|
||||
|
||||
export async function handleListOrgEvents(args: unknown) {
|
||||
const p = validate(args, ListOrgEventsSchema);
|
||||
const path = p.workspace_id ? `/events/${encodeURIComponent(p.workspace_id)}` : "/events";
|
||||
return toMcpResult(await mgmtGet(path));
|
||||
}
|
||||
|
||||
export async function handleListPendingApprovals() {
|
||||
return toMcpResult(await mgmtGet("/approvals/pending"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerManagementTools(srv: McpServer) {
|
||||
// --- Workspaces lifecycle ---
|
||||
srv.tool(
|
||||
"list_workspaces",
|
||||
"Management: list every workspace in the org with status + hierarchy (Org API Key, tenant host).",
|
||||
{},
|
||||
handleListWorkspaces,
|
||||
);
|
||||
srv.tool(
|
||||
"get_workspace",
|
||||
"Management: get one workspace's detail by UUID.",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleGetWorkspace,
|
||||
);
|
||||
srv.tool(
|
||||
"provision_workspace",
|
||||
"Management: provision a new workspace in the org (tenant POST /workspaces, AdminAuth).",
|
||||
{
|
||||
name: z.string().describe("Workspace name"),
|
||||
role: z.string().optional().describe("Role description"),
|
||||
template: z.string().optional().describe("Template name"),
|
||||
runtime: z.string().optional().describe("Runtime (claude-code, langgraph, codex, …)"),
|
||||
tier: z.number().int().min(1).max(4).optional().describe("Tier 1-4"),
|
||||
parent_id: z.string().optional().describe("Parent workspace UUID"),
|
||||
model: z.string().optional().describe("LLM model id"),
|
||||
},
|
||||
handleProvisionWorkspace,
|
||||
);
|
||||
srv.tool(
|
||||
"deprovision_workspace",
|
||||
"Management: delete/deprovision a workspace (cascades to children).",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleDeprovisionWorkspace,
|
||||
);
|
||||
srv.tool(
|
||||
"restart_workspace",
|
||||
"Management: restart a workspace.",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleRestartWorkspace,
|
||||
);
|
||||
srv.tool(
|
||||
"pause_workspace",
|
||||
"Management: pause a workspace (stops container, preserves config).",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handlePauseWorkspace,
|
||||
);
|
||||
srv.tool(
|
||||
"resume_workspace",
|
||||
"Management: resume a paused workspace.",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleResumeWorkspace,
|
||||
);
|
||||
|
||||
// --- Secrets ---
|
||||
srv.tool(
|
||||
"set_workspace_secret",
|
||||
"Management: set a workspace secret/env var (auto-restarts the workspace).",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
key: z.string().describe("Secret key (e.g. ANTHROPIC_API_KEY)"),
|
||||
value: z.string().describe("Secret value"),
|
||||
},
|
||||
handleSetWorkspaceSecret,
|
||||
);
|
||||
srv.tool(
|
||||
"list_workspace_secrets",
|
||||
"Management: list a workspace's secret keys (values never exposed).",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleListWorkspaceSecrets,
|
||||
);
|
||||
srv.tool(
|
||||
"delete_workspace_secret",
|
||||
"Management: delete a workspace secret.",
|
||||
{ workspace_id: z.string().describe("Workspace UUID"), key: z.string().describe("Secret key") },
|
||||
handleDeleteWorkspaceSecret,
|
||||
);
|
||||
srv.tool(
|
||||
"set_org_secret",
|
||||
"Management: set an org-wide secret (available to all workspaces).",
|
||||
{ key: z.string().describe("Secret key (e.g. GITHUB_TOKEN)"), value: z.string().describe("Secret value") },
|
||||
handleSetOrgSecret,
|
||||
);
|
||||
srv.tool(
|
||||
"list_org_secrets",
|
||||
"Management: list org-wide secret keys (values never exposed).",
|
||||
{},
|
||||
handleListOrgSecrets,
|
||||
);
|
||||
srv.tool(
|
||||
"delete_org_secret",
|
||||
"Management: delete an org-wide secret.",
|
||||
{ key: z.string().describe("Secret key") },
|
||||
handleDeleteOrgSecret,
|
||||
);
|
||||
|
||||
// --- Budget / billing ---
|
||||
srv.tool(
|
||||
"set_workspace_budget",
|
||||
"Management: set per-workspace spend ceilings (USD cents) per period.",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
budget_limits: z
|
||||
.record(z.enum(BUDGET_PERIODS), z.number().int().min(0).nullable())
|
||||
.optional()
|
||||
.describe("Map period→USD-cents (null clears). Periods: hourly, daily, weekly, monthly"),
|
||||
budget_limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Legacy single monthly limit (USD cents)"),
|
||||
},
|
||||
handleSetWorkspaceBudget,
|
||||
);
|
||||
srv.tool(
|
||||
"set_llm_billing_mode",
|
||||
"Management: set a workspace's LLM billing-mode override (platform_managed|byok|disabled, or null to clear).",
|
||||
{
|
||||
workspace_id: z.string().describe("Workspace UUID"),
|
||||
mode: z
|
||||
.enum(["platform_managed", "byok", "disabled"])
|
||||
.nullable()
|
||||
.describe("Mode override; null clears (inherit org default)"),
|
||||
},
|
||||
handleSetLlmBillingMode,
|
||||
);
|
||||
|
||||
// --- Templates / org import ---
|
||||
srv.tool(
|
||||
"list_org_templates",
|
||||
"Management: list the org template catalogue.",
|
||||
{},
|
||||
handleListOrgTemplates,
|
||||
);
|
||||
srv.tool(
|
||||
"create_org_from_template",
|
||||
"Management: create a workspace hierarchy from an org template (POST /org/import).",
|
||||
{
|
||||
dir: z.string().optional().describe("Org template directory name"),
|
||||
template: z.record(z.unknown()).optional().describe("Inline org template object"),
|
||||
mode: z.enum(["merge", "reconcile"]).optional().describe("merge (default) or reconcile"),
|
||||
},
|
||||
handleCreateOrgFromTemplate,
|
||||
);
|
||||
srv.tool(
|
||||
"list_templates",
|
||||
"Management: list available workspace templates.",
|
||||
{},
|
||||
handleListTemplates,
|
||||
);
|
||||
srv.tool(
|
||||
"import_template",
|
||||
"Management: import agent files as a new workspace template.",
|
||||
{
|
||||
name: z.string().describe("Template name"),
|
||||
files: z.record(z.string()).describe("Map of file path → content"),
|
||||
},
|
||||
handleImportTemplate,
|
||||
);
|
||||
|
||||
// --- Tokens ---
|
||||
srv.tool(
|
||||
"mint_org_token",
|
||||
"Management: mint a new Org API Key (FULL TENANT-ADMIN — plaintext shown once).",
|
||||
{ name: z.string().max(100).optional().describe("Human label (max 100 chars)") },
|
||||
handleMintOrgToken,
|
||||
);
|
||||
srv.tool(
|
||||
"list_org_tokens",
|
||||
"Management: list the org's API tokens (prefixes + metadata, never plaintext).",
|
||||
{},
|
||||
handleListOrgTokens,
|
||||
);
|
||||
srv.tool(
|
||||
"revoke_org_token",
|
||||
"Management: revoke an Org API Key by id.",
|
||||
{ id: z.string().describe("Org token id") },
|
||||
handleRevokeOrgToken,
|
||||
);
|
||||
srv.tool(
|
||||
"mint_workspace_token",
|
||||
"Management: mint a workspace-scoped bearer token (e.g. for a remote/external agent).",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleMintWorkspaceToken,
|
||||
);
|
||||
|
||||
// --- Plugin allowlist ---
|
||||
srv.tool(
|
||||
"get_org_plugin_allowlist",
|
||||
"Management: get the org's plugin allowlist (tool governance).",
|
||||
{ org_id: z.string().optional().describe("Org id (defaults to MOLECULE_ORG_ID)") },
|
||||
handleGetOrgPluginAllowlist,
|
||||
);
|
||||
srv.tool(
|
||||
"set_org_plugin_allowlist",
|
||||
"Management: replace the org's plugin allowlist.",
|
||||
{
|
||||
org_id: z.string().optional().describe("Org id (defaults to MOLECULE_ORG_ID)"),
|
||||
plugins: z.array(z.string()).describe("Full allowlist of approved plugin names"),
|
||||
enabled_by: z.string().min(1).describe("Admin workspace id (audit) — REQUIRED by the tenant handler"),
|
||||
},
|
||||
handleSetOrgPluginAllowlist,
|
||||
);
|
||||
|
||||
// --- Bundles ---
|
||||
srv.tool(
|
||||
"export_bundle",
|
||||
"Management: export a workspace as a portable bundle.",
|
||||
{ workspace_id: z.string().describe("Workspace UUID") },
|
||||
handleExportBundle,
|
||||
);
|
||||
srv.tool(
|
||||
"import_bundle",
|
||||
"Management: import a workspace from a bundle JSON object.",
|
||||
{ bundle: z.record(z.unknown()).describe("Bundle JSON object") },
|
||||
handleImportBundle,
|
||||
);
|
||||
|
||||
// --- Events / approvals ---
|
||||
srv.tool(
|
||||
"list_org_events",
|
||||
"Management: list org structure events (optionally filtered to a workspace).",
|
||||
{ workspace_id: z.string().optional().describe("Filter to a workspace, or omit for all") },
|
||||
handleListOrgEvents,
|
||||
);
|
||||
srv.tool(
|
||||
"list_pending_approvals",
|
||||
"Management: list pending approval requests across the org's workspaces.",
|
||||
{},
|
||||
handleListPendingApprovals,
|
||||
);
|
||||
|
||||
// --- CP-tier tools (separate module — Org API Key cannot reach CP) ---
|
||||
registerCpAdminTools(srv);
|
||||
}
|
||||
Reference in New Issue
Block a user