Fix MCP discovery: path-aware well-known URL and protocol version (#19766)
## Summary Adding `https://api.twenty.com/mcp` as an MCP server in Claude fails with `Couldn't reach the MCP server` before OAuth can start. Two independent bugs cause this: 1. **Missing path-aware well-known route.** The latest MCP spec instructs clients to probe `/.well-known/oauth-protected-resource/mcp` before `/.well-known/oauth-protected-resource`. Only the root path was registered, so the path-aware request fell through to `ServeStaticModule` and returned the SPA's `index.html` with HTTP 200. Strict clients (Claude.ai) tried to parse it as JSON and gave up. Fixed by registering both paths on the same handler. 2. **Stale protocol version.** Server advertised `2024-11-05`, which predates Streamable HTTP. We've implemented Streamable HTTP (SSE response format was added in #19528), so bumped to `2025-06-18`. Reproduction before the fix: ``` $ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://api.twenty.com/.well-known/oauth-protected-resource/mcp 200 text/html; charset=UTF-8 ``` After the fix this returns `application/json` with the RFC 9728 metadata document. Note: this is separate from #19755 (host-aware resource URL for multi-host deployments). ## Test plan - [x] `npx jest oauth-discovery.controller` — 2/2 tests pass, including one asserting both routes are registered - [x] `npx nx lint:diff-with-main twenty-server` passes - [ ] After deploy, `curl https://api.twenty.com/.well-known/oauth-protected-resource/mcp` returns JSON (not HTML) - [ ] Adding `https://api.twenty.com/mcp` in Claude reaches the OAuth authorization screen 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
||||
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
||||
|
||||
+2
@@ -19,6 +19,7 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -32,6 +33,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
ApplicationCoreModule,
|
||||
ApplicationInstallModule,
|
||||
TokenModule,
|
||||
DomainServerConfigModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { PATH_METADATA } from '@nestjs/common/constants';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type Request } from 'express';
|
||||
|
||||
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
describe('OAuthDiscoveryController', () => {
|
||||
let controller: OAuthDiscoveryController;
|
||||
|
||||
const buildMockRequest = (host: string, protocol = 'https') =>
|
||||
({
|
||||
protocol,
|
||||
get: (header: string) =>
|
||||
header.toLowerCase() === 'host' ? host : undefined,
|
||||
}) as unknown as Request;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [OAuthDiscoveryController],
|
||||
providers: [
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue('https://api.example.com'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainServerConfigService,
|
||||
useValue: {
|
||||
getBaseUrl: jest
|
||||
.fn()
|
||||
.mockReturnValue(new URL('https://app.example.com')),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: { findOneByUniversalIdentifier: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get(OAuthDiscoveryController);
|
||||
});
|
||||
|
||||
describe('getProtectedResourceMetadata', () => {
|
||||
it('echoes the request host in the resource and authorization_servers', () => {
|
||||
const request = buildMockRequest('workspace.twenty.com');
|
||||
|
||||
expect(controller.getProtectedResourceMetadata(request)).toEqual({
|
||||
resource: 'https://workspace.twenty.com/mcp',
|
||||
authorization_servers: ['https://workspace.twenty.com'],
|
||||
scopes_supported: ['api', 'profile'],
|
||||
bearer_methods_supported: ['header'],
|
||||
});
|
||||
});
|
||||
|
||||
// RFC 9728 defines both a root and a resource-specific well-known
|
||||
// URL; both must resolve to this handler so any conformant client
|
||||
// can discover the metadata.
|
||||
it('is registered at both the root and the /mcp path-aware URL', () => {
|
||||
const paths = Reflect.getMetadata(
|
||||
PATH_METADATA,
|
||||
controller.getProtectedResourceMetadata,
|
||||
);
|
||||
|
||||
expect(paths).toEqual([
|
||||
'oauth-protected-resource',
|
||||
'oauth-protected-resource/mcp',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-6
@@ -4,13 +4,18 @@ import { type Request } from 'express';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { cleanServerUrl } from 'src/utils/clean-server-url';
|
||||
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
|
||||
|
||||
@Controller('.well-known')
|
||||
export class OAuthDiscoveryController {
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainServerConfigService: DomainServerConfigService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
) {}
|
||||
|
||||
@@ -18,6 +23,13 @@ export class OAuthDiscoveryController {
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getAuthorizationServerMetadata(@Req() request: Request) {
|
||||
const issuer = this.getRequestBaseUrl(request);
|
||||
// /authorize is served by the frontend; SERVER_URL (API-only) has no such
|
||||
// route, so we route the client to the default frontend base URL in that
|
||||
// case. All other hosts (app.twenty.com, workspace subdomains, custom
|
||||
// domains) serve both frontend and API.
|
||||
const authorizeBase = this.isApiHost(request)
|
||||
? cleanServerUrl(this.domainServerConfigService.getBaseUrl().toString())
|
||||
: issuer;
|
||||
|
||||
const cliRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
@@ -26,7 +38,7 @@ export class OAuthDiscoveryController {
|
||||
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
authorization_endpoint: `${authorizeBase}/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
registration_endpoint: `${issuer}/oauth/register`,
|
||||
revocation_endpoint: `${issuer}/oauth/revoke`,
|
||||
@@ -48,11 +60,12 @@ export class OAuthDiscoveryController {
|
||||
};
|
||||
}
|
||||
|
||||
// RFC 9728: `resource` is echoed back as the host the client connected to
|
||||
// so that MCP clients can validate the resource indicator they were trying
|
||||
// to reach. Without this, pasting any URL other than SERVER_URL/mcp breaks
|
||||
// discovery.
|
||||
@Get('oauth-protected-resource')
|
||||
// RFC 9728: OAuth 2.0 Protected Resource Metadata.
|
||||
// Exposed at both the root and the resource-specific path (/mcp) because
|
||||
// RFC 9728 defines the path-aware form and clients may probe either.
|
||||
// The `resource` value echoes the host the request reached so the metadata
|
||||
// matches the resource indicator the client used.
|
||||
@Get(['oauth-protected-resource', 'oauth-protected-resource/mcp'])
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
getProtectedResourceMetadata(@Req() request: Request) {
|
||||
const base = this.getRequestBaseUrl(request);
|
||||
@@ -68,4 +81,10 @@ export class OAuthDiscoveryController {
|
||||
private getRequestBaseUrl(request: Request): string {
|
||||
return `${request.protocol}://${request.get('host')}`;
|
||||
}
|
||||
|
||||
private isApiHost(request: Request): boolean {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
return request.get('host') === new URL(serverUrl).host;
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
import request from 'supertest';
|
||||
|
||||
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
|
||||
|
||||
/**
|
||||
* Integration tests for MCP core controller
|
||||
*
|
||||
@@ -70,7 +72,7 @@ describe('MCP Controller (integration)', () => {
|
||||
expect(res.body.id).toBe(123);
|
||||
expect(res.body.jsonrpc).toBe('2.0');
|
||||
expect(res.body.result).toBeDefined();
|
||||
expect(res.body.result.protocolVersion).toBe('2024-11-05');
|
||||
expect(res.body.result.protocolVersion).toBe(MCP_PROTOCOL_VERSION);
|
||||
expect(res.body.result.capabilities).toBeDefined();
|
||||
expect(res.body.result.serverInfo).toBeDefined();
|
||||
expect(res.body.result.serverInfo.name).toBe('Twenty MCP Server');
|
||||
|
||||
Reference in New Issue
Block a user