Skip to content

Commit ed2b2f8

Browse files
authored
feat: publish MCP & API discovery documents (well-known standards) (twentyhq#22589)
## What & why Makes Twenty's **MCP server** and **REST/GraphQL APIs** auto-discoverable by catalogs (e.g. integrations.sh) and AI agents, using vendor-neutral open standards rather than a proprietary manifest. The tricky part is that Twenty is **multi-tenant and the REST OpenAPI is generated per workspace** (it reflects each workspace's custom objects, and with no token even the base schema is empty). So there is no single public URL that describes the full API contract. This PR solves that with two complementary layers. ## 1. Static standards on `twenty.com` (`twenty-website`) The brand-level catalog entry, using `{your-workspace-url}` placeholders since `twenty.com` is not a workspace host: - `public/.well-known/mcp/server-card.json` — MCP Server Card (SEP-2127) - `src/app/.well-known/api-catalog/route.ts` — RFC 9727 linkset (route handler so the `application/linkset+json` content type survives the global `nosniff` header) - `public/llms.txt` — LLM-readable overview ## 2. Dynamic per-host serving from `twenty-server` A new `well-known` core module serves the same documents built from the **request host**, so every workspace subdomain, custom domain, and self-hosted instance advertises its own **real, connectable** endpoints (`https://{that-host}/mcp`, its live `/rest/open-api/core`, etc.) — no placeholder: - `GET /.well-known/mcp/server-card.json` - `GET /.well-known/api-catalog` Both are public + CORS + cached. The api-catalog's `service-desc` points at each host's **live** per-workspace OpenAPI — the honest answer to "it's generated per workspace" (real endpoint, real custom objects, still token-gated). The `version` comes from `APP_VERSION`. The two layers are complementary: the static one serves catalog/marketing discovery at the brand domain; the dynamic one serves connecting clients the real endpoints — which is where the MCP spec expects the server card to live (same origin as `/mcp`). ## Refactor Extracted the request→base-URL logic that `OAuthDiscoveryController` had as a private method into a shared `src/utils/get-request-base-url.util.ts`, now used by both it and the new controller. ## Notes - Docs URLs are sourced from the shared `DOCUMENTATION_BASE_URL` (server) and the `SITE_URLS` registry (website) rather than hardcoded. - MCP endpoint, transport (`streamable-http`), and protocol version (`2025-06-18`) are read from the existing MCP constants. - OAuth resource metadata (`/.well-known/oauth-protected-resource`) already existed and is unchanged. ## Testing - `twenty-server` unit tests for the builders and controller (host derivation, version fallback, linkset shape) — passing. - `nx typecheck twenty-server` — passing. - `oxlint` + `oxfmt` clean on both packages; website `check-conventions` OK. https://claude.ai/code/session_01F6g7kefcfpjXSZjH6cwqhi --- _Generated by [Claude Code](https://claude.ai/code/session_01F6g7kefcfpjXSZjH6cwqhi)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22589?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
1 parent 8580cd6 commit ed2b2f8

14 files changed

Lines changed: 459 additions & 7 deletions

File tree

packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
99
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
1010
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
1111
import { cleanServerUrl } from 'src/utils/clean-server-url';
12+
import { getRequestBaseUrl } from 'src/utils/get-request-base-url.util';
1213
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
1314

1415
@Controller('.well-known')
@@ -22,7 +23,7 @@ export class OAuthDiscoveryController {
2223
@Get('oauth-authorization-server')
2324
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
2425
async getAuthorizationServerMetadata(@Req() request: Request) {
25-
const issuer = this.getRequestBaseUrl(request);
26+
const issuer = getRequestBaseUrl(request);
2627
// /authorize is served by the frontend; SERVER_URL (API-only) has no such
2728
// route, so we route the client to the default frontend base URL in that
2829
// case. All other hosts (app.twenty.com, workspace subdomains, custom
@@ -73,15 +74,15 @@ export class OAuthDiscoveryController {
7374
@Get('oauth-protected-resource')
7475
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
7576
getProtectedResourceMetadataRoot(@Req() request: Request) {
76-
const base = this.getRequestBaseUrl(request);
77+
const base = getRequestBaseUrl(request);
7778

7879
return this.buildProtectedResourceMetadata(base, base);
7980
}
8081

8182
@Get('oauth-protected-resource/mcp')
8283
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
8384
getProtectedResourceMetadataMcp(@Req() request: Request) {
84-
const base = this.getRequestBaseUrl(request);
85+
const base = getRequestBaseUrl(request);
8586

8687
return this.buildProtectedResourceMetadata(base, `${base}/mcp`);
8788
}
@@ -95,10 +96,6 @@ export class OAuthDiscoveryController {
9596
};
9697
}
9798

98-
private getRequestBaseUrl(request: Request): string {
99-
return `${request.protocol}://${request.get('host')}`;
100-
}
101-
10299
private isApiHost(request: Request): boolean {
103100
const serverUrl = this.twentyConfigService.get('SERVER_URL');
104101

packages/twenty-server/src/engine/core-modules/core-engine.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-cl
5757
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
5858
import { SearchModule } from 'src/engine/core-modules/search/search.module';
5959
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
60+
import { WellKnownModule } from 'src/engine/core-modules/well-known/well-known.module';
6061
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
6162
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
6263
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -98,6 +99,7 @@ import { FileModule } from './file/file.module';
9899
FileModule,
99100
RowLevelPermissionModule,
100101
OpenApiModule,
102+
WellKnownModule,
101103
ApplicationRegistrationModule,
102104
ApplicationOAuthModule,
103105
ApplicationModule,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Test, type TestingModule } from '@nestjs/testing';
2+
3+
import { type Request } from 'express';
4+
5+
import { WellKnownController } from 'src/engine/core-modules/well-known/controllers/well-known.controller';
6+
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
7+
8+
describe('WellKnownController', () => {
9+
let controller: WellKnownController;
10+
const configGet = jest.fn();
11+
12+
const buildMockRequest = (host: string, protocol = 'https') =>
13+
({
14+
protocol,
15+
get: (header: string) =>
16+
header.toLowerCase() === 'host' ? host : undefined,
17+
}) as unknown as Request;
18+
19+
beforeEach(async () => {
20+
configGet.mockReset();
21+
22+
const module: TestingModule = await Test.createTestingModule({
23+
controllers: [WellKnownController],
24+
providers: [
25+
{
26+
provide: TwentyConfigService,
27+
useValue: { get: configGet },
28+
},
29+
],
30+
}).compile();
31+
32+
controller = module.get(WellKnownController);
33+
});
34+
35+
describe('getMcpServerCard', () => {
36+
it('builds the card for the request host', () => {
37+
configGet.mockReturnValue('1.2.3');
38+
39+
const card = controller.getMcpServerCard(
40+
buildMockRequest('workspace.twenty.com'),
41+
);
42+
43+
expect(card.remotes[0].url).toBe('https://workspace.twenty.com/mcp');
44+
expect(card.version).toBe('1.2.3');
45+
});
46+
47+
it('falls back to 0.0.0 when APP_VERSION is unset', () => {
48+
configGet.mockReturnValue(undefined);
49+
50+
const card = controller.getMcpServerCard(
51+
buildMockRequest('workspace.twenty.com'),
52+
);
53+
54+
expect(card.version).toBe('0.0.0');
55+
});
56+
});
57+
58+
describe('getApiCatalog', () => {
59+
it('returns a parseable linkset anchored on the request host', () => {
60+
const body = controller.getApiCatalog(
61+
buildMockRequest('workspace.twenty.com'),
62+
);
63+
64+
const parsed = JSON.parse(body);
65+
66+
expect(
67+
parsed.linkset.map((entry: { anchor: string }) => entry.anchor),
68+
).toContain('https://workspace.twenty.com/rest');
69+
});
70+
71+
it('respects the forwarded protocol', () => {
72+
const body = controller.getApiCatalog(
73+
buildMockRequest('localhost:3000', 'http'),
74+
);
75+
76+
expect(JSON.parse(body).linkset[0].anchor).toBe(
77+
'http://localhost:3000/rest',
78+
);
79+
});
80+
});
81+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Controller, Get, Header, Req, UseGuards } from '@nestjs/common';
2+
3+
import { type Request } from 'express';
4+
5+
import { buildApiCatalog } from 'src/engine/core-modules/well-known/utils/build-api-catalog.util';
6+
import { buildMcpServerCard } from 'src/engine/core-modules/well-known/utils/build-mcp-server-card.util';
7+
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
8+
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
9+
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
10+
import { getRequestBaseUrl } from 'src/utils/get-request-base-url.util';
11+
import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
12+
13+
const DISCOVERY_CACHE_CONTROL = 'public, max-age=3600';
14+
const FALLBACK_SERVER_VERSION = '0.0.0';
15+
16+
@Controller('.well-known')
17+
export class WellKnownController {
18+
constructor(private readonly twentyConfigService: TwentyConfigService) {}
19+
20+
@Get('mcp/server-card.json')
21+
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
22+
@Header('Cache-Control', DISCOVERY_CACHE_CONTROL)
23+
getMcpServerCard(@Req() request: Request) {
24+
const version =
25+
extractVersionMajorMinorPatch(
26+
this.twentyConfigService.get('APP_VERSION'),
27+
) ?? FALLBACK_SERVER_VERSION;
28+
29+
return buildMcpServerCard({
30+
baseUrl: getRequestBaseUrl(request),
31+
version,
32+
});
33+
}
34+
35+
// Return a string so Nest keeps the explicit linkset+json Content-Type.
36+
@Get('api-catalog')
37+
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
38+
@Header(
39+
'Content-Type',
40+
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
41+
)
42+
@Header('Cache-Control', DISCOVERY_CACHE_CONTROL)
43+
getApiCatalog(@Req() request: Request): string {
44+
return JSON.stringify(buildApiCatalog(getRequestBaseUrl(request)), null, 2);
45+
}
46+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { buildApiCatalog } from 'src/engine/core-modules/well-known/utils/build-api-catalog.util';
2+
3+
describe('buildApiCatalog', () => {
4+
const baseUrl = 'https://mycompany.twenty.com';
5+
6+
it('anchors each surface at its canonical URL on the given host', () => {
7+
const catalog = buildApiCatalog(baseUrl);
8+
9+
const anchors = catalog.linkset.map((entry) => entry.anchor);
10+
11+
expect(anchors).toEqual([
12+
`${baseUrl}/rest`,
13+
`${baseUrl}/rest/metadata`,
14+
`${baseUrl}/graphql`,
15+
`${baseUrl}/mcp`,
16+
]);
17+
});
18+
19+
it('points the REST core surface at its live per-host OpenAPI + OAuth metadata', () => {
20+
const catalog = buildApiCatalog(baseUrl);
21+
22+
const restCore = catalog.linkset.find(
23+
(entry) => entry.anchor === `${baseUrl}/rest`,
24+
);
25+
26+
expect(restCore?.['service-desc']).toEqual([
27+
{ href: `${baseUrl}/rest/open-api/core`, type: 'application/json' },
28+
]);
29+
expect(restCore?.['service-meta']).toEqual([
30+
{
31+
href: `${baseUrl}/.well-known/oauth-protected-resource`,
32+
type: 'application/json',
33+
},
34+
]);
35+
});
36+
37+
it('references the MCP server card for the MCP surface', () => {
38+
const catalog = buildApiCatalog(baseUrl);
39+
40+
const mcp = catalog.linkset.find(
41+
(entry) => entry.anchor === `${baseUrl}/mcp`,
42+
);
43+
44+
expect(mcp?.['service-desc']).toEqual([
45+
{
46+
href: `${baseUrl}/.well-known/mcp/server-card.json`,
47+
type: 'application/json',
48+
},
49+
]);
50+
});
51+
52+
it('gives every surface human documentation', () => {
53+
const catalog = buildApiCatalog(baseUrl);
54+
55+
for (const entry of catalog.linkset) {
56+
expect(entry['service-doc']?.[0]?.href).toMatch(
57+
/^https:\/\/docs\.twenty\.com\//,
58+
);
59+
}
60+
});
61+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
2+
import { buildMcpServerCard } from 'src/engine/core-modules/well-known/utils/build-mcp-server-card.util';
3+
4+
describe('buildMcpServerCard', () => {
5+
it('advertises the streamable-http endpoint on the given host', () => {
6+
const card = buildMcpServerCard({
7+
baseUrl: 'https://mycompany.twenty.com',
8+
version: '1.2.3',
9+
});
10+
11+
expect(card.remotes).toHaveLength(1);
12+
expect(card.remotes[0]).toMatchObject({
13+
type: 'streamable-http',
14+
url: 'https://mycompany.twenty.com/mcp',
15+
supportedProtocolVersions: [MCP_PROTOCOL_VERSION],
16+
});
17+
});
18+
19+
it('carries the registry schema, stable identity and passed version', () => {
20+
const card = buildMcpServerCard({
21+
baseUrl: 'https://api.twenty.com',
22+
version: '0.42.0',
23+
});
24+
25+
expect(card.$schema).toBe(
26+
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json',
27+
);
28+
expect(card.name).toBe('com.twenty/twenty');
29+
expect(card.version).toBe('0.42.0');
30+
expect(card.repository.source).toBe('github');
31+
});
32+
33+
it('marks the Authorization header optional and secret (OAuth or API key)', () => {
34+
const card = buildMcpServerCard({
35+
baseUrl: 'https://mycompany.twenty.com',
36+
version: '1.0.0',
37+
});
38+
39+
expect(card.remotes[0].headers).toEqual([
40+
expect.objectContaining({
41+
name: 'Authorization',
42+
isRequired: false,
43+
isSecret: true,
44+
}),
45+
]);
46+
});
47+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { DOCUMENTATION_BASE_URL } from 'twenty-shared/constants';
2+
3+
const API_DOCS_URL = `${DOCUMENTATION_BASE_URL}/developers/extend/api`;
4+
const MCP_DOCS_URL = `${DOCUMENTATION_BASE_URL}/user-guide/ai/capabilities/mcp`;
5+
6+
// service-desc points at each host's live OpenAPI, which is generated per
7+
// workspace and so includes that workspace's custom objects.
8+
export const buildApiCatalog = (baseUrl: string) => ({
9+
linkset: [
10+
{
11+
anchor: `${baseUrl}/rest`,
12+
'service-desc': [
13+
{ href: `${baseUrl}/rest/open-api/core`, type: 'application/json' },
14+
],
15+
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
16+
'service-meta': [
17+
{
18+
href: `${baseUrl}/.well-known/oauth-protected-resource`,
19+
type: 'application/json',
20+
},
21+
],
22+
},
23+
{
24+
anchor: `${baseUrl}/rest/metadata`,
25+
'service-desc': [
26+
{ href: `${baseUrl}/rest/open-api/metadata`, type: 'application/json' },
27+
],
28+
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
29+
},
30+
{
31+
anchor: `${baseUrl}/graphql`,
32+
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
33+
},
34+
{
35+
anchor: `${baseUrl}/mcp`,
36+
'service-desc': [
37+
{
38+
href: `${baseUrl}/.well-known/mcp/server-card.json`,
39+
type: 'application/json',
40+
},
41+
],
42+
'service-doc': [{ href: MCP_DOCS_URL, type: 'text/html' }],
43+
},
44+
],
45+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
2+
3+
type BuildMcpServerCardArgs = {
4+
baseUrl: string;
5+
version: string;
6+
};
7+
8+
export const buildMcpServerCard = ({
9+
baseUrl,
10+
version,
11+
}: BuildMcpServerCardArgs) => ({
12+
$schema:
13+
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json',
14+
name: 'com.twenty/twenty',
15+
version,
16+
title: 'Twenty CRM',
17+
description:
18+
'Read and write your Twenty CRM data - companies, people, opportunities, tasks, notes and any custom objects - from AI assistants. Tools are discovered at runtime and scoped to the authenticated workspace.',
19+
websiteUrl: 'https://twenty.com',
20+
repository: {
21+
url: 'https://github.com/twentyhq/twenty',
22+
source: 'github',
23+
},
24+
remotes: [
25+
{
26+
type: 'streamable-http',
27+
url: `${baseUrl}/mcp`,
28+
supportedProtocolVersions: [MCP_PROTOCOL_VERSION],
29+
headers: [
30+
{
31+
name: 'Authorization',
32+
description:
33+
"Optional. Bearer <api-key> for static API-key auth. Omit to use OAuth 2.1, auto-discovered from this host's /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.",
34+
isRequired: false,
35+
isSecret: true,
36+
},
37+
],
38+
},
39+
],
40+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { WellKnownController } from 'src/engine/core-modules/well-known/controllers/well-known.controller';
4+
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
5+
6+
@Module({
7+
imports: [TwentyConfigModule],
8+
controllers: [WellKnownController],
9+
})
10+
export class WellKnownModule {}

0 commit comments

Comments
 (0)