From ed2b2f8911616ae27f70b4dd3bc95b28c7843d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 6 Jul 2026 17:53:05 +0200 Subject: [PATCH] feat: publish MCP & API discovery documents (well-known standards) (#22589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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)_ Review in cubic --- .../controllers/oauth-discovery.controller.ts | 11 +-- .../engine/core-modules/core-engine.module.ts | 2 + .../__tests__/well-known.controller.spec.ts | 81 +++++++++++++++++++ .../controllers/well-known.controller.ts | 46 +++++++++++ .../__tests__/build-api-catalog.util.spec.ts | 61 ++++++++++++++ .../build-mcp-server-card.util.spec.ts | 47 +++++++++++ .../utils/build-api-catalog.util.ts | 45 +++++++++++ .../utils/build-mcp-server-card.util.ts | 40 +++++++++ .../well-known/well-known.module.ts | 10 +++ .../src/utils/get-request-base-url.util.ts | 5 ++ .../public/.well-known/mcp/server-card.json | 27 +++++++ packages/twenty-website/public/llms.txt | 22 +++++ .../src/app/.well-known/api-catalog/route.ts | 65 +++++++++++++++ .../twenty-website/src/platform/site-urls.ts | 4 + 14 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/controllers/__tests__/well-known.controller.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/controllers/well-known.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-api-catalog.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-mcp-server-card.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/utils/build-api-catalog.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/utils/build-mcp-server-card.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/well-known/well-known.module.ts create mode 100644 packages/twenty-server/src/utils/get-request-base-url.util.ts create mode 100644 packages/twenty-website/public/.well-known/mcp/server-card.json create mode 100644 packages/twenty-website/public/llms.txt create mode 100644 packages/twenty-website/src/app/.well-known/api-catalog/route.ts diff --git a/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts b/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts index 14f32398cf..1783fbaa0f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller.ts @@ -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'); diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 6429ba6f4b..b0bed69ba2 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/well-known/controllers/__tests__/well-known.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/well-known/controllers/__tests__/well-known.controller.spec.ts new file mode 100644 index 0000000000..679cd76088 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/controllers/__tests__/well-known.controller.spec.ts @@ -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', + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/well-known/controllers/well-known.controller.ts b/packages/twenty-server/src/engine/core-modules/well-known/controllers/well-known.controller.ts new file mode 100644 index 0000000000..ad940fec8c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/controllers/well-known.controller.ts @@ -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); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-api-catalog.util.spec.ts b/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-api-catalog.util.spec.ts new file mode 100644 index 0000000000..4ddb431400 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-api-catalog.util.spec.ts @@ -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\//, + ); + } + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-mcp-server-card.util.spec.ts b/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-mcp-server-card.util.spec.ts new file mode 100644 index 0000000000..68321e81d7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/utils/__tests__/build-mcp-server-card.util.spec.ts @@ -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, + }), + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/well-known/utils/build-api-catalog.util.ts b/packages/twenty-server/src/engine/core-modules/well-known/utils/build-api-catalog.util.ts new file mode 100644 index 0000000000..4725ce2d52 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/utils/build-api-catalog.util.ts @@ -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' }], + }, + ], +}); diff --git a/packages/twenty-server/src/engine/core-modules/well-known/utils/build-mcp-server-card.util.ts b/packages/twenty-server/src/engine/core-modules/well-known/utils/build-mcp-server-card.util.ts new file mode 100644 index 0000000000..fe9375f5da --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/utils/build-mcp-server-card.util.ts @@ -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 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, + }, + ], + }, + ], +}); diff --git a/packages/twenty-server/src/engine/core-modules/well-known/well-known.module.ts b/packages/twenty-server/src/engine/core-modules/well-known/well-known.module.ts new file mode 100644 index 0000000000..d0229e9998 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/well-known/well-known.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/utils/get-request-base-url.util.ts b/packages/twenty-server/src/utils/get-request-base-url.util.ts new file mode 100644 index 0000000000..fe033a1158 --- /dev/null +++ b/packages/twenty-server/src/utils/get-request-base-url.util.ts @@ -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')}`; diff --git a/packages/twenty-website/public/.well-known/mcp/server-card.json b/packages/twenty-website/public/.well-known/mcp/server-card.json new file mode 100644 index 0000000000..a6599d9534 --- /dev/null +++ b/packages/twenty-website/public/.well-known/mcp/server-card.json @@ -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 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 + } + ] + } + ] +} diff --git a/packages/twenty-website/public/llms.txt b/packages/twenty-website/public/llms.txt new file mode 100644 index 0000000000..7e16f651d9 --- /dev/null +++ b/packages/twenty-website/public/llms.txt @@ -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 ` 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) diff --git a/packages/twenty-website/src/app/.well-known/api-catalog/route.ts b/packages/twenty-website/src/app/.well-known/api-catalog/route.ts new file mode 100644 index 0000000000..5b63a83bf9 --- /dev/null +++ b/packages/twenty-website/src/app/.well-known/api-catalog/route.ts @@ -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', + }, + }); +} diff --git a/packages/twenty-website/src/platform/site-urls.ts b/packages/twenty-website/src/platform/site-urls.ts index 587f9551c5..eeba128d07 100644 --- a/packages/twenty-website/src/platform/site-urls.ts +++ b/packages/twenty-website/src/platform/site-urls.ts @@ -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',