Files
twenty/packages/twenty-server/src/main.ts
T
1f3defa7b3 fix(server): expose WWW-Authenticate header for browser-based MCP clients (#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>
2026-04-18 21:28:59 +02:00

96 lines
3.2 KiB
TypeScript

import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import fs from 'fs';
import bytes from 'bytes';
import { useContainer } from 'class-validator';
import session from 'express-session';
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { setPgDateTypeParser } from 'src/database/pg/set-pg-date-type-parser';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
import { UnhandledExceptionFilter } from 'src/filters/unhandled-exception.filter';
import { AppModule } from './app.module';
import './instrument';
import { settings } from './engine/constants/settings';
import { generateFrontConfig } from './utils/generate-front-config';
// Trigger
const bootstrap = async () => {
setPgDateTypeParser();
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
// 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,
...(process.env.SSL_KEY_PATH && process.env.SSL_CERT_PATH
? {
httpsOptions: {
key: fs.readFileSync(process.env.SSL_KEY_PATH),
cert: fs.readFileSync(process.env.SSL_CERT_PATH),
},
}
: {}),
});
const logger = app.get(LoggerService);
const twentyConfigService = app.get(TwentyConfigService);
const trustProxyRaw = twentyConfigService.get('TRUST_PROXY');
const trustProxy = /^\d+$/.test(trustProxyRaw)
? Number(trustProxyRaw)
: (configTransformers.boolean(trustProxyRaw) ?? trustProxyRaw);
app.set('trust proxy', trustProxy);
app.use(session(getSessionStorageOptions(twentyConfigService)));
// Apply class-validator container so that we can use injection in validators
useContainer(app.select(AppModule), { fallbackOnErrors: true });
// Use our logger
app.useLogger(logger);
app.useGlobalFilters(new UnhandledExceptionFilter());
app.useBodyParser('json', { limit: settings.storage.maxFileSize });
app.useBodyParser('urlencoded', {
limit: settings.storage.maxFileSize,
extended: true,
});
// Graphql file upload
app.use(
'/graphql',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
}),
);
app.use(
'/metadata',
graphqlUploadExpress({
maxFieldSize: bytes(settings.storage.maxFileSize)!,
maxFiles: 10,
}),
);
// Inject the server url in the frontend page
generateFrontConfig();
await app.listen(twentyConfigService.get('NODE_PORT'));
};
bootstrap();