Skip to main content
One remote MCP server, POST https://api.stage.systems/mcp, in the shared API. Every org connects the same URL; the tools a connection sees are generated per request from the caller’s org, role, scopes and the org’s enabled tools. Content edits go through the same id + path patch and CAS model as /admin. Runbook and architecture: docs/mcp.md in the platform repo.

The one rule

A connection can only ever touch the site(s) it is bound to. A credential names them (an API key’s row, or the sites picked at OAuth consent); tools take no free org_id; every tool runs the same canActOnOrg + canDo gates as the REST routes; the role is the ceiling and scopes only narrow it. A connection approved for several sites (“All sites” at consent) gives every tool a required site argument (name, slug or id). The role can differ per site, so a scope approved because one site allows it is still refused on a site whose role does not. stage_whoami lists the sites. A site’s own (federated) tools are offered on single-site connections only.

Authentication

Unauthenticated requests get a real 401 with WWW-Authenticate: Bearer resource_metadata="https://api.stage.systems/.well-known/oauth-protected-resource/mcp" scope="…". Scopes: content:read content:write publish media:read media:write analytics:read campaigns:read campaigns:write campaigns:publish inbox:read booking site:tools (+ fleet, staff). A tool is offered when the credential has the scope and the role holds the capability and, for flagged tools, the org has the flag.

Tools

stage_content_get with format: "outline" returns each component’s stable id, type and its text fields as {path, value} pairs (props.title, props.items.2.label) — exactly what stage_content_patch addresses. Every write is sanitised server-side, saved with version CAS + three-way merge, stamped updated_by_name = "<email> via MCP", and logged to the activity feed.

What the site must serve

Two optional routes, both in the template:
  • GET /stage/schema.json (src/pages/stage/schema.json.ts) — the site’s ADMIN_SCHEMA as JSON. The API reads it (cached 5 min) so stage_schema_get and stage_content_add know the arrays, item fields and page palettes. Without it, adds still work by copying a sibling item. Health check: platform.schema-route.
  • Your own tools — see below. Health check: platform.site-tools.

Give the site its own tools

A client repo can offer agents tools only it knows how to run — sync the shop’s products, rebuild a feed, run a report. Declare them in src/stage/mcp-tools.ts:
The template’s src/pages/stage/mcp-tools.json.ts serves the manifest and src/pages/stage/mcp-tools/[name].ts runs a tool. The Stage API:
  1. reads the manifest through its DNS-pinned transport (cached 5 min), validates each tool and lists it as site_<name> to credentials that carry site:tools and an editor+ role;
  2. validates the arguments against the tool’s JSON Schema before anything leaves Stage;
  3. forwards the call POST /stage/mcp-tools/<name> with X-Stage-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")> and a body of { arguments, principal: { email, role, org_id, via } }.
The route verifies with verifyStageSignature(secret, header, rawBody) from the SDK (WebCrypto, five-minute skew, constant-time compare) and returns { content } or { error }. The site decides what a given role may run; it never sees a Stage token. Switching a site on is a studio action: scripts/mcp-client-tools.ts <orgId> --on sets settings.mcp_client_tools, mints the per-org secret, and prints it once for the site’s STAGE_MCP_TOOLS_SECRET env.

Testing

The platform repo has six suites under apps/api/test/mcp-*.mjs (isolation, writes, OAuth as a client, the consent page in real Chrome, campaigns, site tools) and an eval set (mcp-eval.mjs): fifteen realistic tasks run through headless Claude Code against a seeded throwaway org, each with a hard check on what must — or must not — have changed, reporting tool calls, turns, time and cost per task.