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:
Félix Malfait
2026-04-16 17:32:45 +02:00
committed by GitHub
parent 4103efcb84
commit 4f4f723ed0
5 changed files with 107 additions and 8 deletions
@@ -1 +1 @@
export const MCP_PROTOCOL_VERSION = '2024-11-05';
export const MCP_PROTOCOL_VERSION = '2025-06-18';
@@ -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,
@@ -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',
]);
});
});
});
@@ -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;
}
}
@@ -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');