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)| Piece | Where | Role |
|---|---|---|
| OAuth AS + MCP endpoint | workers/mcp | Issues show.fm tokens; serves tools; holds no data |
| Consent + authorize | src/routes/(app)/settings/ai-connections/authorize | Browser leg, on app. so cookies stay put |
| Facade | src/api/routes/mcp | The only data path; internal-only |
| Middleware | src/api/middleware/mcp.ts | Provenance, predicate, fail-closed limiter, audit |
| Ledger + transactions | src/lib/server/mcp/ | Revocation authority, single-use consent state |
| Prune | workers/lifecycle-manager/src/mcp-prune.ts | 90-day audit retention, dead transactions |
The five invariants
Breaking any of these silently makes the surface unsafe. Each has a test.
- AI clients never hold a Supabase credential. GoTrue does not enforce token audience on its own user endpoints (
internal/api/auth.go:parseJWTClaimshas noWithAudience;UserUpdatehas 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. - The ledger is the revocation authority, not the provider.
workers-oauth-providervalidates a denormalised KV token record and never re-reads the grant; KV deletes can stay invisible elsewhere for 60s+.mcp_connections.statusis checked in Postgres on every tool call. Revocation writes the ledger FIRST, synchronously; provider cleanup is async and may fail without restoring access. - 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 againstapp.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). AWorkerEntrypointRPC would be a true capability boundary and is the upgrade path if this surface ever stops being read-only. - Identity comes from two sources that never swap.
client_idandscopescome from the provider's verifiedAuthInfo;user_idandconnection_idfrom its encrypted props. The facade re-checks all four against the ledger row, so forged props cannot widen access. - 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
userIdis 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 advertisesoffline_accessbecause the Anthropic clients request it and a requested scope must survive consent; protected-resource metadata advertises onlymcp:read. Advertising it is NOT what produces refresh tokens: the spike verified the provider issues one either way, and Cursor requestsmcp:readalone 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;
attachVerifiedAuthpublishessummary.scopefor exactly this reason. Publishingsummary.grant.scopewould silently restore what the client gave up.
Consent obligations (ours, not the library's)
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
stateparameter 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, scopemcp_tool, identifieruser:<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_callsrecords every call including denials, once the connection is verified. Tool identity is route identity (one facade route per tool) and the recordedtoolis derived from the route bycanonicalToolForPath, so nothing in the row is caller-supplied. Thex-mcp-toolheader 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 erroror[mcp] audit write threwand 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.
# 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 staging3. 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.
# 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 deploy7. 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)
DNS: proxied
AAAA mcp 100::onpodcasterplus.comandpodcasterplus.dev.Cookie strip (required): a Request Header Transform Rule on
mcp.podcasterplus.comremovingCookie. App cookies are scoped to the registrable parent domain, so a direct browser navigation tomcp.would otherwise carry them. The worker is alreadyworkers_dev = falsewith no preview URLs, so the route is the only path in.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 Pathequals/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.tomland applied byworkers/mcp/src/rate-limit.tsbefore the OAuth provider runs:/oauth/registerat 20/60s and/mcpat 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:grantIdprefix, 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 sendingBearer <random>:<random>:xwith 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_limiton the verifieduser:<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
/mcpis 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-MethodandMcp-Nameheaders so gateways can route without parsing the body. That would let a zone rule chargetools/calldifferently from discovery, and is worth doing if the plan is ever upgraded.- Field
Activate the worker in the pipeline (last step, after 1 to 3): the worker ships held out of CI deploys by
PENDING_INFRA_WORKERSinscripts/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'fromPENDING_INFRA_WORKERSand add it toDEPLOY_ORDER, updating the census counts inscripts/__tests__/affected-workers.test.js(16 deployable becomes 17, staging 15 becomes 16),add the app's
MCP_CONSENT_SERVICEservice binding to the rootwrangler.toml, includingentrypoint = "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, andpodcasterplus-mcpdoes not exist until production is promoted.🔴 The
entrypointfield is not optional for us. Omit it and the binding resolves to the worker's DEFAULT export, which only servesfetch; 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-rundoes not catch it, because the config is well-formed and prints the binding as satisfied; the giveaway is that a correct binding renders aspodcasterplus-mcp-staging#ConsentServicewhile a broken one has no#suffix.config-invariants.test.tsnow asserts everyMCP_CONSENT_SERVICEblock 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.✅ 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.ymlpushesdocs-publictowww.podcasterplus.com/docson any merge tomain, 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.txtoutputs and the committed support-deflection index that the support form and in-app Help launcher read. All five places had to move together:srcExcludeindocs-public/src/.vitepress/config.ts(build, sitemap, LLM outputs),EXCLUDED_FILESindocs-public/scripts/build-search-index.mjs, whose header requires lockstep withsrcExclude, thenpnpm run search-indexand BOTH regenerated artifacts committed (docs-public/src/public/search-index.jsonandstatic/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 themcp.connected-clientstooltip, 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. Confirmdevelopers/mcp-server.htmlis built and the page appears insitemap.xmlandllms.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:
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.
redactContactDetailsstrips email and phone patterns from booking answers and guest notes, andredactCredentialsstrips 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.