From 393da8762c5633856c1fbdb19804c846dc244cb2 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 20:46:23 -0700 Subject: [PATCH 1/5] feat(management): add cross-org management tool registry (Org API Key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the management MCP surface the legacy single-tenant workspace-ops registry lacks: org-lifecycle / tenant-admin tools authed with the Org API Key against the per-org tenant host. Selected via MOLECULE_MCP_MODE=management (same server + conventions, distinct mode — the two registries are mutually exclusive because several tool names overlap and the MCP SDK throws on duplicates). Ships §5(a) of PLATFORM-MANAGEMENT-API.md (31 tools): - workspaces: list/get/provision/deprovision/restart/pause/resume - secrets: set/list/delete for workspace + org scope - 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/list/revoke_org_token, mint_workspace_token - plugin governance: get/set_org_plugin_allowlist - bundles: export/import_bundle - audit: list_org_events, list_pending_approvals - CP-tier (separated + gated on CP_ADMIN_API_TOKEN): list_orgs, get_org Each tool's endpoint, method, 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. (The feat/openapi-management- spec branch is not yet on Gitea; only the /schedules swaggo stub exists, so types are reconciled directly against the contract source.) Auth model: Authorization: Bearer ${MOLECULE_ORG_API_KEY} + X-Molecule-Org-Id against the tenant host. The Org API Key is full-tenant-admin AND self-minting (mint_org_token) — a holder has tenant root; documented in code + README. The Org API Key cannot reach the control plane, so list_orgs/get_org live in a separate cp_admin module and return CP_TIER_NOT_CONFIGURED (no network call) unless CP_ADMIN_API_TOKEN is set — gated, never silently broken. Reuses the existing ApiError + toMcpResult envelope (SSOT for the response shape) and validate() input-guarding. Adds 32 unit tests (HTTP layer mocked) covering the secret/workspace/token/budget/ billing/allowlist tools, auth-gating, CP gating, and registration. Tests: 9 suites, 234 passed (+32). Build + typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 95 +++++ src/__tests__/management.test.ts | 407 ++++++++++++++++++++ src/index.ts | 65 +++- src/tools/management/client.ts | 130 +++++++ src/tools/management/cp_admin.ts | 116 ++++++ src/tools/management/index.ts | 617 +++++++++++++++++++++++++++++++ 6 files changed, 1428 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/management.test.ts create mode 100644 src/tools/management/client.ts create mode 100644 src/tools/management/cp_admin.ts create mode 100644 src/tools/management/index.ts 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..7f78adb --- /dev/null +++ b/src/__tests__/management.test.ts @@ -0,0 +1,407 @@ +/** + * 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, +} 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("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"); + }); +}); 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..1b3fcc8 --- /dev/null +++ b/src/tools/management/index.ts @@ -0,0 +1,617 @@ +/** + * 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)"), + enabled_by: z.string().optional().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/${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/${p.workspace_id}`)); +} + +export async function handleRestartWorkspace(args: unknown) { + const p = validate(args, WorkspaceLifecycleSchema); + return toMcpResult(await mgmtCall("POST", `/workspaces/${p.workspace_id}/restart`, {})); +} + +export async function handlePauseWorkspace(args: unknown) { + const p = validate(args, WorkspaceLifecycleSchema); + return toMcpResult(await mgmtCall("POST", `/workspaces/${p.workspace_id}/pause`, {})); +} + +export async function handleResumeWorkspace(args: unknown) { + const p = validate(args, WorkspaceLifecycleSchema); + return toMcpResult(await mgmtCall("POST", `/workspaces/${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/${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/${p.workspace_id}/secrets`)); +} + +export async function handleDeleteWorkspaceSecret(args: unknown) { + const p = validate(args, DeleteWorkspaceSecretSchema); + return toMcpResult( + await mgmtCall("DELETE", `/workspaces/${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/${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/${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/${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)", + }); + } + const body: Record = { plugins: p.plugins }; + if (p.enabled_by !== undefined) body.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/${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/${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().optional().describe("Admin workspace id (audit)"), + }, + 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); +} -- 2.52.0 From 3dede1fe404ea825ad3e4362f1969d4bf89d78f6 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Sun, 31 May 2026 22:28:36 -0700 Subject: [PATCH 2/5] fix(management): require enabled_by on set_org_plugin_allowlist (#32 review) Five-Axis review of PR #32 found set_org_plugin_allowlist marked enabled_by optional, but the tenant PutAllowlist handler hard-requires it (workspace-server/internal/handlers/org_plugin_allowlist.go:272 -> 400 "enabled_by is required"). An optional field meant a valid-looking call would round-trip a 400 instead of being rejected client-side. - Make enabled_by REQUIRED (z.string().min(1)) in both SetOrgPluginAllowlistSchema and the srv.tool registration shape. - Always send enabled_by in the body builder (drop the presence gate) since the schema now guarantees it. - Add a test asserting a missing-enabled_by call is rejected before fetch, mirroring the existing required-field rejection tests. Verified GET /admin/workspaces/:id/llm-billing-mode is served by the tenant handler (GetWorkspaceLLMBillingMode, router.go:180) so no billing-mode tool change is needed. build: tsc green. test: 235 passed (+1 new) / 1 skipped, all 9 suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/management.test.ts | 9 +++++++++ src/tools/management/index.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/__tests__/management.test.ts b/src/__tests__/management.test.ts index 7f78adb..252fa2c 100644 --- a/src/__tests__/management.test.ts +++ b/src/__tests__/management.test.ts @@ -335,6 +335,15 @@ describe("plugin allowlist tools", () => { 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({}); diff --git a/src/tools/management/index.ts b/src/tools/management/index.ts index 1b3fcc8..b13821d 100644 --- a/src/tools/management/index.ts +++ b/src/tools/management/index.ts @@ -142,7 +142,9 @@ const GetOrgPluginAllowlistSchema = z.object({ 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)"), - enabled_by: z.string().optional().describe("Workspace id of the admin making the change (audit)"), + // 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 ------------------------------------------------------------------ @@ -344,8 +346,9 @@ export async function handleSetOrgPluginAllowlist(args: unknown) { detail: "org_id is required (or set MOLECULE_ORG_ID)", }); } - const body: Record = { plugins: p.plugins }; - if (p.enabled_by !== undefined) body.enabled_by = p.enabled_by; + // 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), ); @@ -579,7 +582,7 @@ export function registerManagementTools(srv: McpServer) { { 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().optional().describe("Admin workspace id (audit)"), + enabled_by: z.string().min(1).describe("Admin workspace id (audit) — REQUIRED by the tenant handler"), }, handleSetOrgPluginAllowlist, ); -- 2.52.0 From 88a3f002aded878936444a231db723d79cffdfe6 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 1 Jun 2026 07:18:29 +0000 Subject: [PATCH 3/5] fix(mcp): use admin org endpoint for get_org CP-tier tool MiniMax review finding: handleGetOrg called /api/v1/orgs/:slug which is the customer-facing session-gated route. CP-admin tools must use the admin surface /api/v1/admin/orgs/:slug so the CP_ADMIN_API_TOKEN is routed to AdminGate rather than RequireSession. Co-Authored-By: Claude Opus 4.7 --- src/tools/management/cp_admin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/management/cp_admin.ts b/src/tools/management/cp_admin.ts index e727b23..0b1380f 100644 --- a/src/tools/management/cp_admin.ts +++ b/src/tools/management/cp_admin.ts @@ -96,8 +96,8 @@ export async function handleListOrgs() { 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)}`)); + // GET /api/v1/admin/orgs/:slug — admin-tier org detail (CP admin bearer). + return toMcpResult(await cpCall("GET", `/api/v1/admin/orgs/${encodeURIComponent(p.slug)}`)); } export function registerCpAdminTools(srv: McpServer) { -- 2.52.0 From cc976616f1ca647bb1585756cf712abcf7945645 Mon Sep 17 00:00:00 2001 From: "Molecule AI Dev Engineer A (Kimi)" Date: Mon, 1 Jun 2026 07:47:08 +0000 Subject: [PATCH 4/5] fix(management): path-escape all user-controlled URL segments (PR #32 review) Every handler that interpolates a caller-supplied workspace_id (or other ID) into a request path now wraps it in encodeURIComponent. This matches the pattern already used for secret keys, org token ids, and org ids, and closes the robustness gap CR2 flagged on CLI PR #13 (same class of issue). Also fixes the stale get_org test which asserted the old non-admin endpoint /api/v1/orgs/:slug instead of the corrected /api/v1/admin/orgs/:slug. Tests: add 5 path-escaping assertions covering workspace lifecycle, secrets, budget/billing-mode, token mint, bundle export, and events filter. All 38 management tests pass. Co-Authored-By: Claude Opus 4.7 --- src/__tests__/management.test.ts | 76 +++++++++++++++++++++++++++++++- src/tools/management/index.ts | 26 +++++------ 2 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/__tests__/management.test.ts b/src/__tests__/management.test.ts index 252fa2c..7796900 100644 --- a/src/__tests__/management.test.ts +++ b/src/__tests__/management.test.ts @@ -46,6 +46,12 @@ import { import { handleProvisionWorkspace as mgmtProvisionWorkspace, handleListWorkspaces as mgmtListWorkspaces, + handleGetWorkspace, + handleRestartWorkspace, + handlePauseWorkspace, + handleResumeWorkspace, + handleExportBundle, + handleListOrgEvents, } from "../tools/management/index.js"; const ORG_KEY = "org_testkey_abcdef"; @@ -370,7 +376,7 @@ describe("CP-tier tools (separated, gated)", () => { 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(url).toBe("https://api.moleculesai.app/api/v1/admin/orgs/agents-team"); expect(headersOf(init).Authorization).toBe("Bearer cp_admin_token"); }); }); @@ -414,3 +420,71 @@ describe("registration + 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/tools/management/index.ts b/src/tools/management/index.ts index b13821d..ad81034 100644 --- a/src/tools/management/index.ts +++ b/src/tools/management/index.ts @@ -174,7 +174,7 @@ export async function handleListWorkspaces() { export async function handleGetWorkspace(args: unknown) { const p = validate(args, GetWorkspaceSchema); - return toMcpResult(await mgmtGet(`/workspaces/${p.workspace_id}`)); + return toMcpResult(await mgmtGet(`/workspaces/${encodeURIComponent(p.workspace_id)}`)); } export async function handleProvisionWorkspace(args: unknown) { @@ -197,22 +197,22 @@ export async function handleProvisionWorkspace(args: unknown) { export async function handleDeprovisionWorkspace(args: unknown) { const p = validate(args, DeprovisionWorkspaceSchema); - return toMcpResult(await mgmtCall("DELETE", `/workspaces/${p.workspace_id}`)); + 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/${p.workspace_id}/restart`, {})); + 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/${p.workspace_id}/pause`, {})); + 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/${p.workspace_id}/resume`, {})); + return toMcpResult(await mgmtCall("POST", `/workspaces/${encodeURIComponent(p.workspace_id)}/resume`, {})); } // Secrets ------------------------------------------------------------------ @@ -221,19 +221,19 @@ 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/${p.workspace_id}/secrets`, { key: p.key, value: p.value }), + 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/${p.workspace_id}/secrets`)); + 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/${p.workspace_id}/secrets/${encodeURIComponent(p.key)}`), + await mgmtCall("DELETE", `/workspaces/${encodeURIComponent(p.workspace_id)}/secrets/${encodeURIComponent(p.key)}`), ); } @@ -260,14 +260,14 @@ export async function handleSetWorkspaceBudget(args: 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/${p.workspace_id}/budget`, body)); + 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/${p.workspace_id}/llm-billing-mode`, { mode: p.mode }), + await mgmtCall("PUT", `/admin/workspaces/${encodeURIComponent(p.workspace_id)}/llm-billing-mode`, { mode: p.mode }), ); } @@ -316,7 +316,7 @@ export async function handleRevokeOrgToken(args: unknown) { 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/${p.workspace_id}/tokens`, {})); + return toMcpResult(await mgmtCall("POST", `/admin/workspaces/${encodeURIComponent(p.workspace_id)}/tokens`, {})); } // Plugin allowlist --------------------------------------------------------- @@ -358,7 +358,7 @@ export async function handleSetOrgPluginAllowlist(args: unknown) { export async function handleExportBundle(args: unknown) { const p = validate(args, ExportBundleSchema); - return toMcpResult(await mgmtGet(`/bundles/export/${p.workspace_id}`)); + return toMcpResult(await mgmtGet(`/bundles/export/${encodeURIComponent(p.workspace_id)}`)); } export async function handleImportBundle(args: unknown) { @@ -370,7 +370,7 @@ export async function handleImportBundle(args: unknown) { export async function handleListOrgEvents(args: unknown) { const p = validate(args, ListOrgEventsSchema); - const path = p.workspace_id ? `/events/${p.workspace_id}` : "/events"; + const path = p.workspace_id ? `/events/${encodeURIComponent(p.workspace_id)}` : "/events"; return toMcpResult(await mgmtGet(path)); } -- 2.52.0 From dcd74cba6ef54ba25565f313c239be307a60c267 Mon Sep 17 00:00:00 2001 From: sdk-dev Date: Mon, 1 Jun 2026 02:10:03 -0700 Subject: [PATCH 5/5] =?UTF-8?q?fix(mcp):=20revert=20get=5Forg=20to=20real?= =?UTF-8?q?=20/orgs/:slug=20route=20=E2=80=94=2088a3f00=20pointed=20at=20n?= =?UTF-8?q?on-existent=20/api/v1/admin/orgs/:slug=20(404);=20keep=20cc9766?= =?UTF-8?q?1=20path-escaping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/management.test.ts | 2 +- src/tools/management/cp_admin.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__tests__/management.test.ts b/src/__tests__/management.test.ts index 7796900..9079f01 100644 --- a/src/__tests__/management.test.ts +++ b/src/__tests__/management.test.ts @@ -376,7 +376,7 @@ describe("CP-tier tools (separated, gated)", () => { 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/admin/orgs/agents-team"); + expect(url).toBe("https://api.moleculesai.app/api/v1/orgs/agents-team"); expect(headersOf(init).Authorization).toBe("Bearer cp_admin_token"); }); }); diff --git a/src/tools/management/cp_admin.ts b/src/tools/management/cp_admin.ts index 0b1380f..e727b23 100644 --- a/src/tools/management/cp_admin.ts +++ b/src/tools/management/cp_admin.ts @@ -96,8 +96,8 @@ export async function handleListOrgs() { export async function handleGetOrg(args: unknown) { const p = validate(args, GetOrgSchema); if (!cpConfigured()) return toMcpResult(cpNotConfigured("get_org")); - // GET /api/v1/admin/orgs/:slug — admin-tier org detail (CP admin bearer). - return toMcpResult(await cpCall("GET", `/api/v1/admin/orgs/${encodeURIComponent(p.slug)}`)); + // 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) { -- 2.52.0