Skip to content

MCP OAuth Stage 1: Dynamic Client Registration (RFC 7591)#89

Merged
heskew merged 3 commits into
mainfrom
mcp/dcr-scaffolding
May 21, 2026
Merged

MCP OAuth Stage 1: Dynamic Client Registration (RFC 7591)#89
heskew merged 3 commits into
mainfrom
mcp/dcr-scaffolding

Conversation

@heskew
Copy link
Copy Markdown
Member

@heskew heskew commented May 21, 2026

Summary

Stage 1 of MCP OAuth support for #86. Adds the persistent client registry plus the POST /oauth/mcp/register endpoint. No /authorize, no /token, no withMCPAuth yet — those are Stages 3–5.

The endpoint is dispatched through OAuthResource.post when providerName === 'mcp'. Stage 4 will extend the same dispatcher for /token.

Why

MCP clients (Claude Desktop, Cursor, mcp-remote) register at runtime with no pre-baked client_id and cache the issued ID across launches. A persistent server-side store is the foundation for everything downstream — without it the spec-compliant DCR flow can't even start, and Claude Desktop silently breaks across deploys.

Where to look

  • src/lib/mcp/dcr.ts — RFC 7591 validation logic. Note the deliberate deviation from the RFC's client_secret_basic default (we default to none because MCP clients are overwhelmingly public; documented at the call site).
  • src/lib/mcp/clientStore.ts — table-backed CRUD. Array fields (redirect_uris, grant_types, etc.) JSON-encode on write/decode on read, matching the existing csrf_tokens.data pattern. null from the DB is normalized to undefined on read.
  • schema/oauth.graphql — new harper_oauth_mcp_clients table. No expiration: registrations must outlive restarts so cached client_ids in MCP clients keep working.
  • src/lib/config.ts + src/index.ts — new expandEnvVarsDeep walks string leaves on the mcp config block so secrets like mcp.dynamicClientRegistration.initialAccessToken support ${ENV_VAR} like every other secret field in the plugin.

What was reviewed

