Fix MCP discovery: path-aware well-known URL and protocol version#19766
Fix MCP discovery: path-aware well-known URL and protocol version#19766FelixMalfait merged 6 commits intomainfrom
Conversation
📊 API Changes ReportGraphQL Schema ChangesGraphQL Schema Changes[error] Error: Unable to read JSON file: /home/runner/work/twenty/twenty/main-schema-introspection.json: Not valid JSON content GraphQL Metadata Schema ChangesGraphQL Metadata Schema Changes[error] Error: Unable to read JSON file: /home/runner/work/twenty/twenty/main-metadata-schema-introspection.json: Not valid JSON content REST API Analysis ErrorError OutputREST Metadata API Analysis ErrorError Output |
|
🚀 Preview Environment Ready! Your preview environment is available at: http://bore.pub:46340 This environment will automatically shut down after 5 hours. |
The latest MCP spec instructs clients to probe the resource-specific well-known URL first (e.g. `/.well-known/oauth-protected-resource/mcp`) before falling back to the root path. Without a route for that path the request fell through to ServeStaticModule and returned the SPA's index.html with HTTP 200, which strict clients (e.g. Claude.ai) tried to parse as JSON and failed with "Couldn't reach the MCP server". Also bump the advertised MCP protocol version from `2024-11-05` to `2025-06-18` to match the actual transport (Streamable HTTP) the server implements. The old version predates Streamable HTTP and caused some clients to reject the handshake. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Rewrite the discovery-route comments so they explain what the code is (RFC 9728 defines both well-known URL forms; we serve both) rather than narrating the bug they were introduced to fix. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test asserted a hardcoded protocol version, so any future bump to the constant breaks the suite. Reference the constant directly so the assertion stays in sync with what the server actually advertises. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dceb750 to
4db2871
Compare
This reverts commit d3df580.
`application-oauth.module.ts` imported `DomainServerConfigModule` twice in the `imports` array. Drive-by cleanup of a pre-existing duplicate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Added two commits on top of the original PR:
Verified every deployment × paste-URL pairing:
Tests: 4/4 passing ( |
|
Hey @FelixMalfait! After you've done the QA of your Pull Request, you can mark it as done here. Thank you! |
…19838) ## Summary Three small spec-compliance fixes called out in an audit against the [MCP authorization spec (draft)](https://modelcontextprotocol.io/specification/draft/basic/authorization) and RFC 9728 / RFC 9207. ### 1. Split Protected Resource Metadata by path (RFC 9728 §3.2) > The `resource` value returned MUST be identical to the protected resource's resource identifier value into which the well-known URI path suffix was inserted. Today a single handler serves both \`/.well-known/oauth-protected-resource\` and \`/.well-known/oauth-protected-resource/mcp\` and returns \`resource: <origin>/mcp\` from both. That's wrong for the root form — per RFC 9728 the root URL corresponds to the **origin as resource**, and only the \`/mcp\`-suffixed URL corresponds to \`<origin>/mcp\`. After this PR: | Request | `resource` field | |---|---| | `GET /.well-known/oauth-protected-resource` | `https://<host>` | | `GET /.well-known/oauth-protected-resource/mcp` | `https://<host>/mcp` | Both still return the same `authorization_servers`, `scopes_supported`, and `bearer_methods_supported`. Claude's current flow happens to work because our WWW-Authenticate points at the root form and Claude compares `resource` against what it connected to. Strict clients probing the path-aware URL first were rejecting us. ### 2. Advertise `authorization_response_iss_parameter_supported: true` (RFC 9207) Defense against OAuth mix-up attacks. Required by the [OAuth 2.1 security BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1). Signals that clients receiving an authorization response will find the issuer in the `iss` parameter and can validate it. ### 3. Fix `WWW-Authenticate` challenge: point at path-aware PRM URL, add `scope` param - Was: `Bearer resource_metadata=\"https://<host>/.well-known/oauth-protected-resource\"` - Now: `Bearer resource_metadata=\"https://<host>/.well-known/oauth-protected-resource/mcp\", scope=\"api profile\"` After change (1), only the path-aware URL returns a PRM document whose `resource` matches what the MCP client connected to (\`<host>/mcp\`). Pointing clients at the right URL keeps discovery consistent. The `scope` parameter is a SHOULD in RFC 6750 and lets clients ask for least-privilege scopes on first authorization. ## Not in this PR (queued separately) From the same audit: - **Audit JWT `aud` (audience) validation** — the spec requires the server to reject tokens whose audience doesn't match this resource. Need a read-only code review to confirm; filing as a follow-up. - **Audit PKCE enforcement** — we advertise `code_challenge_methods_supported: [\"S256\"]`; need to confirm the \`/authorize\` flow actually rejects requests missing `code_challenge`. - **403 `insufficient_scope` challenge format** for step-up auth. - **CIMD (Client ID Metadata Documents)** support — newer spec alternative to DCR. ## Test plan - [x] \`yarn jest --testPathPatterns=\"mcp-auth.guard|oauth-discovery.controller\"\` → 4/4 passing - [x] \`tsc --noEmit\` clean on touched files - [ ] After deploy: \`\`\`bash curl -s https://<host>/.well-known/oauth-protected-resource | jq .resource # expect: \"https://<host>\" curl -s https://<host>/.well-known/oauth-protected-resource/mcp | jq .resource # expect: \"https://<host>/mcp\" curl -sI -X POST https://<host>/mcp | grep -i www-authenticate # expect: Bearer resource_metadata=\"…/oauth-protected-resource/mcp\", scope=\"api profile\" \`\`\` ## Related - #19836 — CORS exposes `WWW-Authenticate` + `MCP-Protocol-Version` so browser clients can read them. Pairs with this PR. - #19755 / #19766 / #19824 — the earlier chain that got host-aware discovery and \`TRUST_PROXY\` working. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…ents (#19836) ## The bug Claude's MCP connector fails with \"Couldn't reach the MCP server\" on every URL (\`api.twenty.com/mcp\`, \`app.twenty.com/mcp\`, \`<workspace>.twenty.com/mcp\`, custom domains). The failure happens **before** any OAuth flow starts — the client never even reaches the consent screen. ## Root cause \`POST /mcp\` unauthenticated returns: \`\`\` HTTP/2 401 access-control-allow-origin: * www-authenticate: Bearer resource_metadata=\"https://…/.well-known/oauth-protected-resource\" content-type: application/json; charset=utf-8 (no access-control-expose-headers) \`\`\` The [Fetch/CORS spec](https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name) defines only six response headers as safelisted — \`Cache-Control\`, \`Content-Language\`, \`Content-Type\`, \`Expires\`, \`Last-Modified\`, \`Pragma\`. Every other header is withheld from cross-origin JS unless the server opts it in via \`Access-Control-Expose-Headers\`. Result: Claude's browser-side MCP client receives the 401 but \`response.headers.get('WWW-Authenticate')\` returns \`null\`. No \`resource_metadata\` URL, no discovery, no OAuth — the client gives up with the generic \"can't reach server\" error. The [MCP authorization spec](https://modelcontextprotocol.io/specification/draft/basic/authorization) explicitly requires this header to be exposed. ## Fix One config change in \`main.ts\`: \`\`\`ts - cors: true, + // Expose WWW-Authenticate so browser-based MCP clients can read the + // resource_metadata pointer on 401. Required by MCP authorization spec. + cors: { exposedHeaders: ['WWW-Authenticate'] }, \`\`\` NestJS's default \`cors: true\` uses the \`cors\` package defaults, which don't set \`exposedHeaders\`. Moving to an explicit config keeps all other defaults (origin \`*\`, standard methods) and adds the single required expose. ## Why it's safe and generally beneficial - \`Access-Control-Expose-Headers: WWW-Authenticate\` is sent on every response but only has an effect when \`WWW-Authenticate\` is actually present (i.e. 401s). It's an opt-in permission, not a header-setter. - \`WWW-Authenticate\` itself is still only set by \`McpAuthGuard\` on 401 — this PR doesn't change where or when the header is emitted. - Covers the entire app, not just \`/mcp\` — any future 401-returning endpoint will behave correctly for browser clients automatically. - No change to origin handling, methods, or credentials. All existing API / GraphQL / REST traffic is unaffected. ## Verification After deploy: \`\`\`bash curl -sI -X POST -H \"Origin: https://claude.ai\" https://api.twenty.com/mcp \\ | grep -iE 'access-control-expose|www-authenticate' # Expect: # access-control-expose-headers: WWW-Authenticate # www-authenticate: Bearer resource_metadata=\"…\" \`\`\` Then re-try adding the MCP connector in Claude — if this was the only blocker, OAuth should now complete. ## Related - #19755, #19766, #19824 — prior fixes in the MCP/OAuth discovery chain (host-aware metadata, path-aware well-known, \`TRUST_PROXY\` for \`request.protocol\`). This PR completes the CORS side of that work. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Summary
Adding
https://api.twenty.com/mcpas an MCP server in Claude fails withCouldn't reach the MCP serverbefore OAuth can start. Two independent bugs cause this:/.well-known/oauth-protected-resource/mcpbefore/.well-known/oauth-protected-resource. Only the root path was registered, so the path-aware request fell through toServeStaticModuleand returned the SPA'sindex.htmlwith HTTP 200. Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed by registering both paths on the same handler.2024-11-05, which predates Streamable HTTP. We've implemented Streamable HTTP (SSE response format was added in Add SSE streaming support on POST /mcp (Phase 2) #19528), so bumped to2025-06-18.Reproduction before the fix:
After the fix this returns
application/jsonwith the RFC 9728 metadata document.Note: this is separate from #19755 (host-aware resource URL for multi-host deployments).
Test plan
npx jest oauth-discovery.controller— 2/2 tests pass, including one asserting both routes are registerednpx nx lint:diff-with-main twenty-serverpassescurl https://api.twenty.com/.well-known/oauth-protected-resource/mcpreturns JSON (not HTML)https://api.twenty.com/mcpin Claude reaches the OAuth authorization screen🤖 Generated with Claude Code