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

# MCP server

> Operating the Soundcheck Model Context Protocol endpoint on api-events.

Soundcheck MCP is hosted **in-process on api-events**. The `@soundcheck/mcp` package (`mcp-ts/`) is the catalog + Streamable HTTP handler library. api-events executes tools via `DirectBackend` → existing `*Operation` functions. CheckAI calls the same catalog out of process via `HttpToolBackend` against `API_BASE_URL`.

End-user setup for Claude Code and Cursor lives in [Soundcheck MCP server](https://docs.soundchecklive.io/integrations/mcp-server.md). This page covers what an operator needs.

## Architecture

```text theme={null}
[Claude / Cursor / CheckAI]
       │  OAuth → Clerk (Authorization Code + PKCE)
       │  Authorization: Bearer <clerk_jwt>
       ▼
[api-events Encore]   /mcp  /public/mcp  /.well-known/oauth-protected-resource
       │  validates JWT in-handler (routes are auth: false so 401 can send PRM)
       │  DirectBackend.exec(op) → *Operation
       ▼
[PostgreSQL]
```

The MCP transport is **stateless**. It does not own a database; sessions exist only inside the MCP transport.

## Source layout

| Path | Purpose |
| - | - |
| `mcp-ts/src/exports.ts` | Public SDK surface (`handleMcpRequest`, backends, catalog). |
| `mcp-ts/src/handler.ts` | Streamable HTTP + `SERVER_*` / instructions. |
| `mcp-ts/src/backend.ts` | `SoundcheckBackend.exec(op, input?, body?)`. |
| `mcp-ts/src/httpBackend.ts` | CheckAI adapter over the generated OpenAPI client. |
| `api-events/src/systems/mcp/` | Encore routes, `DirectBackend`, Clerk + rate-limit wiring. |
| `mcp-ts/src/tools/catalog.ts` | Declarative tool definitions (reads, writes, composites). Clone-org tools live in `catalog-gap.ts` (`export_workspace`, `preview_import_file`, `import_file`). |

Opt-in 1:1 tools are annotated with sibling `mcpTool()` next to `openapiRoute()`. Emit JSON Schema ToolDefs with `cd api-events && npm run mcp:emit`.

## Local development

```bash theme={null}
npm run dev:infra
cd api-events && npm run db:migrate && npm run db:seed:demo-full
cd api-events && npm run dev:emulator
```

Then point [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at `http://localhost:4000/mcp`. CheckAI uses `API_BASE_URL=http://localhost:4000` (no Soundcheck `MCP_URL`).

## OAuth 2.0 Protected Resource

api-events exposes [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728.html) PRM at:

```text theme={null}
GET /.well-known/oauth-protected-resource
GET /.well-known/oauth-protected-resource/mcp
```

and [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414.html) authorization-server metadata (live Clerk AS document, including CIMD when Clerk advertises it) at:

```text theme={null}
GET /.well-known/oauth-authorization-server
GET /.well-known/oauth-authorization-server/mcp
```

OpenAI Apps domain verification (plain-text token, no auth):

```text theme={null}
GET /.well-known/openai-apps-challenge
```

Override the token with `OPENAI_APPS_CHALLENGE` (production defaults to the pinned verification value when unset).

Spec-compliant MCP clients read PRM on 401 and run the OAuth flow against Clerk. Older clients that fetch AS metadata from the resource origin get the same Clerk authorize, token, and DCR endpoints. Hosted API and MCP hosts fill `authorization_servers` from the resource URL when `OAUTH_ISSUER` is unset (`HOSTED_MCP_OAUTH_ISSUERS` in `api-events/src/config/config.ts`).

## Public MCP hostname (same Cloud Run)

MCP is in-process on api-events. The public connect hostname is a **third cert + host rule** on the existing api-events ALB (`mcp_domain_name` on `infra-gcp/modules/events`). CheckAI does not use these hosts; it talks to api-events via `API_BASE_URL`. There is no leftover dedicated MCP Cloud Run in any GCP env state.

| Env | Host | `MCP_RESOURCE_URL` | Squarespace CNAME | |
| - | - | - | - | - |
| prod | `mcp.soundchecklive.io` | `https://mcp.soundchecklive.io/mcp` | `api-events.soundchecklive.io` | |
| qa | `qa-mcp.soundchecklive.io` | `https://qa-mcp.soundchecklive.io/mcp` | `qa-api-events.soundchecklive.io` | |
| dev | `dev-mcp.soundchecklive.io` | `https://dev-mcp.soundchecklive.io/mcp` | `dev-api-events.soundchecklive.io` | |

The MCP host is path-restricted to `/mcp`, `/public/mcp`, and `/.well-known/*`. Member connect stays `https://<host>/mcp` (not the origin root). Do not advertise these URLs until Squarespace CNAMEs exist and the managed cert is **ACTIVE** (ENG-613). Apply order (Cloud Agent or human only) is in `infra-gcp/README.md`.

```hcl theme={null}
mcp_domain_name = "dev-mcp.soundchecklive.io"
# extra_cloud_run_env.MCP_RESOURCE_URL = "https://dev-mcp.soundchecklive.io/mcp"
```

## Server tracing (telemetry.dev)

`api-events` emits one OpenTelemetry SERVER span per JSON-RPC request on `/mcp` and `/public/mcp` (`api-events/src/observability/mcpServerSpans.ts`), exported over OTLP/HTTP to telemetry.dev so client and server spans pair up. The MCP SDK is v1, so the vendor's `@telemetry-dev/mcp` wrapper (v2 only) is not used.

* **Parent context:** W3C `traceparent` / `tracestate` from `params._meta` first, then the HTTP `traceparent` header. **Clients must inject `_meta`** — a header alone is not enough in practice, because proxies in front of the server (the harness `/api/mcp-proxy` among them) forward only an allow-list of headers and drop `traceparent`. Header-only runs show up as orphan client spans. CheckAI does this in `checkai/agent/lib/mcpClientTracing.ts`. Neither present means a new root span.
* **Attributes:** `mcp.method.name`, `mcp.tool.name` / `gen_ai.tool.name` (on `tools/call`), `gen_ai.operation.name`, `mcp.tier` (`member` / `public`), `mcp.confirmation_required` (the call returned a confirmation prompt), `jsonrpc.request.id`, `mcp.protocol.version`, `http.response.status_code`, `error.type`.
* **Never captured:** tool arguments or results, tool error messages, `confirmation_token`, bearer tokens, org ids.
* **Client-supplied values are validated before export:** methods must be known MCP methods, tool names must be in the tier's catalog (otherwise the span is just `tools/call`), request ids are kept only when integer or UUID, and protocol versions only when `YYYY-MM-DD`. At most 16 messages per request are traced.
* **Batches:** the invocation hook has no request id, so when one batch calls the same tool twice those spans carry no `mcp.confirmation_required` / tool `error.type` rather than a guessed one.
* **Notifications** produce no span. `mcp.confirmation_required` and tool `error.type` come from the mutating-tool invocation hook, so read-only tools always report `false`.
* The provider is private (never registered globally), so Sentry's trace sampling and the PostHog trace exporter are unaffected.

| Variable | Purpose | Default |
| - | - | - |
| `TELEMETRY_DEV_API_KEY` | Ingest key (Secret Manager `{env}-TelemetryDevAPIKey`). Unset or `PLACEHOLDER` = spans off | — |
| `MCP_TELEMETRY_DEV_ENABLED` | Kill switch; `false` turns spans off with the key still mounted | `true` |
| `TELEMETRY_DEV_ENVIRONMENT` | `deployment.environment.name` resource attribute | `development` |
| `TELEMETRY_DEV_SERVICE_NAME` | `service.name` resource attribute | `soundcheck-mcp-server` |
| `TELEMETRY_DEV_TRACES_URL` | OTLP traces endpoint (point at a local collector to smoke-test) | `https://ingest.telemetry.dev/v1/traces` |

Only dev enables it (`enable_telemetry_dev_api_key = true` in `infra-gcp/environments/dev`). After `terraform apply`, add the key and roll a revision:

```bash theme={null}
printf '%s' "$TELEMETRY_DEV_API_KEY" | gcloud secrets versions add dev-TelemetryDevAPIKey \
  --project dev-soundcheck-live --data-file=-
```

qa/prod stay off until the flag is set there after a redaction review.

### Tool-call errors agents can act on

`tools/call` is checked before the MCP SDK sees it (`mcp-ts/src/tools/preflight.ts`), because the SDK answers an unknown tool or a schema miss with bare prose and nothing is recorded. Both now come back as the usual JSON envelope with `isError` and `structuredContent`:

* **Unknown tool** → `{"code":"unknown_tool","tool":"list_org_call_lists","did_you_mean":["get_staffing_plan"]}`. Nothing ran, so no audit row is written.
* **Bad arguments** → `{"code":"invalid_arguments","tool":"…","missing_fields":[…],"issues":[{"field":"…","message":"…"}]}`, recorded as an `error` invocation.

`create_event_invitations` is event-scoped (no `organization_id`) and takes `invitations:[{email|user_id|phone_number, …}]`, or the `emails:["sam@example.com"]` shorthand. With valid arguments it returns `confirmation_required` while `MCP_CONFIRMATION_SECRET` is set, never a silent failure.

## MCP Apps (SEP-1865)

The member tier can serve [MCP Apps](https://modelcontextprotocol.io/extensions/apps): interactive HTML views that hosts implementing the extension render in a sandboxed iframe next to a tool result. Code lives in `mcp-ts/src/apps/`: `viewRuntime.ts` (shared bridge, theme, widget styles, and confirm step), `confirmCard.ts` (ENG-855 Confirm Card), `planReview.ts` (ENG-856 Plan Review), `staffingDraft.ts` (ENG-857 Staffing Draft), `gigs.ts` (Gigs, with OpenAI entrypoints), `toolViews.ts` (which catalog tools link to which view and which results carry view data), `helloWorld.ts` (ENG-854 spike: tool `show_hello_world_app` renders `ui://soundcheck/hello-world.html`), and `register.ts`.

* **Product views are on in every environment; the hello-world spike is dev-only.** `handleMcpRequest` always registers the Confirm Card, Plan Review, Staffing Draft, and Gigs resources, the app-only `reject_confirmation` tool, the card link on gated member tools, and the view links in `toolViews.ts`. The public tier never gets MCP Apps. The ENG-854 hello-world tool and resource are registered only with `handleMcpRequest({ mcpAppsSpike: true })`: api-events sets that from `mcpConfig().mcpAppsSpikeEnabled = !isDeployedEnvironment()` → `runtime.mcpAppsSpike`, so qa/prod `tools/list` never lists `show_hello_world_app`. There is no feature flag.
* **No SDK upgrade.** The wire contract is plain `_meta`, which SDK v1.30 already passes through on tools, resources, content items, and `CallToolResult`: the tool has `_meta.ui.resourceUri` (plus the legacy `_meta["ui/resourceUri"]` key), and the resource is `text/html;profile=mcp-app` with `_meta.ui.csp` / `prefersBorder`. `@modelcontextprotocol/ext-apps` 2.x needs SDK v2 (`@modelcontextprotocol/server`) and zod 4. 1.7.x works with SDK `^1.29` and zod 3 if we later want its `App` class or React hooks in the view. `@mcp-ui/server` 6.x still pins ext-apps `^0.3`; skip it.
* **Views are self-contained HTML built from `viewRuntime.ts`.** `VIEW_BRIDGE_JS` is a hand-rolled postMessage JSON-RPC client (`scCreateBridge`): `ui/initialize` → `ui/notifications/initialized`, then `tool-input` / `tool-result` / `host-context-changed` / `size-changed`, and `tools/call` / `ui/update-model-context` back through the host. `VIEW_THEME_CSS` maps host variables onto `--sc-*` tokens for light and dark. `mcpAppHtml()` assembles a page. No build step, no external scripts, styles, or fonts. Views set dynamic text with `textContent` only.
* **Text fallback.** Every app tool returns normal `content` text plus `structuredContent`. Clients without MCP Apps support ignore `_meta` and read the text. The server cannot detect UI support per client, because the transport is stateless and `initialize` capabilities are gone by the next request. So the same response has to work both ways.

### Confirm Card (ENG-855)

Every confirmation-gated member tool (any tool with a `confirm` callback or a `confirmation_token` argument, including the custom-gated `send_message`, `broadcast_event_reminder`, and `reply_to_mailbox_thread`) links to `ui://soundcheck/confirm-card.html`. When the tool returns `confirmation_required`, hosts that support MCP Apps (Cursor, Claude) render the card: the summary, an optional structured preview, a live "Expires in …" countdown, "Nothing is sent or changed until you approve.", and **Approve** / **Reject**.

* **What changes for tools-only clients.** `tools/list` gains `_meta` on gated tools plus the `reject_confirmation` entry. Hosts that honour `visibility` hide that tool from the model. Other clients may show it, but its description says it is not for agents, and calling it can only void an unused token, never perform an action. Tool result `content` and `structuredContent` do not change.
* **Result `_meta`, not the body.** The card's data rides in `CallToolResult._meta["soundcheck/confirm"] = { tool, arguments (without confirmation_token), preview? }`. `content` and `structuredContent` are unchanged, so tools-only clients see exactly what they saw before. The token only arrives at runtime inside the tool result; the static HTML never contains one.
* **Previews.** `ToolConfirmResult.preview` (and the optional last argument of `requireConfirmation`) is `{ title, facts: [{ label, value }], recipients: [{ name, detail? }] }`. It is kept beside the confirmation payload (a `WeakMap`), so it never touches `parts`, `items`, the args hash, or the payload. `send_message`, `send_crew_offer`, and `create_event_invitations` build one from data their confirm step already has (no extra backend calls; `send_message` recipients are user ids). Other gated tools show the summary only.
* **Approve.** Only a click sends `tools/call` through the host with the **same arguments** plus `confirmation_token`. The view uses the host's `ui/notifications/tool-input` arguments and falls back to `_meta` arguments; the tool name comes from `_meta` then `hostContext.toolInfo`. A busy flag disables both buttons on the first click, so a double-click sends one call. The card then shows done, the error, or a fresh card if the server asks for confirmation again. It tells the model with `ui/update-model-context` (or `ui/message` when the host only supports that).
* **Reject burns the token.** Reject calls the app-only tool `reject_confirmation` (`_meta.ui.visibility: ["app"]`, accepted by preflight through `mcpAppTools()`) with `{ tool, confirmation_token, organization_id? }`. `Gate.reject` checks the token is authentic, unexpired, and bound to this caller, organization, and tool. It then claims the action row and marks it failed with `REJECTED_ACTION_ERROR`. A later approve with that token fails with "was rejected by the user … get a fresh approval" instead of the generic unresolved-outcome error. Rejecting an action that already ran, is running, or failed returns an error and changes nothing. The card also tells the model the user rejected, so the agent stops.
* **Expiry.** After `expires_in_seconds` the card disables Approve and Reject and says "This approval expired. Ask the agent to prepare it again." The click handler re-checks expiry too.
* **Executed results.** With the gate disabled (local dev without `MCP_CONFIRMATION_SECRET`) or after the tool ran, the card shows "Done" or the error.
* **Reuse (ENG-856 / ENG-857).** Embed `VIEW_BRIDGE_JS` + `CONFIRM_STEP_JS` in a view and call `scRenderToolResult(container, bridge, { tool, arguments, preview, result, onSettled? })` or `scConfirmStep(container, bridge, { tool, arguments, preview, required, onSettled? })` to run the same Approve/Reject flow inside a larger widget. `onSettled(outcome, result)` fires once with `approved`, `failed`, `rejected`, `reject-failed`, or `expired`, so the widget can refresh or give its own controls back.

### Plan Review (ENG-856)

`get_event_plan` and `review_event_plan` link to `ui://soundcheck/plan-review.html`. A tool can link to one view only, so the gated `create_draft_event_from_plan` keeps the Confirm Card link; the Plan Review runs the same confirm step inline instead.

* **Data.** `get_event_plan`, `review_event_plan`, and `update_event_plan` results carry `_meta["soundcheck/plan"]`: a compact model built by `planReviewModel()` (title, dated details with provenance, venue/client, template, counts, readiness, open questions with draft-blocking ones first, open conflicts, the review's proposed changes, status, version, created gig id). `update_event_plan` gets the result `_meta` but no view link. `content` and `structuredContent` are unchanged, so tools-only clients see the same JSON as before.
* **Answer / Dismiss.** Each open question gets a select built from its own `options` when it has them, otherwise a text, number, date, or time input. Save sends one `update_event_plan` with the plan's current `plan_version` and `[{ op: "answer_question", question_id, value }]` (Dismiss sends `dismiss_question`). The view submits only what the user typed or picked, never an empty answer, and re-renders from the plan in the result.
* **Version conflicts.** A 412 / `PLAN_VERSION_MISMATCH` / `PLAN_CHANGED_REVIEW_REQUIRED` answer makes the view call `get_event_plan` once, show the fresh version, and tell the user nothing was saved. It never retries the write.
* **Mark reviewed** calls `review_event_plan` and then shows the proposed gig. **Create draft gig** is enabled only when the plan is REVIEWED and `readiness.can_create_draft`; otherwise it is disabled and the view lists the blocking reasons. The click calls `create_draft_event_from_plan` without a token, renders the confirm step from the `confirmation_required` result, and only Approve re-sends the same arguments plus `confirmation_token`. Reject burns the token. A refusal from the tool's confirm step (plan not ready) shows as an error line. A CREATED plan shows "Draft gig created" with the gig id and no controls.
* **Model sync.** After each write the view sends a one-line factual summary through `ui/update-model-context` (or `ui/message`), including the new plan version, so the agent's next edit uses it.

### Staffing Draft (ENG-857)

`review_staffing_draft`, `get_crew_offer`, and `save_crew_offer` link to `ui://soundcheck/staffing-draft.html`. `send_crew_offer` keeps its Confirm Card link; the Staffing Draft runs the same confirm step inline.

* **New read-only tool.** `review_staffing_draft { event_id, organization_id? }` (`mcp-ts/src/tools/staffingRoster.ts`) makes two backend reads, `v1OrganizationsOffersList` (event-scoped, complete) and `v1OrganizationsPositionsList` (`active_only: false`, for position names), and returns a model-shaped roster: shifts grouped by position, each with title, type, status, days with call times formatted in the gig's timezone, fee, `seats_needed` / `max_acceptances`, per-person name / status / delivery / fee, counts (seats, attached, accepted, declined, pending, not sent), `sendable` plus a hint, gig totals, and a `next_step` line. Names come from the `name` / `username` / `email` the offer rows already carry, so there is no per-person lookup. Both ops were already in api-events `DIRECT_OPS`.
* **Data.** The roster rides in `_meta["soundcheck/staffing"]`. `get_crew_offer` and `save_crew_offer` results get a single-shift roster there, built from the offer they return with no extra read; their `content` and `structuredContent` are unchanged.
* **Send.** A shift is `sendable` when it is DRAFT with people attached (or a linked call list), or OPEN with attached PENDING people whose delivery is NOT\_SENT. Its "Send…" button calls `send_crew_offer { offer_id, organization_id? }` without a token, renders the confirm step with the ENG-855 preview (which now shows people's names instead of user ids, and call times in the gig's timezone instead of raw instants; the text summary is unchanged), and only Approve re-sends with `confirmation_token`. Reject burns the token. After an approved send the view re-reads the board once and tells the model which shift was sent.
* **Refresh** re-calls `review_staffing_draft` (or `get_crew_offer` for the single-shift variant). The view never calls anything on render.

### Gigs and ChatGPT extensions

`open_gigs` (title **Gigs**, input `{}`) links to `ui://soundcheck/gigs.html` and lists the caller's upcoming gigs through `v1EventsList` (`upcoming: true`, active org when set). It is the first tool to use OpenAI's [MCP Extensions](https://github.com/openai/mcp-extensions/blob/main/docs/spec.md). Code: `mcp-ts/src/tools/catalog-gigs.ts`, `mcp-ts/src/apps/gigs.ts`, `mcp-ts/src/apps/openaiExtensions.ts`, `mcp-ts/src/apps/toolIcons.ts`.

* **Entrypoints.** `_meta["openai/ui"].entrypoints` is `[{ type: "global" }, { type: "thread" }]`, so ChatGPT shows Gigs in the sidebar and as a thread tab. Both open the tool with `{}`. The view renders the entrypoint's tool result and does not call the tool again on first render. Refresh re-calls `open_gigs`.
* **Icon.** `icons` holds one 20×20 monochrome stroke SVG (`currentColor`, 1.33px strokes) as a base64 `data:` URI. The MCP `Icon` type allows `data:` URIs, and an inline icon needs no hosting, CORS, or same-origin trust. SDK 1.30 does not serialize tool `icons`, so `decorateToolListIcons` adds them to `tools/list` responses on the member transport.
* **Display modes.** Gigs declares `availableDisplayModes: ["inline", "fullscreen"]` and `preferredDisplayMode: "fullscreen"` on its resource `_meta["openai/ui"]`. Plan Review and Staffing Draft declare both modes with no preference.
* **Ask about this gig.** Each row sends `ui/message` with the gig's title, date, and `event_id` when the host advertises `message`; otherwise it tells the user what to ask. **Open in Soundcheck** uses `ui/open-link` for `{appUrl}/gigs/{event_id}` when the host advertises `openLinks`, and shows the URL otherwise.
* **Gig detail.** `open_gig` (title **Gig**, input `{ event_id }`) links to `ui://soundcheck/gig.html` (prefers fullscreen). It reads `v1EventsDetail` (normal event view access checks and server-side redaction), then, failing soft, crew offers and positions for the gig's org, `v1EventsRunOfShowList` when the org adopted Run of Show, and `v1EventsLedgerList` only when `viewer_capabilities.can_view_financials` is true. Money is left out of the result entirely without finance access. Code: `mcp-ts/src/tools/catalog-gig.ts`, `mcp-ts/src/apps/gig.ts`.
* **Navigation.** A Gigs row calls `open_gig` from the view (`tools/call`) and renders the detail in place with a back button. The spec has no host navigation request for moving between views, and rendering in place keeps the list cached. Deep links: the Gigs view reads `hostContext["openai/deepLink"]` (`{ url }`, or the older `{ path, query }`) at initialize and on `host-context-changed`; `/gigs/{event_id}` opens the detail, `/` and `/gigs` show the list, anything else is ignored.
* **Model context.** While a gig is on screen the detail sends `ui/update-model-context` with one titled text block (`_meta["openai/title"]`) naming the gig and its `event_id`, and sends `{ content: [] }` on back. Hosts without `updateModelContext` get nothing.
* **Composer @-mentions.** `search_gig_mentions` (`_meta["openai/extensions"]["mentions/search"]`, `ui.visibility: ["app"]`) takes `{ query }` and returns `structuredContent.items` as `resource_link`s to `soundcheck://gig/{event_id}` (`text/markdown`). That template returns the same short summary as the `open_gig` text fallback, with the same finance gate. `soundcheck://event/{event_id}` is unchanged (raw event JSON) for other clients.
* **MCP Apps off.** `registerTools` adds the OpenAI metadata only with `mcpApps: true`, so `tools/list` for other callers is unchanged apart from the two new tools.

### CSP and sandbox constraints

* Hosts render the view in a sandboxed iframe on their own origin, with a CSP built from `_meta.ui.csp`. Ours declares empty `connectDomains` and `resourceDomains`, so the view **cannot fetch anything or load remote assets**. Inline `<script>` and `<style>` are allowed. Adding a domain widens every host's CSP for that view. Treat that change like adding an API scope.
* **Never put tokens, org ids, or PII in the HTML.** The resource is static, cached by hosts, and the same for every caller. Per-user data arrives only through the tool result (`structuredContent`) the host forwards to the view.
* The view has no credentials. Any write goes back through the host as `tools/call` on the member MCP, under the user's existing session and the normal confirmation gate. The Confirm Card's Approve therefore sends `confirmation_token` like any other caller.
* The view must not assume the host answers quickly or at all. Show useful content before `ui/initialize` resolves.

### Verifying locally

The mcp-ts unit tests cover `tools/list` / `resources/read` / `tools/call` through a real SDK client. For an end-to-end check, the spike used a throwaway page: it hosts the view with the official `AppBridge` from `@modelcontextprotocol/ext-apps/app-bridge`, over Streamable HTTP, against `handleMcpRequest`. The result: handshake about 10–30 ms after the iframe loads, the tool result painted about 20–150 ms after page start, and a view→host→server `tools/call` round trip of about 5 ms. A host has to connect its `PostMessageTransport` **before** it sets the iframe's `srcdoc`; otherwise it misses the view's first `ui/initialize`. The same harness, pointed at `archive_event` and `create_event_invitations` with the gate on, confirmed the Confirm Card end to end: a double-clicked Approve made one backend write, Reject made none and sent `ui/update-model-context`, and a 2-second token disabled both buttons. `mcp-ts/src/apps/confirmCardView.test.ts` runs the card's inline script in `node:vm` against a fake host and covers the same flows.

## WebMCP browser context (PRO-429)

[WebMCP](https://webmachinelearning.github.io/webmcp/) lets a page register tools for an agent running in the user's browser. Soundcheck uses it for **context only**. The page tells the agent where the user is. The member MCP stays the one place agents read data and take actions.

* **One tool: `get_current_context`.** It returns `context_id`, `organization_id`, `organization_name`, `route`, `surface`, `entity` (`{type, id}` for the record open on the page), that record's id under the exact member MCP argument name (`MEMBER_MCP_ID_KEYS`: `event_id`, `plan_id`, `customer_id`, `venue_id`, `user_id`, `setlist_id`, `job_id` for imports, `request_id` for availability requests; `event_id` is `null` off a gig page), and `selection` (`{type, ids}` or `null`). A test checks that every key is a real argument in the `mcp-ts` catalog. Records with no member MCP tool (hotels, run-of-show templates) come back as `entity` only. The route table is `DETAIL_ROUTES` in `web/src/lib/webmcp/context.ts`. A list page reports its first path segment as `surface`, with no entity. A creation page (`/setlist/new`, `/gigs/plans/new`) reports its surface with no entity.
* **No data or actions in the browser.** Do not add browser tools that read or write Soundcheck data. They would be a second API that skips member MCP tenancy, the confirmation gate, and the audit trail. PRO-429 lists this as a non-goal. The agent passes the ids from the context to member MCP tools.
* **Live at call time.** `web/src/components/webmcp/WebMcpTools.tsx` registers the tool once, beside `AskCheckAIDock` in the authenticated tree. It reads the route, the active org (only once `isOrgReady`), and the selection when the tool is called. Switching org or page never re-registers anything. The ids are what the UI is showing. If that record was deleted, the member MCP call returns the not-found error.
* **Selection.** A page publishes what the user has selected with `useWebMcpSelection(type, ids)` (`web/src/lib/webmcp/selection.ts`). The latest publisher wins, and unmounting clears it. No page publishes one yet.
* **Feature detection.** `resolveModelContext()` prefers `document.modelContext` (the current spec, which removes a tool when its registration `AbortSignal` aborts) and falls back to `navigator.modelContext` (Chrome's earlier preview, which uses `unregisterTool` or a handle returned by `registerTool`). A registration the browser rejected is never removed by name, because that name may belong to another owner. Browsers without WebMCP skip all of this. There is no feature flag.
* **Telemetry.** Each call captures `webmcp_context_requested` in PostHog with the same `context_id` the agent receives, plus the surface, entity type, and selection size. It records no ids and no route. The member MCP does not accept a `context_id` yet, so a context read can't be joined to the MCP call that follows it. That's the remaining part of PRO-429's telemetry requirement.

## CI

* **SDK library:** `.github/workflows/mcp-deploy.yml` — one `checks` job (lint, typecheck, tsc build, coverage).
* **Hosted MCP:** `.github/workflows/api-events-deploy.yml` — builds api-events after preparing `@soundcheck/mcp`.

## Legacy Azure sidecar

`infra/modules/mcp/` and `module "mcp"` in `infra/environments/*/main.tf` still describe the retired Express App Service (`soundcheck-mcp` image, `/healthz`). This repository no longer builds or deploys that image. Teardown is a **separate explicit Terraform apply** — do not destroy those modules from a code PR.

## Discovery operations reference

The following operator notes were moved from the public MCP discovery page on September 15, 2026. They describe the recorded rollout state; verify live configuration and directory status before operating or republishing.

This is the operator playbook for **agent discoverability**: making AI agents find the MCP and pick it for "staff / run a live event" tasks. It complements the end-user setup in [Connect your AI (MCP)](https://docs.soundchecklive.io/integrations/mcp-server.md).

The goal is to be the well-described **front door** for any agent asked how to staff or run a live event. The wedge is that the MCP ecosystem has events *ticketing-discovery* wrappers but **no crew/staffing server**. This product owns staffing.

> [!WARNING]
>
> **Prefer `mcp.soundchecklive.io` as the advertised connect host.** Public (anonymous): `https://mcp.soundchecklive.io/public/mcp` (`soundcheck-public` **0.1.0-ts**; **18 tools** including `search_businesses`, `get_business`, `send_feedback`, `normalize_positions`, `read_event_details`, `get_import_format`, `check_import_file`, places catalog, and inventory taxonomy). Member (OAuth via Clerk PRM): `https://mcp.soundchecklive.io/mcp`. Official MCP Registry listing: `io.soundchecklive/soundcheck` latest **0.1.1-ts** (remote `streamable-http` points at the member URL). Do **not** advertise `qa-mcp` / `dev-mcp` (internal qa and dev environments, not for customers). Do **not** treat `api.soundchecklive.io` `/public/mcp` or `/mcp` as the primary customer recipe. There is **no** Cursor Marketplace plugin yet. Phase 2 public gig search remains blocked on a public-safe storefront projection.&#x20;

> [!NOTE]
>
> ENG-613 (dedicated `mcp.soundchecklive.io` DNS/TLS) and ENG-615 (registry publish) are **Done**. Point agent indexes at the Mintlify MCP docs and the live `mcp.soundchecklive.io` URLs above. Prefer the registry remote for member connect.&#x20;

## What the server advertises (in code)

Set in `mcp-ts/src/handler.ts` (`SERVER_TITLE`, `SERVER_VERSION`, `INSTRUCTIONS` / `PUBLIC_INSTRUCTIONS`) and served on the MCP host:

* **`Implementation.Title`** = "Soundcheck - Live Event Staffing", **`WebsiteURL`** (from `MCP_WEBSITE_URL`, default product apex), and **`Version`** (`SERVER_VERSION`).&#x20;
* **Server `Instructions`**: a one-paragraph "what the product is + when to use it + how to treat consequential tools." This is a primary signal the model uses to route a task here, so keep it crisp and current.
* **Protected Resource Metadata** (`/.well-known/oauth-protected-resource` and `/.well-known/oauth-protected-resource/mcp`, RFC 9728): `resource`, `authorization_servers` (production lists `https://clerk.soundchecklive.io`), `bearer_methods_supported`, `scopes_supported`, `resource_name`, and, when the env vars are set, `resource_documentation` (`MCP_DOCS_URL`) and `resource_policy_uri` (`MCP_POLICY_URL`). Point `MCP_DOCS_URL` at [/integrations/mcp-server](https://docs.soundchecklive.io/integrations/mcp-server.md) on the Mintlify docs host and `MCP_POLICY_URL` at [https://soundchecklive.io/privacy](https://soundchecklive.io/privacy). Older MCP clients also read RFC 8414 at `/.well-known/oauth-authorization-server` and `/.well-known/oauth-authorization-server/mcp` on the same origin (live Clerk AS metadata, including `client_id_metadata_document_supported` when Clerk advertises it). Treat DCR metadata as informational; do not claim naive clients can finish member OAuth via DCR today, and do not invent a `client_id`. OpenAI Apps domain verification is served at `/.well-known/openai-apps-challenge` (`OPENAI_APPS_CHALLENGE`).&#x20;

Tool **names and descriptions** are the other selection signal: they are namespaced and action-first, and the consequential ones state "preview/confirm first."

## 1. Official MCP Registry (published)

The [official MCP Registry](https://registry.modelcontextprotocol.io) (launched Sept 2025) is the canonical index that downstream catalogs federate from. It hosts **metadata only**, and is still **preview** (no durability guarantees).

**Published:** `io.soundchecklive/soundcheck` latest **0.1.1-ts** (active). The listing uses a `remotes` entry (`streamable-http` → `https://mcp.soundchecklive.io/mcp`), not `packages`, because this is a hosted server.&#x20;

Keep the listing current:

* The listing files live in `mcp-ts/registry/` (schema `2025-12-11`), generated from the constants in `mcp-ts/src/handler.ts` by `npm run generate:registry`. CI runs `npm run check:registry`, so a constant change without a regenerated listing fails.
* `mcp-publisher publish` reads `server.json` from the working directory, so publish each listing with its file copied in as `server.json`.
* Keep the reverse-DNS namespace verified for the product domain. `soundchecklive.io` carries `v=MCPv1; k=ed25519` TXT records; `mcp-publisher login dns --domain soundchecklive.io` needs the private key matching one of them (keep that key in the team password manager, never in the repo). A lost key is replaced by adding a new TXT record; existing listings are unaffected.&#x20;
* Keep `remotes[0].url` on `https://mcp.soundchecklive.io/mcp`. Never invent a host, never point at `qa-mcp` / `dev-mcp`, and never reuse retired Azure App Service leftovers.&#x20;
* Registry package `version` (today **0.1.1-ts**) is **not** the same field as `initialize` `SERVER_VERSION` / `soundcheck-public` (**0.1.0-ts**). Do not require lockstep; document both when they differ. Prefer bumping runtime in a follow-up if you want them equal.&#x20;
* Republish with the `mcp-publisher` CLI (`mcp-publisher login` → `mcp-publisher publish`) when the remote URL or version changes.

**Published:** `io.soundchecklive/live-event-quotes` latest **0.1.0** (active, published 2026-09-25; the public registry API lists only this version) (`mcp-ts/registry/live-event-quotes.server.json`), a separate listing whose remote is the anonymous `https://mcp.soundchecklive.io/public/mcp`. Agents searching the registry for event pricing can only use a server that works without sign-in, and the member listing needs OAuth. The `.well-known` discovery card already names it in `public.name`. The Claude public connector (Instant Quotes) listing is live; the Claude member connector and ChatGPT app remain in review.&#x20;

## 2. Submit to client connector directories (after registry)

* **Anthropic Connectors Directory**: the Claude **public** connector (Instant Quotes) was approved on 2026-10-01 and listing changes went live on 2026-10-02. The Claude **member** connector is still in review; until it is listed, use a custom connector with the member URL — [Claude connector](https://docs.soundchecklive.io/integrations/mcp-claude.md). Requirements already met for review: production remote, per-tool annotations, privacy URL, usage examples, logo.&#x20;
* **ChatGPT / OpenAI plugin**: **still in review, not live in the directory yet.** Until listed, paste the member or public URL as a custom MCP — [ChatGPT plugin](https://docs.soundchecklive.io/integrations/mcp-chatgpt.md).&#x20;
* **Cursor**: discover by HTTPS URL (**Settings → Tools & MCP**). There is **no** Cursor Marketplace plugin yet — [Cursor](https://docs.soundchecklive.io/integrations/mcp-cursor.md). Mirror to **PulseMCP**, **Smithery**, **mcp.so**, and the **GitHub MCP Registry** from the official registry listing (most federate from it).

## 3. `llms.txt`

Keep agent indexes coherent across surfaces:

| Surface | Role | |
| - | - | - |
| Mintlify `/llms.txt` on the docs host | Generated index of public docs (includes these MCP pages) | |
| App `/llms.txt` (`web/public/llms.txt`) | Must point at Mintlify MCP docs, **not** the marketing SPA, and must advertise the live `mcp.soundchecklive.io` connect URLs plus the registry listing. Do **not** advertise `qa-mcp` / `dev-mcp` or a Cursor Marketplace plugin | |
| Marketing-site `llms.txt` | Separate product; out of scope for this docs change | |

Treat `llms.txt` as one signal (tool quality and registry presence matter more). Prefer [/integrations/mcp-server](https://docs.soundchecklive.io/integrations/mcp-server.md) on the Mintlify docs host as the docs link for agents. A marketing-domain `llms-full.txt` 404 is **not** fixed here; Mintlify already serves `/llms-full.txt` on the docs host.

## 4. `.well-known/mcp` (product MCP host)

The product MCP host serves the same discovery card at:

* `https://mcp.soundchecklive.io/.well-known/mcp`&#x20;
* `https://mcp.soundchecklive.io/.well-known/mcp.json`&#x20;
* `https://mcp.soundchecklive.io/.well-known/mcp/server-card.json`&#x20;

These paths return `application/json` with the product remote (`streamable-http` → member `/mcp`), public tier URL, OAuth2 / PRM pointers, docs, privacy, and registry identity (`io.soundchecklive/soundcheck` **0.1.1-ts**).  Competing SEP drafts are still unmerged; keep RFC 9728 PRM as the auth discovery source of truth, and treat these cards as a compatibility probe surface.

Do **not** change the product MCP host card path (`/.well-known/mcp/server-card.json` on the `mcp.` hostname). That nested path is owned by api-events.

## 5. `.well-known` on the marketing site (apex / www)

The Lovable marketing site (`https://soundchecklive.io` and `https://www.soundchecklive.io`) is separate from the product MCP host. When published, it serves:

* `https://soundchecklive.io/.well-known/mcp` (and `www`) — same content as `mcp.json`
* `https://soundchecklive.io/.well-known/mcp.json` (and `www`)
* `https://soundchecklive.io/.well-known/mcp-server-card.json` (and `www`) — flat path; replaces the older nested marketing path `/.well-known/mcp/server-card.json`

The Mintlify docs host and the product MCP host keep their nested `/.well-known/mcp/server-card.json` paths unchanged.

## For agents

* **Product:** [https://soundchecklive.io](https://soundchecklive.io)&#x20;
* **Public MCP:** `https://mcp.soundchecklive.io/public/mcp` (`soundcheck-public` **0.1.0-ts**; **18 tools** including `search_businesses`, `get_business`, `send_feedback`, `normalize_positions`, `read_event_details`, `get_import_format`, `check_import_file`, places catalog, and inventory taxonomy)&#x20;
* **Member MCP:** `https://mcp.soundchecklive.io/mcp` (OAuth via Clerk PRM)&#x20;
* **Registry:** `io.soundchecklive/soundcheck` latest **0.1.1-ts**&#x20;
* **MCP docs:** [https://docs.soundchecklive.io/integrations/mcp-server](https://docs.soundchecklive.io/integrations/mcp-server.md)&#x20;
* **Connect spokes:** [Claude](https://docs.soundchecklive.io/integrations/mcp-claude.md), [Claude Code](https://docs.soundchecklive.io/integrations/mcp-claude-code.md), [ChatGPT](https://docs.soundchecklive.io/integrations/mcp-chatgpt.md), [Cursor](https://docs.soundchecklive.io/integrations/mcp-cursor.md)
* **This playbook:** [https://docs.soundchecklive.io/integrations/mcp-discovery](https://docs.soundchecklive.io/integrations/mcp-discovery.md)&#x20;
* **Official AI-info:** [https://docs.soundchecklive.io/ai-info](https://docs.soundchecklive.io/ai-info.md)&#x20;
* **Privacy:** [https://soundchecklive.io/privacy](https://soundchecklive.io/privacy)&#x20;

Point agent indexes at the Mintlify docs and the `mcp.soundchecklive.io` URLs above. The Claude public connector (Instant Quotes) is listed; the Claude member connector (slug `soundcheck`) and the ChatGPT plugin are **submitted for review, not live**; use a custom connector today ([Claude steps](https://docs.soundchecklive.io/integrations/mcp-claude.md), [ChatGPT steps](https://docs.soundchecklive.io/integrations/mcp-chatgpt.md)). Do **not** advertise `qa-mcp` / `dev-mcp` (internal qa and dev environments, not for customers) or a Cursor Marketplace plugin.&#x20;

## Roadmap

A **tiered** MCP is the target shape:

| Tier | Auth | Purpose |
| - | - | - |
| 1: Public discovery | none | the agent front door: org lookup, crew roles, UEF literacy (`get_import_format` / `check_import_file` / `read_event_details`), intake (`request_booking`/`request_sponsorship`), and authority **prompts** (`how_to_staff_a_live_event`) |
| 2: Member | OAuth | personal reads + quick actions (today's `my_agenda`, availability, accept/decline) |
| 3: Coordinator | OAuth + role | run gigs (today's create/staff/call-list/messaging/import) |

* **Phase 1 (live and advertised).** Tier 1 is a distinct `mcp.Server` instance registered with only public tools, mounted with no auth middleware, isolated by construction; it calls public API routes with no bearer token, so it cannot leak member data. Tools: `get_public_org`, `list_positions` (global default position catalog), `normalize_positions` (deterministic catalog normalize for free-text crew roles; not AI-gated), `read_event_details` (→ `POST /api/v1/public/normalize-event-data`, returns a UEF draft plus missing fields; always listed, AI call fails clearly when gateway is unavailable), `get_import_format` / `check_import_file` (local UEF schema textbook + validate workbook; no AI), `search_places` / `get_place` (seeded public industry venue + hotel catalog), `search_inventory` / `normalize_inventory` (public gear taxonomy; not org stock), `lookup_market_pricing` (→ `POST /api/v1/public/pricing/lookup`, evidence-backed market ranges; Soundcheck actuals only as ≥3-org aggregates; `coverage: "none"` and no numbers when evidence is missing), `request_booking` (→ `POST /lead-requests`), `request_sponsorship` (→ `POST /sponsor-requests`), `send_feedback` (→ `POST /api/v1/public/feedback`, creates a Linear issue for the Soundcheck team with email fallback; unverified sender, 3 per IP and 30 total per 10 minutes). Prompts: `how_to_staff_a_live_event`, `plan_event_crew`. e2e tests assert it is reachable without auth, exposes no member tools, and forwards no `Authorization` header. **Advertise** `https://mcp.soundchecklive.io/public/mcp` (`soundcheck-public` **0.1.0-ts**). Prefer that host over any `api.soundchecklive.io` path.&#x20;
  * **Per-source rate limiting (DONE, app-level).** `/public/mcp` is wrapped by a per-client-IP token-bucket limiter in the MCP server (`mcp-ts/src/ratelimit.ts`), configured via `PUBLIC_RATELIMIT_RPS` / `PUBLIC_RATELIMIT_BURST` / `PUBLIC_RATELIMIT_TRUSTED_PROXY_COUNT`. Idle buckets are TTL-evicted so the map can't grow unbounded. The downstream API's per-IP limiter can't help here at all; all `/public/mcp` traffic reaches it from the MCP's single egress IP, which is why the limit lives in front of `/public/mcp`.
    * **Keying is per-source IP only** (not per-tool). The public MCP ingress bucket is still shared per IP because MCP calls are all `POST /public/mcp` with the tool name in the JSON-RPC body, not the URL path.
    * **Tool-specific hardening (expensive analyze).** `read_event_details` calls a dedicated public API route (`POST /api/v1/public/normalize-event-data`) with its own stricter per-route token bucket (`PUBLIC_NORMALIZE_RATELIMIT_RPS`, `PUBLIC_NORMALIZE_RATELIMIT_BURST`, `PUBLIC_NORMALIZE_RATELIMIT_TRUSTED_PROXY_COUNT`; fallback to `PUBLIC_RATELIMIT_TRUSTED_PROXY_COUNT` for proxy parsing). Direct MCP (`v1PublicNormalizeEventDataCreate`) shares the same singleton limiter via `allowPublicNormalize` with HTTP client context — REST and Direct MCP each charge once at their transport boundary; `normalizeEventDataOperation` does not rate-limit. This composes with the shared `/public/mcp` limiter so expensive extraction calls are constrained even when ingress limits are relaxed.
    * **Tool-specific hardening (pricing lookup).** `lookup_market_pricing` calls `POST /api/v1/public/pricing/lookup`, which has its own per-route token bucket (`PUBLIC_PRICING_RATELIMIT_RPS` default 2, `PUBLIC_PRICING_RATELIMIT_BURST` default 10, `PUBLIC_PRICING_RATELIMIT_TRUSTED_PROXY_COUNT`; fallback to `PUBLIC_RATELIMIT_TRUSTED_PROXY_COUNT` for proxy parsing). Direct MCP (`v1PublicPricingLookupCreate`) shares the same singleton limiter via `allowPricingLookup`, so REST and Direct MCP each charge once at their transport boundary. The lookup is database-only — no model call — so its limits are looser than normalize. See [pricing knowledge](https://docs.soundchecklive.io/engineering/backend/pricing-knowledge.md).
    * **AI Gateway dependency (fail-closed on call).** Deployed environments must mount `AI_GATEWAY_API_KEY` for analysis to succeed. Without it, `POST /api/v1/public/normalize-event-data` returns **503**, Direct MCP returns **503**, and the public MCP tool still appears in `tools/list` but returns a clear tool error on call. Infra intentionally does not provision this key yet (owner decision pending) — successful live extraction waits on that mount.
    * **Single-instance assumption.** The bucket map is process-local, so the effective limit is `RPS × instance_count`. Before enabling autoscale or running MCP multi-instance, either pin it to a single instance, lower `RPS`/`BURST` to tolerate the fan-out, or move enforcement to the edge. **Tune `RPS`/`BURST` per environment before a wide launch.**
  * **Do not spam intake.** `request_booking` and `request_sponsorship` create real leads. Treat them like the public web forms.
  * **Durable edge:** production customer host is `mcp.soundchecklive.io` (extra cert + host rules on the existing api-events ALB; same Cloud Run as REST; that host only exposes `/mcp`, `/public/mcp`, and `/.well-known/*`). Set `PUBLIC_RATELIMIT_TRUSTED_PROXY_COUNT` to **2** (ALB appends client IP then forwarding-rule IP). Do **not** advertise `qa-mcp.soundchecklive.io` or `dev-mcp.soundchecklive.io` (internal qa and dev environments, not for customers).&#x20;
  * Document member `/mcp` on the [MCP server page](https://docs.soundchecklive.io/integrations/mcp-server.md) as the advertised customer OAuth path (`https://mcp.soundchecklive.io/mcp`). Registry listing is live at **0.1.1-ts**. Cursor Marketplace plugin is still not published.&#x20;
* **Phase 2** adds public gig/event search. This is blocked on an **API change**: `GET /storefront/events` currently returns full rosters/assignments/invitations to anonymous callers; it needs a public-safe event projection and an `isPublic`/org-consent flag before any agent exposure.

### Catalog and deployment provenance

The [generated catalog](https://docs.soundchecklive.io/integrations/mcp-catalog.md) is built from SDK protocol listings by `mcp-ts/scripts/catalog.mts`; update it with `npm run generate:catalog` and validate it with `npm run check:catalog`. It contains source capabilities, not a live deployment claim. Runtime `serverInfo.version` appends the deployment Git SHA from `MCP_BUILD_REVISION`, which deploy CI stamps from the deployed commit and the deployment smoke verifies. Terraform does not manage it, so an apply leaves it unset until the next deploy. The registry release version is separate.

The deployment workflow verifies the advertised MCP hostname with public initialize/list/call and member rejection checks. Controlled member reads use `MCP_SMOKE_CREDENTIAL_SECRET` for a rotating OAuth credential in Secret Manager, or `MCP_SMOKE_TOKEN` for a one-off check, and can be required by `MCP_SMOKE_REQUIRE_MEMBER=true`. See the SDK README for enrollment, serialized rotation and the environment contract. The external marketing site at `soundchecklive.io/ai-info` is maintained outside this repository; its publisher should link to this generated reference rather than copy tool counts.&#x20;


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.