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
Member

What

Adds the management MCP surface the existing server lacks. Today's registry is single-tenant workspace-ops + A2A against one tenant; this PR adds an org-lifecycle / tenant-admin registry authed with the Org API Key against the per-org tenant host, selected with MOLECULE_MCP_MODE=management.

This extends the existing server (SSOT) — same codebase, same conventions, same ApiError/toMcpResult envelope and validate() input-guarding — run in a distinct mode. The two registries are mutually exclusive in one process because several tool names overlap (list_workspaces, get_workspace, restart/pause/resume_workspace) and the MCP SDK throws on duplicate tool names.

Toolset (§5(a) of PLATFORM-MANAGEMENT-API.md — 31 tools)

Group Tools
Workspaces list_workspaces, get_workspace, provision_workspace, deprovision_workspace, restart_workspace, pause_workspace, resume_workspace
Secrets set/list/delete_workspace_secret, set/list/delete_org_secret
Budget / billing set_workspace_budget, set_llm_billing_mode
Templates / org import list_org_templates, create_org_from_template (POST /org/import), list_templates, import_template
Tokens mint/list/revoke_org_token, mint_workspace_token
Plugin governance get/set_org_plugin_allowlist
Bundles export_bundle, import_bundle
Audit list_org_events, list_pending_approvals
CP-tier (separated + gated) list_orgs, get_org

Contract / SSOT

Each tool's endpoint, HTTP 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. Every route+method was cross-checked against router.go; provision_workspace body fields verified against models.CreateWorkspacePayload.

Note: the OpenAPI spec branch feat/openapi-management-spec now exists on Gitea (molecule-core, HEAD dc7e660e). These schemas remain derived directly from the canonical router/handler source the spec itself is authored from (per the dev-SOP "read the canonical source for internal APIs" rule); where the in-flight spec disagrees with that source, the source wins. Known case: the spec's set_llm_billing_mode / budget_limits shapes (#2056) are stale and are being fixed there separately — the tool shapes here match the live handler, not the stale spec. When the spec lands on main, diff these schemas against it for any remaining drift.

Auth model + security

Authorization: Bearer ${MOLECULE_ORG_API_KEY} + X-Molecule-Org-Id against the tenant host (<slug>.moleculesai.app).

The Org API Key is full-tenant-admin AND self-minting (mint_org_token/revoke_org_token). A management MCP holding one holds tenant root; there is no scope-down today. Documented in client.ts and the README.

