Skip to content

MCP OAuth: make the Recoup MCP server connectable as a Claude connector #695

Description

@sidneyswift

Spec: OAuth for the Recoup MCP server (Claude connector)

Status: ready for implementation
Owner: api
Last grounded: 2026-06-23 (live probe of api.recoupable.com + repo reads + Claude/Privy docs)

Each requirement is tagged [VERIFIED], [ASSUMED], or [DECISION]. See the
Assumptions vs Verified appendix at the bottom.

Objective

Make https://api.recoupable.com/mcp connectable as a one-click OAuth connector in
Claude (Cowork/Desktop/web): a user clicks "Connect," logs in with their Recoup
identity, picks an org, and the agent gets scoped tool access — no API-key paste.

Verified current state

  • [VERIFIED – repo] app/mcp/route.ts: createMcpHandler(registerAllTools) wrapped in
    withMcpAuth(baseHandler, verifyBearerToken, { required: true }).
  • [VERIFIED – repo] lib/mcp/verifyApiKey.ts → verifyBearerToken: accepts Bearer =
    Privy JWT or Recoup API key → { extra: { accountId } }, scope mcp:tools.
  • [VERIFIED – repo] middleware.ts: x402 charges only GET /api/x402/image/generate;
    matcher /api/* + /protected/*. /mcp and /.well-known/* are outside the matcher
    → not paywalled.
  • [VERIFIED – repo] Deps present: mcp-handler@^1.0.4, @modelcontextprotocol/sdk@^1.24.3,
    @privy-io/node@^0.6.2, Next 16.
  • [VERIFIED – live curl] GET/POST /mcp with no auth → 401 with:
    www-authenticate: Bearer error="invalid_token",
      error_description="No authorization provided",
      resource_metadata="https://api.recoupable.com/.well-known/oauth-protected-resource"
    
    → The 401 + WWW-Authenticate + resource_metadata pointer already works
    (mcp-handler built-in).
  • [VERIFIED – live curl] Every discovery doc 404s:
    /.well-known/oauth-protected-resource (and /mcp suffix),
    /.well-known/oauth-authorization-server (and /mcp suffix),
    /.well-known/openid-configuration.

The gap (verified)

Per Claude's troubleshooting doc: if the PRM the WWW-Authenticate points to returns 404,
"Claude has no way to start the OAuth flow." So the connector cannot work today purely
because discovery + an authorization server don't exist. Concretely missing:

  1. [GAP] PRM document at /.well-known/oauth-protected-resource.
  2. [GAP] An Authorization Server: metadata (/.well-known/oauth-authorization-server
    or /.well-known/openid-configuration) + authorize + token endpoints + client
    registration (CIMD or DCR).
  3. [GAP] verifyBearerToken must also accept the AS-issued, audience-bound access tokens.

The contract Claude needs

  • [VERIFIED – Claude docs] Claude connectors support oauth_dcr (RFC 7591 Dynamic Client
    Registration) and oauth_cimd (Client ID Metadata Document) out of the box.
  • [VERIFIED – Claude/MCP docs] Required: PKCE S256; PRM (RFC 9728); ASM (RFC 8414)
    advertising authorization_endpoint, token_endpoint,
    code_challenge_methods_supported:["S256"],
    token_endpoint_auth_methods_supported:["none"] (public clients), and a
    registration_endpoint or client_id_metadata_document_supported:true;
    offline_access in scopes_supported for refresh tokens; audience-bound tokens
    (RFC 8707) bound to the MCP URL.
  • [VERIFIED – Claude docs] The PRM resource field must exactly match the MCP URL the
    user enters (https://api.recoupable.com/mcp).

Approach — Authorization Server [DECISION]

An AS is required regardless; the only question is build vs buy. Two findings shape this:

  • [VERIFIED] Privy is not an MCP-compliant AS (no DCR/PKCE AS) — it's an identity
    provider. Privy is the login step inside an AS, not the AS itself.
  • [VERIFIED] CIMD is now the spec-preferred client-identity mechanism (only ~4% of AS
    support DCR in practice; CIMD needs no client DB). Claude supports both.

Option A (recommended): thin in-house AS in api, Privy as IdP, CIMD.
Routes: /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server,
/oauth/authorize (renders Privy login + consent), /oauth/token (PKCE verify → issue
audience-bound JWT). With CIMD you can skip a /register DB and set
client_id_metadata_document_supported:true.

  • Why: reuses Privy + Supabase accounts, no new vendor, no client DB. A close reference
    implementation exists (indexnetwork/mcp: Express MCP + /authorize /token
    /.well-known/* + Privy login + consent).
  • Cost: you own an OAuth AS surface (needs a security review). [ASSUMED] effort ≈
    small-to-medium.

Option B: managed AS (WorkOS AuthKit / Stytch / Scalekit / Auth0 / Keycloak / Supabase
Auth) federated to Privy/email; PRM authorization_servers points to its issuer; the verifier
validates its JWT via JWKS.

  • Why: offloads OAuth correctness. Cost: new vendor + identity federation.
  • [ASSUMED – not verified] which of these currently ship MCP-grade DCR/CIMD — confirm
    during selection.

Concrete changes (api)

  1. app/.well-known/oauth-protected-resource/route.ts200 JSON:
    { resource: "https://api.recoupable.com/mcp", authorization_servers: [<issuer>], scopes_supported: ["mcp:tools","offline_access"] }. Add OPTIONS/CORS.
    [ASSUMED] mcp-handler@1.0.4 may export protectedResourceHandler /
    metadataCorsOptionsRequestHandler — confirm against the installed package; if absent,
    hand-roll the JSON (trivial).
  2. Authorization Server metadata route (Option A) or point PRM at the managed issuer
    (Option B).
  3. app/oauth/authorize + app/oauth/token (Option A only) — PKCE S256, Privy login +
    consent, audience-bound short-lived access token + rotating refresh.
  4. Extend lib/mcp/verifyApiKey.ts → verifyBearerToken with an OAuth-token branch (validate
    signature/JWKS + aud = MCP URL + expiry → accountId). Keep the Privy-JWT and API-key
    branches
    unchanged.
  5. Add orgId to McpAuthInfoExtra; thread through resolveAccountId.

Identity mapping [VERIFIED pattern]

Verified email from login → selectAccountEmails({emails:[email]})accountId; if none,
provision via createAccountWithEmail (the same path getOrCreateAccountIdByAuthToken
already uses). Never trust an unverified email claim.

Org handling

Consent screen offers org selection when the account has multiple orgs; persist and surface
as orgId. v1 fallback: resolve org per-call as today.

Security

  • Discovery + token endpoints public + CORS; everything else stays gated.
  • PKCE S256 mandatory (reject plain); token_endpoint_auth_methods_supported:["none"].
  • Audience-bound tokens (aud = https://api.recoupable.com/mcp), validated in
    verifyBearerToken; short-lived access + rotating refresh; redirect-URI exact match.
    Never log tokens.

Backward compatibility

  • [VERIFIED] Existing API-key Bearer (external agents, per docs/mcp.mdx) and Privy-JWT
    Bearer must keep working — OAuth is additive.
  • [ASSUMED – not verified] Whether the chat app is itself a live MCP client (and how it
    authenticates). Grep only found /mcp in ToolComponents.tsx (likely rendering tool
    results, not a client). Confirm before assuming chat depends on this auth path.

Test plan

  1. npx @modelcontextprotocol/inspector → connect to a preview /mcp → full OAuth flow →
    list tools → call get_local_time, then create_new_artist.
  2. curl the two .well-known docs → 200 valid JSON + CORS; PRM resource matches the
    MCP URL exactly.
  3. Re-curl /mcp unauth → confirm 401 + WWW-Authenticate still correct (it already is).
  4. Add the connector in Claude Cowork/Desktop end-to-end (consent, org pick, tool calls).
  5. Extend lib/mcp/__tests__/verifyApiKey.test.ts: API-key + Privy-JWT + new OAuth token all
    authenticate; wrong-audience token rejected.
  6. Refresh + expiry + revoked-token rejection.

Acceptance criteria

  • New user adds "Recoup" in Claude via one-click OAuth, logs in with Recoup identity, calls
    tools — no API key pasted.
  • .well-known docs return 200; the discovery → authorize → token → /mcp chain passes MCP
    Inspector.
  • Existing API-key/Privy-JWT clients unaffected.
  • Tools resolve correct accountId (+ orgId if selected).

Out of scope (v1)

Local git workspace / scoped git tokens; per-tool scopes; retiring API keys; OpenClaw →
open-agents migration behind prompt_sandbox.

Appendix — Assumptions vs Verified

# Claim Status How
1 /mcp returns 401 + WWW-Authenticate w/ resource_metadata VERIFIED live curl
2 All .well-known discovery docs 404 (the gap) VERIFIED live curl
3 MCP route uses withMcpAuth + verifyBearerToken; accepts Privy JWT or API key VERIFIED read source
4 x402 only charges image route; /mcp & /.well-known/* not matched VERIFIED read middleware.ts
5 Dep versions (mcp-handler 1.0.4, MCP SDK 1.24.3, Privy) VERIFIED package.json
6 Claude connectors support DCR and CIMD; require PKCE/PRM/ASM/audience-binding VERIFIED Claude connector docs
7 CIMD now preferred over DCR VERIFIED Claude docs + MCP.directory
8 Privy can't be the AS (no DCR); used as IdP behind a custom AS VERIFIED Privy docs + reference impl
9 prompt_sandbox → OpenClaw (legacy) VERIFIED docs/mcp.mdx
10 "Claude Cowork uses the same connector OAuth mechanism as Desktop/web" ASSUMED (high) inferred from Claude connector docs; not Cowork-named
11 Exact mcp-handler@1.0.4 exports for serving PRM/ASM ASSUMED node_modules not installed to read; hand-roll is the fallback
12 MCP SDK 1.24.3 OAuth-server helpers (mcpAuthRouter, etc.) ASSUMED not read this session
13 Which managed AS (WorkOS/Stytch/Scalekit/Auth0/Supabase) ship MCP-grade DCR/CIMD ASSUMED not verified per-vendor
14 Prod deploy == repo for the MCP route MOSTLY VERIFIED live 401 matches repo's withMcpAuth; not git-diffed
15 Chat app is a live MCP client and how it authenticates ASSUMED / UNVERIFIED grep inconclusive
16 RFC numbers (9728/8414/7591/8707/7636) VERIFIED corroborated in docs this session
17 CIMD vs DCR; build vs buy AS DECISION team call

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions