MCP OAuth Stage 1: Dynamic Client Registration (RFC 7591)#89
Conversation
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>
|
Reviewed; no blockers found. |
|
Reviewed; no blockers found. |
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>
|
Addressed both bot reviews in 1. Constant-time 2. Spread on Harper tracked object dropped scalar fields (Gemini, 3. 525 unit tests now passing locally (+9 from these fixes). CI re-running. 🤖 Posted by Claude on Nathan's behalf |
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>
|
Addressed Gemini's second-round finding in The docs gap is real for one specific reason: So this PR now adds:
What's intentionally NOT in this PR: full MCP API walkthrough, 🤖 Posted by Claude on Nathan's behalf |
Summary
Stage 1 of MCP OAuth support for #86. Adds the persistent client registry plus the
POST /oauth/mcp/registerendpoint. No/authorize, no/token, nowithMCPAuthyet — those are Stages 3–5.The endpoint is dispatched through
OAuthResource.postwhenproviderName === '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_idand 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'sclient_secret_basicdefault (we default tononebecause 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 existingcsrf_tokens.datapattern.nullfrom the DB is normalized toundefinedon read.schema/oauth.graphql— newharper_oauth_mcp_clientstable. No expiration: registrations must outlive restarts so cachedclient_ids in MCP clients keep working.src/lib/config.ts+src/index.ts— newexpandEnvVarsDeepwalks string leaves on themcpconfig block so secrets likemcp.dynamicClientRegistration.initialAccessTokensupport${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:
mcpconfig block was skipping env-var expansion → fixed viaexpandEnvVarsDeepclient_name,logo_uri,software_id,software_version, etc.) weren't validated as strings → could persist arbitrary objects → fixeddecodeRecordreturnednullfor array-valued fields when the DB storednull→ fixed (normalized toundefined)Authorizationheader check (Node's HTTP parser lowercases) → dropped, test updated to reflect production contractTest plan
npm test); +7 from this PR (expandEnvVarsDeep, scalar validation)npm run lintcleannpm run format:checkclean/authorize+/tokenland)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 4withMCPAuthwrapper — Stage 5🤖 Generated with Claude Code