From 1f3defa7b35530e1584f0267a49fcfd9373954a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 18 Apr 2026 21:28:59 +0200 Subject: [PATCH] fix(server): expose WWW-Authenticate header for browser-based MCP clients (#19836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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\`, \`.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 --- packages/twenty-server/src/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index 13b2cd708ed..3190a509539 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -28,7 +28,9 @@ const bootstrap = async () => { setPgDateTypeParser(); const app = await NestFactory.create(AppModule, { - 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'] }, bufferLogs: process.env.LOGGER_IS_BUFFER_ENABLED === 'true', rawBody: true, snapshot: process.env.NODE_ENV === NodeEnvironment.DEVELOPMENT,