Skip to content

MCP server

Remote MCP server letting podcasters connect AI clients to their own account. Design doc: docs/planning/plans/2026-08-05-mcp-server.md (agreed by an adversarial two-agent review, four Codex rounds).

Shape

AI client ──OAuth 2.1 (PKCE)──▶ authorize+consent on app.podcasterplus.com
          ──Bearer + Streamable HTTP──▶ mcp.podcasterplus.com  (workers/mcp)
                                          │ service binding APP_SERVICE

                                        podcasterplus-app  /api/mcp/*  (internal only)

                                        Postgres (ledger, audit, tenant data)
PieceWhereRole
OAuth AS + MCP endpointworkers/mcpIssues show.fm tokens; serves tools; holds no data
Consent + authorizesrc/routes/(app)/settings/ai-connections/authorizeBrowser leg, on app. so cookies stay put
Facadesrc/api/routes/mcpThe only data path; internal-only
Middlewaresrc/api/middleware/mcp.tsProvenance, predicate, fail-closed limiter, audit
Ledger + transactionssrc/lib/server/mcp/Revocation authority, single-use consent state
Pruneworkers/lifecycle-manager/src/mcp-prune.ts90-day audit retention, dead transactions

The five invariants

Breaking any of these silently makes the surface unsafe. Each has a test.

  1. AI clients never hold a Supabase credential. GoTrue does not enforce token audience on its own user endpoints (internal/api/auth.go: parseJWTClaims has no WithAudience; UserUpdate has no audience check), so a Supabase JWT in an AI client would remain a full-account credential able to change the account's email and password no matter what claims it carried. This is why we run our own authorization server rather than using Supabase's OAuth server. Do not "simplify" this back.
  2. The ledger is the revocation authority, not the provider. workers-oauth-provider validates a denormalised KV token record and never re-reads the grant; KV deletes can stay invisible elsewhere for 60s+. mcp_connections.status is checked in Postgres on every tool call. Revocation writes the ledger FIRST, synchronously; provider cleanup is async and may fail without restoring access.
  3. The facade answers nothing without the shared secret. mcpProvenance() 404s any request lacking it, before reading any other header, so a valid MCP token pasted into curl against app.podcasterplus.com/api/mcp/* gets nothing. Precisely: the route is a public Hono route and the check authenticates the SECRET, not the transport. Treat the value as an internal service credential (distinct per environment, rotatable). A WorkerEntrypoint RPC would be a true capability boundary and is the upgrade path if this surface ever stops being read-only.
  4. Identity comes from two sources that never swap. client_id and scopes come from the provider's verified AuthInfo; user_id and connection_id from its encrypted props. The facade re-checks all four against the ledger row, so forged props cannot widen access.
  5. The worker can reach nothing but its OAuth store and the facade. No Hyperdrive, no R2, no AI, no service-role key. Adding one is a plan-level decision.

Token and identity model

  • The provider's userId is the connection id, not the Supabase user id, so provider KV never holds a user UUID and the opaque token (userId:grantId:random) leaks nothing about the account.
  • Encrypted props carry { userId, connectionId } only.
  • Scope at launch is mcp:read. The AS also advertises offline_access because the Anthropic clients request it and a requested scope must survive consent; protected-resource metadata advertises only mcp:read. Advertising it is NOT what produces refresh tokens: the spike verified the provider issues one either way, and Cursor requests mcp:read alone and still gets one.
  • The scope the facade sees is the token's EFFECTIVE scope, not the grant's. The token endpoint honours downscoping, so a client can refresh for less than it was granted; attachVerifiedAuth publishes summary.scope for exactly this reason. Publishing summary.grant.scope would silently restore what the client gave up.

Per RFC 9700, implemented in the authorize route:

  • The validated authorization request is stored server-side in mcp_auth_transactions; the browser carries only the opaque transaction id, including through a login round trip.
  • Trusted fields (client, redirect, scopes) are read from that stored request at completion, never from form inputs.
  • The consent form has its own CSRF nonce. The OAuth state parameter is not a CSRF token.
  • Consumption is an atomic compare-and-set (consumed_at IS NULL), so a replayed submission cannot mint a second grant.
  • Consent responses are no-store, no-referrer, frame-ancestors 'none'. The app sets no global CSP, so these are per-response headers on that route.

Rate limiting and audit

  • consume_api_rate_limit, scope mcp_tool, identifier user:<id>, fail-closed: a null or errored verdict denies. The app's general limiter fails open because it guards the money path; MCP is not the money path.
  • mcp_tool_calls records every call including denials, once the connection is verified. Tool identity is route identity (one facade route per tool) and the recorded tool is derived from the route by canonicalToolForPath, so nothing in the row is caller-supplied. The x-mcp-tool header is still sent by the worker but is not what gets audited.
  • The audit write is best effort, not a gate. The response is not held for it. A failure is logged as [mcp] audit write returned an error or [mcp] audit write threw and the call still succeeds, so the honest reading of this table is "every verified call that the database accepted a row for". Making audit a hard precondition would mean failing tool calls when Postgres is degraded, which is the wrong trade for a read-only surface. Alert on those log lines rather than assuming completeness. (Until 2026-08-07 a failed write logged nothing at all: Supabase resolves with { error } rather than rejecting, and the handler only inspected rejections.)

Deploy runbook

This is one strict sequence. Do not reorder it. The edge controls in steps 3 and 4 are not configuration tidy-up: the cookie strip is what keeps parent-domain session cookies out of this worker, and the rate rules are the only abuse control in front of public client registration. Both must exist BEFORE the hostname serves traffic, because the worker is reachable the moment its route resolves.

bash
# 1. Migrations (production checklist in supabase/CLAUDE.md)
supabase db push --linked

# 2. KV namespace for the OAuth store, then paste the ids into wrangler.toml
cd workers/mcp
CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler kv namespace create OAUTH_KV
CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler kv namespace create OAUTH_KV --env staging

3. DNS, cookie strip and rate rules (manual, in the dashboard). Before any deploy. See "Manual Cloudflare steps" below. The wrangler OAuth token has zone (read) only, so DNS is always a manual step.

bash
# 4. Shared secret. DIFFERENT VALUES per environment: one leaked staging secret must not
#    open the production facade, and staging is where test clients point.
openssl rand -hex 32   # production value, used for BOTH production commands
cd workers/mcp
CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler secret put MCP_SHARED_SECRET
cd ../.. && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler secret put MCP_SHARED_SECRET

openssl rand -hex 32   # a SECOND value, staging only
cd workers/mcp
CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler secret put MCP_SHARED_SECRET --env staging
cd ../.. && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler secret put MCP_SHARED_SECRET --env staging

# 5. The worker FIRST — the app's MCP_CONSENT_SERVICE binding needs it to exist
cd workers/mcp && CLOUDFLARE_ACCOUNT_ID=b8eff1b484adf398bda38644efc85bce npx wrangler deploy

# 6. Then the app (facade, consent page, settings page)
cd ../.. && pnpm run deploy

7. Product E2E on staging before production announcement. Phase 0 proved the OAuth and MCP stack on a throwaway worker; it did not touch the consent page, the ledger, the facade or the entitlement gates. Run at least one real client all the way through: connect, consent, tool call, leave it past the one-hour access-token expiry, tool call again (proves refresh), revoke mid-session, confirm the next call fails, reconnect.

Manual Cloudflare steps (not in wrangler config)

  1. DNS: proxied AAAA mcp 100:: on podcasterplus.com and podcasterplus.dev.

  2. Cookie strip (required): a Request Header Transform Rule on mcp.podcasterplus.com removing Cookie. App cookies are scoped to the registrable parent domain, so a direct browser navigation to mcp. would otherwise carry them. The worker is already workers_dev = false with no preview URLs, so the route is the only path in.

  3. Rate limiting: ONE zone rule, plus in-Worker limiters. What was specified here (hostname-scoped, method-aware, per-minute) is not expressible on the zone plan we are on, so this records what was actually built, 2026-08-07.

    The zone plan allows one rate-limiting rule, with a 10-second window and a field set that excludes hostname. That single rule goes on /oauth/register, scoped by URI Path:

    • Field URI Path equals /oauth/register, characteristic IP, 2 requests / 10 seconds, action Block, duration 10 seconds.

    It goes there rather than on the protocol endpoint because a registration request writes durable KV state, so it is the one worth killing before the Worker runs at all. Path scoping is zone-wide rather than hostname-scoped; nothing else on the zone serves that path, and nothing else ever should.

    Everything the zone cannot express is done in the Worker with Cloudflare's in-Worker rate limiting (GA 2025-09-19), configured in workers/mcp/wrangler.toml and applied by workers/mcp/src/rate-limit.ts before the OAuth provider runs: /oauth/register at 20/60s and /mcp at 600/60s.

    🔴 The key is the address, and only the address. Never anything from the request body or headers. These limiters run BEFORE the OAuth provider, so every identifier a caller supplies is unvalidated input at that point. An earlier version keyed protocol traffic by the token's userId:grantId prefix, to avoid bucketing every claude.ai user behind one vendor egress address. Codex review found that this removes the limit rather than refining it: a caller sending Bearer <random>:<random>:x with fresh values each request mints a fresh bucket every time, and anyone who has seen a real token's non-secret prefix can exhaust that connection's bucket without holding the secret.

    The concern behind it was real, and is handled where it can be handled safely: per-connection fairness is the facade's job, via consume_api_rate_limit on the verified user:<id>, after the provider has validated the token and the ledger has confirmed the connection. Identity-keyed limits belong after authentication, never before it.

    The residual is accepted knowingly: users on a server-side client such as claude.ai share one vendor address and therefore share this ceiling, which is why /mcp is set at 600/60s rather than something tighter. If it ever bites, raise the ceiling. Do not reintroduce a key derived from unvalidated input.

    🔴 In-Worker counters are per Cloudflare location, not global, and the limiters fail open (unlike the facade's, which fails closed). Both are deliberate and argued at the top of rate-limit.ts. This bounds retry storms and casual abuse; volumetric attacks are Cloudflare's baseline DDoS protection.

    Revisit Advanced Rate Limiting when there is real traffic: it would restore hostname scoping, per-minute windows and more than one rule.

    MCP 2026-07-28 requires clients to send Mcp-Method and Mcp-Name headers so gateways can route without parsing the body. That would let a zone rule charge tools/call differently from discovery, and is worth doing if the plan is ever upgraded.

  4. Activate the worker in the pipeline (last step, after 1 to 3): the worker ships held out of CI deploys by PENDING_INFRA_WORKERS in scripts/affected-workers.mjs, because deploying it before its KV namespace exists fails on the placeholder id and takes every other affected surface down with it. Activation is three edits in one commit:

    • paste the real KV ids into workers/mcp/wrangler.toml (both environments),

    • remove 'mcp' from PENDING_INFRA_WORKERS and add it to DEPLOY_ORDER, updating the census counts in scripts/__tests__/affected-workers.test.js (16 deployable becomes 17, staging 15 becomes 16),

    • add the app's MCP_CONSENT_SERVICE service binding to the root wrangler.toml, including entrypoint = "ConsentService". Add the staging binding at staging activation and the production one only at promotion: a binding to a worker that does not exist fails the APP's deploy, and podcasterplus-mcp does not exist until production is promoted.

      🔴 The entrypoint field is not optional for us. Omit it and the binding resolves to the worker's DEFAULT export, which only serves fetch; every RPC method the consent page calls is then missing and the page 500s before rendering, with no clue pointing at the binding. This shipped to staging on 2026-08-07. wrangler deploy --dry-run does not catch it, because the config is well-formed and prints the binding as satisfied; the giveaway is that a correct binding renders as podcasterplus-mcp-staging#ConsentService while a broken one has no #suffix. config-invariants.test.ts now asserts every MCP_CONSENT_SERVICE block names an entrypoint the worker actually exports.

    Until the binding lands, getConsentService() returns null and the consent page reports that AI connections are unavailable, which is the correct fail-closed state.

  5. Reveal the user-facing surfaces. DONE 2026-08-07, with the production rollout. Recorded as history rather than a checklist, because it is finished; the reasoning is kept because the next dormant surface will face the same trap.

    🔴 Reveal at PRODUCTION PROMOTION, never at staging activation. This step originally said "same commit as step 4", which would have published a live help page for a server that did not exist. The docs sites are production-only surfaces with no staging variant: deploy.yml pushes docs-public to www.podcasterplus.com/docs on any merge to main, while the production worker deploys by manual dispatch.

    🔴 Removing a nav entry is NOT enough. A page still reaches readers through local search, the sitemap, the llms.txt outputs and the committed support-deflection index that the support form and in-app Help launcher read. All five places had to move together:

    • srcExclude in docs-public/src/.vitepress/config.ts (build, sitemap, LLM outputs),
    • EXCLUDED_FILES in docs-public/scripts/build-search-index.mjs, whose header requires lockstep with srcExclude, then pnpm run search-index and BOTH regenerated artifacts committed (docs-public/src/public/search-index.json and static/support-docs-index.json),
    • the nav and sidebar entries in the VitePress config,
    • the developers section overview (docs-public/src/developers/index.md), which describes the developer offering and would otherwise still present it as API-and-player only,
    • learnMore: '/developers/mcp-server' on the mcp.connected-clients tooltip, withheld until this point because the CI lockstep only checks the source file exists, so a link added earlier would have passed CI and still 404'd,
    • a Connected AI clients card in src/routes/(app)/settings/+page.svelte; the page had existed since the build with nothing linking to it.

    Verify against dist/, never the source. Confirm developers/mcp-server.html is built and the page appears in sitemap.xml and llms.txt. Checking the source is what let an earlier version of this slip.

Verify after deploy

Set HOST and APP for the environment, then run the block unchanged:

bash
HOST=https://mcp.podcasterplus.dev   APP=https://app.podcasterplus.dev    # staging
# HOST=https://mcp.podcasterplus.com APP=https://app.podcasterplus.com    # production

curl -s "$HOST/.well-known/oauth-protected-resource" | jq
# resource = $HOST/mcp, scopes_supported = ["mcp:read"], authorization_servers = [$HOST]

curl -s -o /dev/null -w '%{http_code}\n' -X POST "$HOST/mcp" \
  -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# 401, with a WWW-Authenticate header pointing at the resource metadata

curl -s -o /dev/null -w '%{http_code}\n' "$APP/api/mcp/podcasts"
# 404 — the facade answers nothing without the shared secret

curl -s "$HOST/health" -H 'Cookie: test=1' | jq
# cookieHeaderPresent MUST be false. `true` means the Cookie-stripping transform rule
# is missing or scoped to the wrong hostname, which is the one control keeping app
# session cookies off this worker. This replaces "confirm in the logs", which nobody ran.

/.well-known/oauth-authorization-server/mcp returns 404 and that is correct, not a gap: the issuer has no path component, so RFC 8414 puts its metadata at the unsuffixed path, which is 200. Both protected-resource paths are 200. All six clients discover us fine through this chain.

Client evidence (Phase 0 spike, 2026-08-07)

Measured against a real connection, not inferred from documentation.

Every client registers through DCR, because CIMD is off. With CIMD advertised the Anthropic clients chose it and arrived with a URL as their client_id; with it off they do not, so in the shipped configuration oauth_client_id always holds a short opaque id. Do not assume either shape: turning CIMD back on reintroduces URLs.

DCR metadata is self-asserted, and the observed clients all supply a usable name. Read straight out of the spike's KV store: Cursor registers as Cursor with redirect URIs cursor://anysphere.cursor-mcp/oauth/callback, https://www.cursor.com/agents/mcp/oauth/callback and http://localhost:8787/callback; claude.ai as Claude with https://claude.ai/api/mcp/auth_callback, and notably with token_endpoint_auth_method: client_secret_post rather than none; Claude Code as Claude Code on a loopback port; ChatGPT as ChatGPT with https://chatgpt.com/connector/oauth/…. So turning CIMD off costs nothing on the settings page, which was the one user-visible thing CIMD was credited with.

But a name is a claim, not identity. Anything can register as Claude with client_uri: https://claude.ai. The consent screen therefore presents the name in quotes, states that show.fm has not verified it, and shows the redirect target, which is the one field an impostor cannot fake usefully because it is where the code is delivered (describeRedirectTarget in src/lib/server/mcp/types.ts). Do not restore confident branding to that screen without a server-owned pre-registration to match against.

context.http.authInfo is empty without the bridge. See the failure signature below; this was found because a real client connected successfully and then reported an empty client id.

Advertising CIMD breaks the OpenAI clients, which is why it is off. ChatGPT and Codex choose CIMD when a server advertises it, and the provider then rejects their metadata document: it declares token_endpoint_auth_method: "private_key_jwt" (RFC 7523 §2.2), while the provider hard-codes CIMD_ALLOWED_AUTH_METHODS = ["none"], rejects on the declared preference without consulting token_endpoint_auth_methods_supported (where none would have matched), and does not implement private_key_jwt at all. Authorization fails before consent with CimdFetchError.

With clientIdMetadataDocumentEnabled: false, every client falls back to DCR and every client works. Six were connected and exercised on 2026-08-07: claude.ai, Claude Code, Claude Desktop, Cursor, ChatGPT and Codex.

Revisit when the provider implements private_key_jwt. DCR is deprecated and will be removed in a future spec revision, so this leans on the retiring path; the exit is a provider that accepts ChatGPT's document, at which point CIMD goes back on. Re-run the client matrix before trusting it. Worth reporting upstream in the meantime: rejecting on the preference rather than intersecting with the supported list looks like a bug independent of private_key_jwt support (the same request is filed against other MCP servers, e.g. Altinity issue #118).

claude.ai is a 2025-era client and does not support elicitation. Asking it to confirm mid-tool-call fails with "the client on this 2025-era connection did not declare the required capability". Two things follow. First, a future write tool cannot be gated by the protocol's own confirmation mechanism yet; it needs our own pending-action record (plan §7.3). Second, 2025-era serving is load-bearing: what serves claude.ai is the SDK's protocol negotiation, and SUPPORTED_PROTOCOL_VERSIONS in @modelcontextprotocol/server still lists 2025-03-26. An SDK bump that drops the 2025 revisions disconnects every client tested, so a test asserts that list directly. (An earlier version of this note claimed a legacy: 'reject' option was the risk. There is no such option on createMcpHandler in [email protected], and the test guarding it could never have failed.)

Consent takes several seconds. Completing the grant does real work (transaction consume, ledger insert, provider RPC with its own KV writes and existing-grant sweep, ledger activate). The consent page therefore shows a spinner and states plainly that no second press is needed, and a duplicate submission is answered as "already being set up" rather than as an error, because the first press is the one that counts.

Known failure signatures

invalid_token: Token audience does not match resource server on every tool call, while OAuth completes normally and the token looks fine. The provider enforces RFC 8707 audience binding, so MCP_RESOURCE_URL must equal the origin clients actually reach, exactly: scheme, host and the /mcp path. A staging value left pointing at production, or a local run advertising the deployed hostname, produces this. Asserted for both environments by workers/mcp/src/__tests__/config-invariants.test.ts; found by the Phase 0 spike, 2026-08-07.

Every tool call refused while OAuth completes normally, and clientId is null. The Agents SDK reads verified auth from Symbol.for('cloudflare.workers-oauth-provider.verified-context.v1') on the worker ctx, and @cloudflare/[email protected] never sets it; createMcpHandler is strictly pass-through and verifies nothing itself. attachVerifiedAuth (workers/mcp/src/verified-auth.ts) bridges the gap using the provider's own unwrapToken(). If a future provider version publishes the symbol, delete the bridge; if tool calls start failing after a provider upgrade, check this first. Found live against claude.ai, 2026-08-07.

A 500 or a malformed response on discovery, in the first minute after a deploy. Seen once on the production rollout, 2026-08-07: one 500 and one non-JSON body out of the first ten requests to /.well-known/oauth-protected-resource, immediately after the worker deployed. Never reproduced across ~120 requests afterwards, and the worker tail showed no exceptions and no non-ok outcomes throughout. Recorded as UNEXPLAINED rather than resolved. The timing points at route or script propagation rather than our code. If you see this outside a deploy window, treat it as a real defect and start with a tail on podcasterplus-mcp rather than assuming it is the same benign thing.

Tokens stop working immediately after a connection is made twice. Replaying an authorization code does not merely fail, it revokes the tokens that code issued (OAuth 2.1 §7.1). That is the intended stolen-code defence, so a client that retries a code exchange loses its connection and must reconnect. Reconnecting the same client is safe on its own: the provider revokes the superseded grant and the new one works.

Standards posture (reviewed 2026-08-07)

Built against MCP 2026-07-28 and re-validated against Cloudflare's MCP v2 announcement mid-build. createMcpHandler is the official path (it graduated from experimental), McpAgent is deprecated and unused, the server is stateless, RFC 8707 audience binding and the RFC 9207 iss parameter are both satisfied by the pinned packages, and the SDK serves the 2025-03-26 revision alongside the current one so older clients still connect.

Two dated items to schedule rather than react to:

  • DCR is deprecated and will be removed in a future spec revision, under a 12-month minimum deprecation window. No fixed removal date is published; do not plan against one. It is currently the ONLY registration path any client has here, so this is not a fallback any more. The replacement order is pre-registered clients first, then CIMD, then DCR. Note that pre-registration is not a migration we can perform unilaterally: the spec requires the client to hold the id or offer a field for entering it, so a DCR id observed in the spike cannot simply be reused. Each vendor needs a verified configuration or a relationship before pre-registration counts as an exit.
  • Roots, sampling and logging are deprecated, and MCP Apps, Enterprise-Managed Authorization and Tasks moved to extensions. None are used here, so nothing is at risk, but do not adopt one without checking its status first.

Elicitation is the one to watch: the protocol now lets a tool return input_required and refuse to act until the client answers, which is a server-held gate rather than an advisory hint. That is the mechanism a future write tool would use for confirmation (plan §7.3), and the spike probes whether real clients honour it.

Provider library policy

@cloudflare/workers-oauth-provider is pinned exactly (currently 0.10.1). It is security-critical infrastructure:

  • Treat every upgrade as a security-sensitive migration with its own review, never a routine bump.
  • Keep implicit flow and plain PKCE disabled.
  • Known accepted residual: the provider accepts the immediately previous refresh token until a newer one is used (upstream issue #43), so the usual stolen-refresh-token reuse signal is absent. Blast radius is capped by the ledger and short access-token lifetimes.
  • Advisory history: two published advisories (PKCE downgrade, missing redirect-URI validation), both fixed in 0.0.5, well before the pinned version.

What is deliberately absent

  • No write tools. Client-side confirmation is not an authorization control (clients can auto-run tools), so any future write tool needs server-side approval mechanics first: a pending-action record the user confirms in-app. See plan §7.3.
  • No search tool. Duplicates list-plus-filter and invites enumeration.
  • No guest-plane access. Guests are not Supabase users; there is no tool that can reach episode_guests.
  • No contact-detail FIELDS. Serializers omit guest email and phone deliberately. This is not the same as "no contact details": guest biographies, host notes, booking-form answers and transcripts are free text, and anything typed into them leaves as written. redactContactDetails strips email and phone patterns from booking answers and guest notes, and redactCredentials strips token-bearing URLs from every free-text string, but pattern matching reduces the residual rather than removing it. The published wording says so; do not restore a categorical claim to any surface.

Internal documentation - Not for public distribution