feat: publish MCP & API discovery documents (well-known standards) (#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. -->
This commit is contained in:
Félix Malfait
2026-07-06 17:53:05 +02:00
committed by GitHub
parent 8580cd6f27
commit ed2b2f8911
14 changed files with 459 additions and 7 deletions
@@ -9,6 +9,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
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 { getRequestBaseUrl } from 'src/utils/get-request-base-url.util';
import { TWENTY_CLI_APPLICATION_REGISTRATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-cli-application-registration.constant';
@Controller('.well-known')
@@ -22,7 +23,7 @@ export class OAuthDiscoveryController {
@Get('oauth-authorization-server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async getAuthorizationServerMetadata(@Req() request: Request) {
const issuer = this.getRequestBaseUrl(request);
const issuer = 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
@@ -73,7 +74,7 @@ export class OAuthDiscoveryController {
@Get('oauth-protected-resource')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getProtectedResourceMetadataRoot(@Req() request: Request) {
const base = this.getRequestBaseUrl(request);
const base = getRequestBaseUrl(request);
return this.buildProtectedResourceMetadata(base, base);
}
@@ -81,7 +82,7 @@ export class OAuthDiscoveryController {
@Get('oauth-protected-resource/mcp')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
getProtectedResourceMetadataMcp(@Req() request: Request) {
const base = this.getRequestBaseUrl(request);
const base = getRequestBaseUrl(request);
return this.buildProtectedResourceMetadata(base, `${base}/mcp`);
}
@@ -95,10 +96,6 @@ 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');
@@ -57,6 +57,7 @@ import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-cl
import { RedisClientService } from 'src/engine/core-modules/redis-client/redis-client.service';
import { SearchModule } from 'src/engine/core-modules/search/search.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { WellKnownModule } from 'src/engine/core-modules/well-known/well-known.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -98,6 +99,7 @@ import { FileModule } from './file/file.module';
FileModule,
RowLevelPermissionModule,
OpenApiModule,
WellKnownModule,
ApplicationRegistrationModule,
ApplicationOAuthModule,
ApplicationModule,
@@ -0,0 +1,81 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type Request } from 'express';
import { WellKnownController } from 'src/engine/core-modules/well-known/controllers/well-known.controller';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('WellKnownController', () => {
let controller: WellKnownController;
const configGet = jest.fn();
const buildMockRequest = (host: string, protocol = 'https') =>
({
protocol,
get: (header: string) =>
header.toLowerCase() === 'host' ? host : undefined,
}) as unknown as Request;
beforeEach(async () => {
configGet.mockReset();
const module: TestingModule = await Test.createTestingModule({
controllers: [WellKnownController],
providers: [
{
provide: TwentyConfigService,
useValue: { get: configGet },
},
],
}).compile();
controller = module.get(WellKnownController);
});
describe('getMcpServerCard', () => {
it('builds the card for the request host', () => {
configGet.mockReturnValue('1.2.3');
const card = controller.getMcpServerCard(
buildMockRequest('workspace.twenty.com'),
);
expect(card.remotes[0].url).toBe('https://workspace.twenty.com/mcp');
expect(card.version).toBe('1.2.3');
});
it('falls back to 0.0.0 when APP_VERSION is unset', () => {
configGet.mockReturnValue(undefined);
const card = controller.getMcpServerCard(
buildMockRequest('workspace.twenty.com'),
);
expect(card.version).toBe('0.0.0');
});
});
describe('getApiCatalog', () => {
it('returns a parseable linkset anchored on the request host', () => {
const body = controller.getApiCatalog(
buildMockRequest('workspace.twenty.com'),
);
const parsed = JSON.parse(body);
expect(
parsed.linkset.map((entry: { anchor: string }) => entry.anchor),
).toContain('https://workspace.twenty.com/rest');
});
it('respects the forwarded protocol', () => {
const body = controller.getApiCatalog(
buildMockRequest('localhost:3000', 'http'),
);
expect(JSON.parse(body).linkset[0].anchor).toBe(
'http://localhost:3000/rest',
);
});
});
});
@@ -0,0 +1,46 @@
import { Controller, Get, Header, Req, UseGuards } from '@nestjs/common';
import { type Request } from 'express';
import { buildApiCatalog } from 'src/engine/core-modules/well-known/utils/build-api-catalog.util';
import { buildMcpServerCard } from 'src/engine/core-modules/well-known/utils/build-mcp-server-card.util';
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 { getRequestBaseUrl } from 'src/utils/get-request-base-url.util';
import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
const DISCOVERY_CACHE_CONTROL = 'public, max-age=3600';
const FALLBACK_SERVER_VERSION = '0.0.0';
@Controller('.well-known')
export class WellKnownController {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@Get('mcp/server-card.json')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@Header('Cache-Control', DISCOVERY_CACHE_CONTROL)
getMcpServerCard(@Req() request: Request) {
const version =
extractVersionMajorMinorPatch(
this.twentyConfigService.get('APP_VERSION'),
) ?? FALLBACK_SERVER_VERSION;
return buildMcpServerCard({
baseUrl: getRequestBaseUrl(request),
version,
});
}
// Return a string so Nest keeps the explicit linkset+json Content-Type.
@Get('api-catalog')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@Header(
'Content-Type',
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
)
@Header('Cache-Control', DISCOVERY_CACHE_CONTROL)
getApiCatalog(@Req() request: Request): string {
return JSON.stringify(buildApiCatalog(getRequestBaseUrl(request)), null, 2);
}
}
@@ -0,0 +1,61 @@
import { buildApiCatalog } from 'src/engine/core-modules/well-known/utils/build-api-catalog.util';
describe('buildApiCatalog', () => {
const baseUrl = 'https://mycompany.twenty.com';
it('anchors each surface at its canonical URL on the given host', () => {
const catalog = buildApiCatalog(baseUrl);
const anchors = catalog.linkset.map((entry) => entry.anchor);
expect(anchors).toEqual([
`${baseUrl}/rest`,
`${baseUrl}/rest/metadata`,
`${baseUrl}/graphql`,
`${baseUrl}/mcp`,
]);
});
it('points the REST core surface at its live per-host OpenAPI + OAuth metadata', () => {
const catalog = buildApiCatalog(baseUrl);
const restCore = catalog.linkset.find(
(entry) => entry.anchor === `${baseUrl}/rest`,
);
expect(restCore?.['service-desc']).toEqual([
{ href: `${baseUrl}/rest/open-api/core`, type: 'application/json' },
]);
expect(restCore?.['service-meta']).toEqual([
{
href: `${baseUrl}/.well-known/oauth-protected-resource`,
type: 'application/json',
},
]);
});
it('references the MCP server card for the MCP surface', () => {
const catalog = buildApiCatalog(baseUrl);
const mcp = catalog.linkset.find(
(entry) => entry.anchor === `${baseUrl}/mcp`,
);
expect(mcp?.['service-desc']).toEqual([
{
href: `${baseUrl}/.well-known/mcp/server-card.json`,
type: 'application/json',
},
]);
});
it('gives every surface human documentation', () => {
const catalog = buildApiCatalog(baseUrl);
for (const entry of catalog.linkset) {
expect(entry['service-doc']?.[0]?.href).toMatch(
/^https:\/\/docs\.twenty\.com\//,
);
}
});
});
@@ -0,0 +1,47 @@
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
import { buildMcpServerCard } from 'src/engine/core-modules/well-known/utils/build-mcp-server-card.util';
describe('buildMcpServerCard', () => {
it('advertises the streamable-http endpoint on the given host', () => {
const card = buildMcpServerCard({
baseUrl: 'https://mycompany.twenty.com',
version: '1.2.3',
});
expect(card.remotes).toHaveLength(1);
expect(card.remotes[0]).toMatchObject({
type: 'streamable-http',
url: 'https://mycompany.twenty.com/mcp',
supportedProtocolVersions: [MCP_PROTOCOL_VERSION],
});
});
it('carries the registry schema, stable identity and passed version', () => {
const card = buildMcpServerCard({
baseUrl: 'https://api.twenty.com',
version: '0.42.0',
});
expect(card.$schema).toBe(
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json',
);
expect(card.name).toBe('com.twenty/twenty');
expect(card.version).toBe('0.42.0');
expect(card.repository.source).toBe('github');
});
it('marks the Authorization header optional and secret (OAuth or API key)', () => {
const card = buildMcpServerCard({
baseUrl: 'https://mycompany.twenty.com',
version: '1.0.0',
});
expect(card.remotes[0].headers).toEqual([
expect.objectContaining({
name: 'Authorization',
isRequired: false,
isSecret: true,
}),
]);
});
});
@@ -0,0 +1,45 @@
import { DOCUMENTATION_BASE_URL } from 'twenty-shared/constants';
const API_DOCS_URL = `${DOCUMENTATION_BASE_URL}/developers/extend/api`;
const MCP_DOCS_URL = `${DOCUMENTATION_BASE_URL}/user-guide/ai/capabilities/mcp`;
// service-desc points at each host's live OpenAPI, which is generated per
// workspace and so includes that workspace's custom objects.
export const buildApiCatalog = (baseUrl: string) => ({
linkset: [
{
anchor: `${baseUrl}/rest`,
'service-desc': [
{ href: `${baseUrl}/rest/open-api/core`, type: 'application/json' },
],
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
'service-meta': [
{
href: `${baseUrl}/.well-known/oauth-protected-resource`,
type: 'application/json',
},
],
},
{
anchor: `${baseUrl}/rest/metadata`,
'service-desc': [
{ href: `${baseUrl}/rest/open-api/metadata`, type: 'application/json' },
],
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
},
{
anchor: `${baseUrl}/graphql`,
'service-doc': [{ href: API_DOCS_URL, type: 'text/html' }],
},
{
anchor: `${baseUrl}/mcp`,
'service-desc': [
{
href: `${baseUrl}/.well-known/mcp/server-card.json`,
type: 'application/json',
},
],
'service-doc': [{ href: MCP_DOCS_URL, type: 'text/html' }],
},
],
});
@@ -0,0 +1,40 @@
import { MCP_PROTOCOL_VERSION } from 'src/engine/api/mcp/constants/mcp-protocol-version.const';
type BuildMcpServerCardArgs = {
baseUrl: string;
version: string;
};
export const buildMcpServerCard = ({
baseUrl,
version,
}: BuildMcpServerCardArgs) => ({
$schema:
'https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json',
name: 'com.twenty/twenty',
version,
title: 'Twenty CRM',
description:
'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.',
websiteUrl: 'https://twenty.com',
repository: {
url: 'https://github.com/twentyhq/twenty',
source: 'github',
},
remotes: [
{
type: 'streamable-http',
url: `${baseUrl}/mcp`,
supportedProtocolVersions: [MCP_PROTOCOL_VERSION],
headers: [
{
name: 'Authorization',
description:
"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.",
isRequired: false,
isSecret: true,
},
],
},
],
});
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WellKnownController } from 'src/engine/core-modules/well-known/controllers/well-known.controller';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Module({
imports: [TwentyConfigModule],
controllers: [WellKnownController],
})
export class WellKnownModule {}
@@ -0,0 +1,5 @@
import { type Request } from 'express';
// Absolute origin the request arrived on (honors Express `trust proxy`).
export const getRequestBaseUrl = (request: Request): string =>
`${request.protocol}://${request.get('host')}`;
@@ -0,0 +1,27 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json",
"name": "com.twenty/twenty",
"version": "0.2.1",
"title": "Twenty CRM",
"description": "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.",
"websiteUrl": "https://twenty.com",
"repository": {
"url": "https://github.com/twentyhq/twenty",
"source": "github"
},
"remotes": [
{
"type": "streamable-http",
"url": "https://{your-workspace-url}/mcp",
"supportedProtocolVersions": ["2025-06-18"],
"headers": [
{
"name": "Authorization",
"description": "Optional. Bearer <api-key> for static API-key auth. Omit to use OAuth 2.1, which is auto-discovered from the workspace host's /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server.",
"isRequired": false,
"isSecret": true
}
]
}
]
}
+22
View File
@@ -0,0 +1,22 @@
# Twenty
> Twenty is an open-source CRM. AI assistants and agents can read and write CRM data through the Model Context Protocol (MCP) server or the REST/GraphQL API.
Every endpoint is workspace-scoped: replace `{your-workspace-url}` with your workspace host (e.g. `mycompany.twenty.com` or a custom domain). The MCP server is at `https://{your-workspace-url}/mcp` (transport: streamable-http; auth: OAuth 2.1 or an API-key Bearer token). The REST base is `https://{your-workspace-url}/rest`, with per-workspace OpenAPI at `/rest/open-api/core` and `/rest/open-api/metadata` (send an `Authorization: Bearer <token>` header). GraphQL is at `/graphql` and `/metadata`.
Twenty is multi-tenant, so there is no single global API host: each workspace has its own base URL, and the REST OpenAPI is generated per workspace, so it includes any custom objects and fields you have created. Prefer the MCP server for agents — it exposes typed tools with header-based auth and discovers your workspace's tools at runtime, so it does not depend on the per-workspace OpenAPI document.
## Connect an AI assistant
- [MCP server guide](https://docs.twenty.com/user-guide/ai/capabilities/mcp): connect Claude, Cursor, ChatGPT and others.
- [MCP server card](https://twenty.com/.well-known/mcp/server-card.json): machine-readable MCP metadata.
## APIs
- [API guide](https://docs.twenty.com/developers/extend/api): REST and GraphQL, generated from your workspace schema.
- [API catalog](https://twenty.com/.well-known/api-catalog): RFC 9727 index of the API surfaces.
## Docs
- [Developer documentation](https://docs.twenty.com/developers/introduction)
- [Source code](https://github.com/twentyhq/twenty)
@@ -0,0 +1,65 @@
import { SITE_URLS } from '@/platform/site-urls';
// Route handler (not a public/ file) so the RFC 9727 application/linkset+json
// content type survives the site's global nosniff header.
//
// Twenty is multi-tenant, so anchors use a `{your-workspace-url}` placeholder
// (a workspace host such as `mycompany.twenty.com` or a custom domain).
const WORKSPACE = 'https://{your-workspace-url}';
const apiCatalog = {
linkset: [
{
anchor: `${WORKSPACE}/rest`,
'service-desc': [
{ href: `${WORKSPACE}/rest/open-api/core`, type: 'application/json' },
],
'service-doc': [{ href: SITE_URLS.docsApi, type: 'text/html' }],
'service-meta': [
{
href: `${WORKSPACE}/.well-known/oauth-protected-resource`,
type: 'application/json',
},
],
},
{
anchor: `${WORKSPACE}/rest/metadata`,
'service-desc': [
{
href: `${WORKSPACE}/rest/open-api/metadata`,
type: 'application/json',
},
],
'service-doc': [{ href: SITE_URLS.docsApi, type: 'text/html' }],
},
{
anchor: `${WORKSPACE}/graphql`,
'service-doc': [{ href: SITE_URLS.docsApi, type: 'text/html' }],
},
{
anchor: `${WORKSPACE}/mcp`,
'service-desc': [
{
href: 'https://twenty.com/.well-known/mcp/server-card.json',
type: 'application/json',
},
],
'service-doc': [{ href: SITE_URLS.docsMcp, type: 'text/html' }],
},
],
};
export const dynamic = 'force-static';
export async function GET() {
return new Response(JSON.stringify(apiCatalog, null, 2), {
headers: {
'Content-Type':
'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
@@ -4,8 +4,10 @@ export const SITE_URLS: Record<
| 'appWelcome'
| 'calBooking'
| 'discord'
| 'docsApi'
| 'docsDevelopers'
| 'docsGettingStarted'
| 'docsMcp'
| 'docsUserGuide'
| 'github'
| 'linkedin'
@@ -16,8 +18,10 @@ export const SITE_URLS: Record<
appWelcome: 'https://app.twenty.com/welcome',
calBooking: 'https://cal.com/forms/f7841033-0a20-4958-8c92-4e34ec128a81',
discord: 'https://discord.gg/cx5n4Jzs57',
docsApi: 'https://docs.twenty.com/developers/extend/api',
docsDevelopers: 'https://docs.twenty.com/developers/introduction',
docsGettingStarted: 'https://docs.twenty.com/getting-started/introduction',
docsMcp: 'https://docs.twenty.com/user-guide/ai/capabilities/mcp',
docsUserGuide: 'https://docs.twenty.com/user-guide/introduction',
github: 'https://github.com/twentyhq/twenty',
linkedin: 'https://www.linkedin.com/company/twenty',