Gemini CLI 0.42.0 cross-reviewed the diff against RFC 7591 and surfaced four real issues, all addressed in this PR before pushing:

  1. mcp config block was skipping env-var expansion → fixed via expandEnvVarsDeep
  2. Scalar metadata fields (client_name, logo_uri, software_id, software_version, etc.) weren't validated as strings → could persist arbitrary objects → fixed
  3. decodeRecord returned null for array-valued fields when the DB stored null → fixed (normalized to undefined)
  4. Redundant capitalized Authorization header check (Node's HTTP parser lowercases) → dropped, test updated to reflect production contract

Test plan

  • All 516 unit tests pass locally (npm test); +7 from this PR (expandEnvVarsDeep, scalar validation)
  • npm run lint clean
  • npm run format:check clean
  • Manual verification of the endpoint against a real MCP client (deferred to Stage 7 — full end-to-end integration fixture; meaningful only once /authorize + /token land)
  • Confirm replicated Harper deployments share the registry across nodes (deferred to Stage 7 integration)

Out of scope

  • /.well-known/oauth-protected-resource, /.well-known/oauth-authorization-server, /.well-known/jwks.json — Stage 2
  • /oauth/mcp/authorize — Stage 3
  • /oauth/mcp/token + JWT signing — Stage 4
  • withMCPAuth wrapper — Stage 5
  • Refresh-token revocation, audit hooks — Stages 4/6
  • End-to-end fixture against an MCP client — Stage 7

🤖 Generated with Claude Code

Stage 1 of MCP OAuth support (issue #86). Adds the persistent client
registry and POST /oauth/mcp/register endpoint so MCP clients (Claude
Desktop, Cursor, mcp-remote) that cache their issued client_id continue
working across Harper restarts.

- New harper_oauth_mcp_clients table declared in schema/oauth.graphql
- MCPClientStore mirrors the existing CSRFTokenManager pattern
- handleRegister implements RFC 7591: redirect URI validation
  (HTTPS-only except loopback per RFC 8252 §8.3, no fragments, optional
  host allowlist), public-client default (deviation from RFC default
  client_secret_basic, documented), supported grant_types and
  response_types whitelisted
- Sensitive config leaves under `mcp` (e.g. initialAccessToken) support
  ${ENV_VAR} expansion via expandEnvVarsDeep
- Endpoint dispatched through OAuthResource.post when providerName=mcp;
  /authorize and /token follow in subsequent stages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heskew heskew requested a review from kriszyp May 21, 2026 12:00
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 21, 2026

Reviewed; no blockers found.

Comment thread src/lib/mcp/dcr.ts Outdated
Comment thread src/lib/mcp/index.ts
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Reviewed; no blockers found.

@heskew heskew linked an issue May 21, 2026 that may be closed by this pull request
Three blockers from Claude and Gemini reviewers, all real:

1. **Constant-time initialAccessToken compare** (Claude). Replaced
   `presented !== configured` with `timingSafeEqual` (length-checked
   first, since timingSafeEqual requires equal-length buffers). A
   precise-latency attacker could otherwise progressively recover the
   token character-by-character.

2. **Spread on Harper tracked object dropped scalar fields** (Gemini —
   the exact gotcha called out in CLAUDE.md). decodeRecord used
   `{ ...raw }`, which silently produces `{}` against a
   GenericTrackedObject Proxy (own-keys is empty), so client_id and
   every other scalar field would have been lost on read in production.
   Rewrote encodeRecord and decodeRecord to use explicit field access
   for the known MCPClientRecord shape. Mock in clientStore.test.js now
   wraps stored rows in a tracked-object Proxy so this can't regress;
   added an explicit "preserves scalar fields" assertion.

3. **handleMCPPost dispatcher untested** (Claude). The master enable
   gate, action routing, and unknown-action 404 all bypassed the
   handleRegister-direct tests. Added test/lib/mcp/index.test.js
   covering all three (and the layered deny when MCP is enabled but
   DCR is explicitly off).

525 unit tests pass locally (+9 net from these fixes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heskew
Copy link
Copy Markdown
Member Author

heskew commented May 21, 2026

Addressed both bot reviews in acc5147. All three flagged blockers were real:

1. Constant-time initialAccessToken compare (Claude, src/lib/mcp/dcr.ts:43) → timingSafeEqual from node:crypto, length-checked first.

2. Spread on Harper tracked object dropped scalar fields (Gemini, src/lib/mcp/clientStore.ts:55) → the exact gotcha called out in CLAUDE.md. { ...raw } against a GenericTrackedObject Proxy produces {}, so client_id and every other scalar would have vanished on read in production. encodeRecord and decodeRecord rewritten to use explicit field access. The test mock now wraps stored rows in a tracked-object Proxy so this can't regress, with an explicit "preserves scalar fields" assertion as a tripwire.

3. handleMCPPost dispatcher untested (Claude, src/lib/mcp/index.ts:28) → new test/lib/mcp/index.test.js covers the master enable gate (4 cases), action routing (3 cases), and the layered deny when MCP is enabled but DCR is explicitly off. Each deny path asserts the protected action did not run.

525 unit tests now passing locally (+9 from these fixes). CI re-running.


🤖 Posted by Claude on Nathan's behalf

@heskew heskew marked this pull request as ready for review May 21, 2026 12:36
Gemini's second-round review on PR #89 flagged that admins reading the
README post-merge have no way to discover that the new /oauth/mcp/register
endpoint defaults to OPEN registration per RFC 7591, nor how to lock it
down. Real security-visibility gap.

Adding the security-relevant surface only:

- README "Database Schema" section: mention harper_oauth_mcp_clients
- README "Security Considerations": call out open-by-default DCR and the
  two ways to gate it (initialAccessToken, allowedRedirectUriHosts)
- docs/configuration.md: new "MCP OAuth (work in progress)" subsection
  with the mcp config block, sub-fields, env expansion note, and a
  prominent in-line warning about open registration

Full MCP docs (quickstart, /authorize, /token, withMCPAuth wrapper) stay
deferred to Stage 8 of issue #86 — meaningful only once those endpoints
land in subsequent PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@heskew
Copy link
Copy Markdown
Member Author

heskew commented May 21, 2026

Addressed Gemini's second-round finding in f1185e8, partially.

The docs gap is real for one specific reason: mcp.dynamicClientRegistration.initialAccessToken ships open-by-default per RFC 7591, and an admin reading the README post-merge has no way to learn it exists or how to lock it down. That's a security-visibility issue, not a docs nicety.

So this PR now adds:

  • README "Database Schema": one paragraph naming harper_oauth_mcp_clients with a pointer to Add MCP OAuth flow support #86
  • README "Security Considerations": a bullet explicitly flagging open-by-default DCR + the two gates (initialAccessToken, allowedRedirectUriHosts)
  • docs/configuration.md MCP OAuth section: the mcp config block, sub-fields table, env-expansion note, and an in-line warning that without initialAccessToken registration is open

What's intentionally NOT in this PR: full MCP API walkthrough, /authorize//token docs, withMCPAuth usage, quickstart, OAuth dance diagram. Those are deferred to Stage 8 of #86 — documenting one third of an unusable flow (PR #89 ships only /register; /authorize and /token land in Stages 3–4) teaches integrators something they can't act on, which is worse than no docs. The marker "(work in progress)" in the section heading signals this.


🤖 Posted by Claude on Nathan's behalf

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add MCP OAuth flow support

2 participants