## 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>
96 lines
3.2 KiB
TypeScript
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();
|