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 freeorg_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’sADMIN_SCHEMAas JSON. The API reads it (cached 5 min) sostage_schema_getandstage_content_addknow 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 insrc/stage/mcp-tools.ts:
src/pages/stage/mcp-tools.json.ts serves the manifest and
src/pages/stage/mcp-tools/[name].ts runs a tool. The Stage API:
- reads the manifest through its DNS-pinned transport (cached 5 min), validates each tool and
lists it as
site_<name>to credentials that carrysite:toolsand an editor+ role; - validates the arguments against the tool’s JSON Schema before anything leaves Stage;
- forwards the call
POST /stage/mcp-tools/<name>withX-Stage-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>and a body of{ arguments, principal: { email, role, org_id, via } }.
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 underapps/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.