diff --git a/README.md b/README.md index a957049..ed603a2 100644 --- a/README.md +++ b/README.md @@ -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** +(`.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://.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": "", + "MOLECULE_ORG_ID": "" + } + } + } +} +``` + ## Remote Agents (Phase 30) For agents running outside the platform's Docker network, the `get_remote_agent_setup_command` diff --git a/src/__tests__/management.test.ts b/src/__tests__/management.test.ts new file mode 100644 index 0000000..9079f01 --- /dev/null +++ b/src/__tests__/management.test.ts @@ -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 { + return (init.headers as Record) || {}; +} + +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`); + }); +}); diff --git a/src/index.ts b/src/index.ts index dfd0844..f8c0590 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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). diff --git a/src/tools/management/client.ts b/src/tools/management/client.ts new file mode 100644 index 0000000..77b03ab --- /dev/null +++ b/src/tools/management/client.ts @@ -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 + * (`.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 | 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 = { + "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 | ApiError): v is Record { + 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( + method: string, + path: string, + body?: unknown, +): Promise { + 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(path: string): Promise { + return mgmtCall("GET", path); +} diff --git a/src/tools/management/cp_admin.ts b/src/tools/management/cp_admin.ts new file mode 100644 index 0000000..e727b23 --- /dev/null +++ b/src/tools/management/cp_admin.ts @@ -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(method: string, path: string): Promise { + 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, + ); +} diff --git a/src/tools/management/index.ts b/src/tools/management/index.ts new file mode 100644 index 0000000..ad81034 --- /dev/null +++ b/src/tools/management/index.ts @@ -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 = {}; + 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 = {}; + 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 = { 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); +}