The Org API Key cannot reach the control plane (CP /api/v1/orgs/* 401/403 it), so list_orgs/get_org live in a separate cp_admin.ts module, point at the CP, and are gated on CP_ADMIN_API_TOKEN — when absent they return a structured CP_TIER_NOT_CONFIGURED and make no call (gated, not silently broken). Member/billing tools need the same CP session tier and are out of scope for the org-key MCP.

Tests / verification (Phase 4)

  • Build + typecheck: npm run build clean.
  • Tests: npm test → 9 suites, 235 passed, 1 skipped. No regression on the prior 234.
  • management.test.ts (HTTP layer mocked) covers the secret/workspace/token/budget/billing/allowlist tools — asserting exact URL+method+body+auth-headers — plus auth-gating (missing org key → AUTH_ERROR, no call), 401→AUTH_ERROR, 429→RATE_LIMITED, CP-tier gating, schema rejection of bad input, and full registration (31 tools, no duplicates).
  • Incidental package-lock.json peer:true drift from npm install was reverted — not part of this change.

Five-Axis review (Phase 4 SOP — actual output)

Review run on the branch HEAD (3dede1f). Two findings were Required; both are fixed in this push.

1. Correctness2 findings, both Required, both fixed.

  • set_org_plugin_allowlist marked enabled_by optional, but the tenant handler hard-requires it. workspace-server/internal/handlers/org_plugin_allowlist.go:272 returns 400 "enabled_by is required" when the field is empty. An optional schema meant a plausible call would round-trip a 400 instead of being rejected client-side. Fixed: enabled_by is now z.string().min(1) (required) in both SetOrgPluginAllowlistSchema and the srv.tool registration shape, and the body builder always sends it (presence gate dropped). New test asserts a missing-enabled_by call is rejected before fetch.
  • get_llm_billing_mode GET-handler check. Verified GET /admin/workspaces/:id/llm-billing-mode IS served by the tenant handler — GetWorkspaceLLMBillingMode (llm_billing_mode_handler.go), mounted at router.go:180. There is no get_llm_billing_mode tool in this MCP today (only set_llm_billing_mode, a PUT, mounted at router.go:181), so no tool change was needed — the GET route is confirmed present for when a read tool is added (#2056 is adding the GET to the spec in parallel).

2. Authno findings. Tenant tools send Bearer ${MOLECULE_ORG_API_KEY} + X-Molecule-Org-Id; missing key → AUTH_ERROR with no fetch; 401/403 → AUTH_ERROR. CP-tier tools are isolated in cp_admin.ts, gated on CP_ADMIN_API_TOKEN, and return CP_TIER_NOT_CONFIGURED (no call) when absent. The org-key-vs-CP boundary is enforced structurally (separate module + base URL), not by convention.

3. Testsno findings. All inputs are validate()-guarded before any fetch; bad input throws pre-network (asserted for secrets, budget periods, billing mode, over-long token name, and now missing enabled_by). Each tool asserts exact URL+method+body. Full registration test enumerates all 31 tools and asserts no duplicate names. Count: 235 passed / 1 skipped, all 9 suites green; +1 vs. the prior 234 from the new allowlist rejection test.

4. Securityno findings beyond the documented caveat. The self-minting full-tenant-admin nature of the Org API Key is called out in client.ts, the README, and the section above (not a regression — inherent to the credential, no scope-down exists). Secret values are never logged or returned by the list endpoints. URL path segments (key, token id) are encodeURIComponent-escaped to prevent path injection.

5. Altitude / scopeno findings. Changes are confined to the management registry + its test; the spurious lockfile churn was reverted; no drive-by refactors. The fix is the minimal required change (one field required, one presence gate removed, one test added).

Env vars

MOLECULE_MCP_MODE=management, MOLECULE_API_URL (tenant host), MOLECULE_ORG_API_KEY, MOLECULE_ORG_ID; optional MOLECULE_ORG_SLUG, CP_ADMIN_API_TOKEN, MOLECULE_CP_URL. Documented in README.

Do not self-merge (per SOP).

## What Adds the **management MCP surface** the existing server lacks. Today's registry is single-tenant **workspace-ops + A2A** against one tenant; this PR adds an org-lifecycle / tenant-admin registry authed with the **Org API Key** against the per-org tenant host, selected with `MOLECULE_MCP_MODE=management`. This **extends** the existing server (SSOT) — same codebase, same conventions, same `ApiError`/`toMcpResult` envelope and `validate()` input-guarding — run in a distinct mode. The two registries are mutually exclusive in one process because several tool names overlap (`list_workspaces`, `get_workspace`, `restart/pause/resume_workspace`) and the MCP SDK throws on duplicate tool names. ## Toolset (§5(a) of PLATFORM-MANAGEMENT-API.md — 31 tools) | Group | Tools | |---|---| | Workspaces | `list_workspaces`, `get_workspace`, `provision_workspace`, `deprovision_workspace`, `restart_workspace`, `pause_workspace`, `resume_workspace` | | Secrets | `set/list/delete_workspace_secret`, `set/list/delete_org_secret` | | Budget / billing | `set_workspace_budget`, `set_llm_billing_mode` | | Templates / org import | `list_org_templates`, `create_org_from_template` (POST /org/import), `list_templates`, `import_template` | | Tokens | `mint/list/revoke_org_token`, `mint_workspace_token` | | Plugin governance | `get/set_org_plugin_allowlist` | | Bundles | `export_bundle`, `import_bundle` | | Audit | `list_org_events`, `list_pending_approvals` | | **CP-tier (separated + gated)** | `list_orgs`, `get_org` | ## Contract / SSOT Each tool's endpoint, HTTP 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. Every route+method was cross-checked against `router.go`; `provision_workspace` body fields verified against `models.CreateWorkspacePayload`. > Note: the OpenAPI spec branch **`feat/openapi-management-spec` now exists on Gitea** (`molecule-core`, HEAD `dc7e660e`). These schemas remain derived directly from the canonical router/handler source the spec itself is authored from (per the dev-SOP "read the canonical source for internal APIs" rule); where the in-flight spec disagrees with that source, the source wins. Known case: the spec's `set_llm_billing_mode` / `budget_limits` shapes (#2056) are stale and are being fixed there separately — the tool shapes here match the live handler, not the stale spec. When the spec lands on `main`, diff these schemas against it for any remaining drift. ## Auth model + security `Authorization: Bearer ${MOLECULE_ORG_API_KEY}` + `X-Molecule-Org-Id` against the tenant host (`<slug>.moleculesai.app`). ⚠ **The Org API Key is full-tenant-admin AND self-minting** (`mint_org_token`/`revoke_org_token`). A management MCP holding one holds **tenant root**; there is no scope-down today. Documented in `client.ts` and the README. The Org API Key **cannot reach the control plane** (CP `/api/v1/orgs/*` 401/403 it), so `list_orgs`/`get_org` live in a **separate `cp_admin.ts` module**, point at the CP, and are **gated on `CP_ADMIN_API_TOKEN`** — when absent they return a structured `CP_TIER_NOT_CONFIGURED` and make no call (gated, not silently broken). Member/billing tools need the same CP session tier and are out of scope for the org-key MCP. ## Tests / verification (Phase 4) - **Build + typecheck:** `npm run build` clean. - **Tests:** `npm test` → 9 suites, **235 passed**, 1 skipped. No regression on the prior 234. - `management.test.ts` (HTTP layer mocked) covers the secret/workspace/token/budget/billing/allowlist tools — asserting exact URL+method+body+auth-headers — plus auth-gating (missing org key → `AUTH_ERROR`, no call), 401→`AUTH_ERROR`, 429→`RATE_LIMITED`, CP-tier gating, schema rejection of bad input, and full registration (31 tools, no duplicates). - Incidental `package-lock.json` `peer:true` drift from `npm install` was reverted — not part of this change. ## Five-Axis review (Phase 4 SOP — actual output) Review run on the branch HEAD (`3dede1f`). Two findings were Required; both are fixed in this push. **1. Correctness** — *2 findings, both Required, both fixed.* - **`set_org_plugin_allowlist` marked `enabled_by` optional, but the tenant handler hard-requires it.** `workspace-server/internal/handlers/org_plugin_allowlist.go:272` returns `400 "enabled_by is required"` when the field is empty. An optional schema meant a plausible call would round-trip a 400 instead of being rejected client-side. **Fixed:** `enabled_by` is now `z.string().min(1)` (required) in both `SetOrgPluginAllowlistSchema` and the `srv.tool` registration shape, and the body builder always sends it (presence gate dropped). New test asserts a missing-`enabled_by` call is rejected before fetch. - **`get_llm_billing_mode` GET-handler check.** Verified `GET /admin/workspaces/:id/llm-billing-mode` IS served by the tenant handler — `GetWorkspaceLLMBillingMode` (`llm_billing_mode_handler.go`), mounted at `router.go:180`. There is no `get_llm_billing_mode` tool in this MCP today (only `set_llm_billing_mode`, a PUT, mounted at `router.go:181`), so **no tool change was needed** — the GET route is confirmed present for when a read tool is added (#2056 is adding the GET to the spec in parallel). **2. Auth** — *no findings.* Tenant tools send `Bearer ${MOLECULE_ORG_API_KEY}` + `X-Molecule-Org-Id`; missing key → `AUTH_ERROR` with no fetch; 401/403 → `AUTH_ERROR`. CP-tier tools are isolated in `cp_admin.ts`, gated on `CP_ADMIN_API_TOKEN`, and return `CP_TIER_NOT_CONFIGURED` (no call) when absent. The org-key-vs-CP boundary is enforced structurally (separate module + base URL), not by convention. **3. Tests** — *no findings.* All inputs are `validate()`-guarded before any fetch; bad input throws pre-network (asserted for secrets, budget periods, billing mode, over-long token name, and now missing `enabled_by`). Each tool asserts exact URL+method+body. Full registration test enumerates all 31 tools and asserts no duplicate names. Count: 235 passed / 1 skipped, all 9 suites green; +1 vs. the prior 234 from the new allowlist rejection test. **4. Security** — *no findings beyond the documented caveat.* The self-minting full-tenant-admin nature of the Org API Key is called out in `client.ts`, the README, and the section above (not a regression — inherent to the credential, no scope-down exists). Secret values are never logged or returned by the list endpoints. URL path segments (`key`, token `id`) are `encodeURIComponent`-escaped to prevent path injection. **5. Altitude / scope** — *no findings.* Changes are confined to the management registry + its test; the spurious lockfile churn was reverted; no drive-by refactors. The fix is the minimal required change (one field required, one presence gate removed, one test added). ## Env vars `MOLECULE_MCP_MODE=management`, `MOLECULE_API_URL` (tenant host), `MOLECULE_ORG_API_KEY`, `MOLECULE_ORG_ID`; optional `MOLECULE_ORG_SLUG`, `CP_ADMIN_API_TOKEN`, `MOLECULE_CP_URL`. Documented in README. Do not self-merge (per SOP).
sdk-dev added 1 commit 2026-06-01 03:46:53 +00:00
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) <noreply@anthropic.com>
sdk-dev added the tier:medium label 2026-06-01 03:47:02 +00:00
sdk-dev added 1 commit 2026-06-01 05:28:51 +00:00
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) <noreply@anthropic.com>
core-be added 1 commit 2026-06-01 07:18:52 +00:00
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 <noreply@anthropic.com>
core-be added 1 commit 2026-06-01 07:47:15 +00:00
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 <noreply@anthropic.com>
sdk-dev added 1 commit 2026-06-01 09:10:12 +00:00
hongming-ceo-delegated approved these changes 2026-06-01 09:46:21 +00:00
hongming-ceo-delegated left a comment
Member

Approved per comprehensive pre-merge review at HEAD dcd74cba — all 7 verification points pass (get_org reverted to real /api/v1/orgs/:slug; cc97661 path-escaping retained; CP-tier isolation + enabled_by + org-key auth intact; 240 tests green; no doctored tests/secret leak). Recorded under CTO authorization.

Approved per comprehensive pre-merge review at HEAD dcd74cba — all 7 verification points pass (get_org reverted to real /api/v1/orgs/:slug; cc97661 path-escaping retained; CP-tier isolation + enabled_by + org-key auth intact; 240 tests green; no doctored tests/secret leak). Recorded under CTO authorization.
devops-engineer approved these changes 2026-06-01 09:46:21 +00:00
devops-engineer left a comment
Member

Second approval recorded under CTO authorization, backed by the comprehensive review above.

Second approval recorded under CTO authorization, backed by the comprehensive review above.
devops-engineer merged commit 6a6ac18bf3 into main 2026-06-01 09:46:23 +00:00
Sign in to join this conversation.
4 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: molecule-ai/molecule-mcp-server#32