> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userefract.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Platform MCP

> Super-admin MCP control plane — human OAuth, shared catalog, registry-only tools, call-time RBAC, and portal-only governance.

Platform MCP lets a **human super admin** drive the control plane from Cursor (or another MCP client). It is a dedicated HTTP server at `POST /mcp`, not a wrapper around GraphQL, and v1 is **not** a machine PAT.

If you only remember one sentence: **catalog metadata lives in shared, handlers live in the backend registry, portal governance is never an MCP tool.**

<details>
  <summary>🤖 Agent context</summary>

  Canonical guide: `apps/backend/src/mcp/AGENTS.md`. Cursor/Claude: `.cursor/rules/mcp-platform.mdc` / `.claude/rules/mcp-platform.md`.

  Auth isolation: `MCP_BEARER_ACCEPTED_AUDIENCES` = `mcp` only. Do **not** add `mcp` to `GRAPHQL_BEARER_ACCEPTED_AUDIENCES`.

  Register tools with `defineMcpTool` + `buildDefaultPlatformMcpRegistry`. Empty `requiredScopes` is forbidden. Handlers call existing domain utilities — no Sequelize in `mcp/tools/`.

  Portal cockpit renders GraphQL `platformMcpCatalog` (mapped from shared `PLATFORM_MCP_TOOL_CATALOG` after `mcp:catalog:view`), plus `platformMcpUsers`, `mcpToolAudits`, and MCP `activeSessions`. Never import `apps/backend/src/mcp/tools/`.

  ESLint: `refract/mcp-tools-must-use-registry`, `refract/mcp-tool-requires-scopes`, `refract/no-portal-audience-in-mcp`, `refract/no-mcp-tools-in-admin-governance`, `refract/no-sequelize-in-mcp-handlers`.

  Verify with: `make test module=backend path=apps/backend/src/mcp/__tests__/contracts` and `make test module=tooling-eslint-refract`.
</details>

## Why this exists

Operators already have session governance in the portal. MCP is the same actions, same scopes, same finalizers — reachable from an agent session so a super admin can list and revoke credentials without clicking through the UI. The cage is RBAC: `tools/list` hides what you cannot call, and `tools/call` checks again.

## Locked decisions

| Decision | Choice |
| - | - |
| Who | Super admin **humans** only (`is_super_admin`). Non–super-admin OAuth is `access_denied`. |
| Credential | `user_session` + `user_refresh`, `audience: mcp`, `auth_method: mcp_oauth` |
| Transport | Streamable HTTP `POST /mcp` (JSON-RPC). Batches unsupported. Notifications → HTTP 202. |
| Kill switch | `mcp.enabled` (`MCP_ENABLED`). False unmounts `/mcp`, MCP OAuth, and MCP well-known. |
| DCR allowList | Per-env `mcp.allowList` (loopback always allowed). Details: [Authentication](/architecture/authentication) §9.1.1. |
| GraphQL | MCP tokens **cannot** call GraphQL. Portal tokens **cannot** call `/mcp`. |
| Registry | Shared metadata + backend `defineMcpTool`. No SDK `server.tool()`. Runtime handle: `buildPlatformMcp` → `tools.mcp`. |
| Scopes | Dedicated `mcp:*` plus existing session scopes. Empty scopes forbidden even for super admin. |
| Governance UI | Portal only (`/admin/super/mcp`). Catalog / audit-view / access-view scopes stay off the MCP catalog. |
| Machine PATs / Tenant MCP | Later. Same `auth_credentials` table. No second auth stack. |

## Sequence

```mermaid theme={null}
sequenceDiagram
  participant Client as MCP client
  participant OAuth as /auth/mcp
  participant Portal as Portal cookies
  participant MCP as POST /mcp
  participant RBAC as canAccessScopes
  participant Domain as sessionGovernance etc.

  Client->>OAuth: PKCE authorize
  OAuth->>Portal: human session (consent)
  OAuth-->>Client: audience=mcp tokens
  Client->>MCP: tools/list Bearer mcp
  MCP->>RBAC: filter by requiredScopes
  MCP-->>Client: visible tools
  Client->>MCP: tools/call
  MCP->>RBAC: re-check scopes
  MCP->>Domain: same finalizer as GraphQL
  MCP-->>Client: result + correlation id
```

OAuth authorize borrows **portal cookies** so the human can say yes. The minted credential is still `audience=mcp`. Do not put the portal access token in `mcp.json`.

## Layout

| Piece | Path |
| - | - |
| Catalog + scopes + protocol | `apps/shared/src/mcp/` |
| `defineMcpTool` / registry | `apps/backend/src/mcp/registry.ts`, `platformRegistry.ts` |
| Runtime handle | `apps/backend/src/mcp/buildPlatformMcp.ts` → `tools.mcp` |
| Handlers | `apps/backend/src/mcp/tools/` |
| JSON-RPC | `apps/backend/src/mcp/jsonRpc.ts` |
| Bearer gate | `apps/backend/src/middleware/mcpAuth.ts` |
| OAuth + DCR | `apps/backend/src/routers/auth/mcp.ts`, `apps/backend/src/utilities/mcpOAuth.ts` |
| Audit rows | `mcp_tool_audits` via `apps/backend/src/utilities/mcpToolAudit.ts` |
| GraphQL audit read | `mcpToolAudits` (`mcp:audit:view`, super admin) |
| Portal cockpit | `/admin/super/mcp` — GraphQL `platformMcpCatalog` (`mcp:catalog:view`, mapped from shared catalog), `platformMcpUsers` (`mcp:access:view`), `mcpToolAudits`. Never registered as MCP tools. |

Unknown input fields are rejected (`z.object({}).strict()` / `additionalProperties: false`). `AllScopes` members must be string literals — TypeScript will not let you initialize an enum from an imported constant.

## Observability

Every `tools/call` (success **and** deny) logs a stable message, bumps `mcp.tools.call` with tags `tool` / `outcome` / `server=platform`, and writes `mcp_tool_audits` (actor, credential id, tool, outcome, correlation id, argument digest). Never raw tokens or raw args.

## Adding a tool

Follow the checklist in `apps/backend/src/mcp/AGENTS.md`. Short version: shared catalog → `defineMcpTool` handler that calls a domain utility → register in `buildDefaultPlatformMcpRegistry` → contract tests for success, deny (`canAccessScopes` stub), and invalid input.

Related auth model: [Authentication](/architecture/authentication) §9.1. Session UI: `/admin/super/sessions`. Portal cockpit: `/admin/super/mcp`.
