feat(management): cross-org management MCP registry (Org API Key) #32

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