Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mcp-twenty-crm

mcp-twenty-crm is a self-hosted Model Context Protocol (MCP) server for Twenty, the open-source CRM. It lets MCP clients such as claude.ai, Claude Desktop and Claude Code search, read, create and update people, companies, opportunities, notes, tasks and custom objects in a Twenty workspace, on Twenty Cloud or self-hosted.

The server runs one service, crm. It has its own MCP endpoint, and a combined endpoint offers it with prefixed tool names:

EndpointServiceWhat it does
/crmCRM17 tools and 2 prompts: generic record tools that work on every object (including custom ones), the data model, and simple create tools for the standard CRM objects
/mcpCombinedEvery enabled service’s tools and prompts in one connector, named <service>_<tool>, for example crm_search_records

Highlights

  • Each person signs in with their own Twenty API key, so one server serves many people and every call runs with that key’s role. The server holds no key of its own and stores none.
  • A single static Rust binary, also shipped as a FROM scratch container image (ghcr.io/trozz/mcp-twenty-crm).
  • TLS end to end: the server terminates TLS itself, and talks to Twenty over https only (plain http only to a loopback address).
  • A built-in OAuth 2.1 authorization server, so each endpoint works as a claude.ai custom connector. No database and no volume are needed.
  • TWENTY_READ_ONLY=crm hides and refuses every tool that changes data.
  • Every write is recorded in an audit log: tool, outcome and caller, never arguments or results.

How it talks to Twenty

The server uses Twenty’s REST API at <TWENTY_API_URL>/rest, sending each person’s own API key as the bearer token:

PurposePath
Records of any object/rest/<object>, /rest/<object>/<id>, /rest/batch/<object>
The data model/rest/metadata/objects
Note and task links/rest/noteTargets, /rest/taskTargets

TWENTY_API_URL defaults to Twenty Cloud (https://api.twenty.com). For a self-hosted workspace set it to your Twenty server’s address.

What it needs from Twenty

  • A recent Twenty release: note and task links use the targetPerson, targetCompany and targetOpportunity fields, and bodies use bodyV2 Markdown.
  • An API key per person, whose role decides what that person can read and change. Listing and describing objects needs a role that may read the data model.
  • Twenty allows about 100 API requests per minute per workspace. When the limit is hit, tools say how long to wait.

Privacy

API keys are never stored on the server: a key is checked once at sign-in, then sealed inside that person’s tokens. Logs record tool names, outcomes, durations and a short keyed hash of who called, never record content or keys, unless you explicitly turn on unsafe content logging while debugging. See Security.

Built on mcp-server-core, the same base as mcp-icloud.

Next: Getting started.

Getting started

This page takes you from nothing to a running server. You need a Twenty workspace (Twenty Cloud or self-hosted), and a machine that can run Docker or a single binary.

1. Create a Twenty API key

Everyone who uses the server brings their own key. Each person:

  1. Opens their Twenty workspace and goes to Settings, then APIs & Webhooks.
  2. Chooses Create API key and copies the key when it is shown. Twenty does not show it again.

The key’s role decides what can be read and changed through the server. The server itself needs no key: it only checks each person’s key when they sign in.

2. Choose a secret for the server

The server needs one secret of its own:

  • OAUTH_SIGNING_KEY: at least 32 bytes, used to sign OAuth tokens and to seal each person’s API key inside them. Generate one with openssl rand -hex 32 and keep it stable: changing it signs everyone out.

Store it in a secret manager or an environment file readable only by you. Every variable can also be read from a file: append _FILE to the variable name, for example OAUTH_SIGNING_KEY_FILE=/run/secrets/oauth_signing_key.

There is no admin password and no static token: MCP_AUTH_TOKEN cannot be used, because everyone signs in with their own key.

3. Run it

With Docker

docker run -d --name mcp-twenty-crm -p 8443:8443 \
  -e PUBLIC_URL=https://crm-mcp.example.com \
  -e OAUTH_SIGNING_KEY="$(openssl rand -hex 32)" \
  -e TWENTY_API_URL=https://crm.example.com \
  -v /path/to/certs:/certs:ro \
  -e TLS_CERT_PATH=/certs/fullchain.pem \
  -e TLS_KEY_PATH=/certs/privkey.pem \
  ghcr.io/trozz/mcp-twenty-crm:latest

PUBLIC_URL is the address clients use to reach the server. TWENTY_API_URL is your Twenty server’s address, without /rest; leave it out for Twenty Cloud. See TLS and public access for how to make the server reachable with a trusted certificate.

With Docker Compose and a Cloudflare tunnel

A tunnel gives the server a public HTTPS address with a trusted certificate, without opening a port on your network. Create a tunnel in the Cloudflare dashboard, add a public hostname such as crm-mcp.example.com pointing to https://mcp-twenty-crm:8443 with No TLS Verify turned on (the server’s own certificate is self-signed in this setup), and copy the tunnel token.

compose.yaml:

services:
  mcp-twenty-crm:
    image: ghcr.io/trozz/mcp-twenty-crm:latest
    container_name: mcp-twenty-crm
    restart: unless-stopped
    env_file: app.env
    ports:
      - "127.0.0.1:8443:8443"
    volumes:
      - ./audit:/audit
  tunnel:
    image: cloudflare/cloudflared:latest
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}
    depends_on:
      - mcp-twenty-crm

app.env (readable only by you, for example chmod 600 app.env):

PUBLIC_URL=https://crm-mcp.example.com
OAUTH_SIGNING_KEY=replace-with-the-output-of-openssl-rand-hex-32
TWENTY_API_URL=https://crm.example.com
TWENTY_DEFAULT_CURRENCY=GBP
AUDIT_LOG_PATH=/audit/audit.jsonl

The server runs as UID 65532, so the audit directory must be writable by it: mkdir -p audit && sudo chown 65532:65532 audit. Then start both containers with TUNNEL_TOKEN=<token> docker compose up -d.

With the binary

Download the archive for your platform from Releases (Linux x86_64 and arm64, macOS arm64), verify the checksum, extract it and run:

shasum -a 256 -c mcp-twenty-crm-<version>-<target>.tar.gz.sha256
tar -xzf mcp-twenty-crm-<version>-<target>.tar.gz
cd mcp-twenty-crm-<version>-<target>
PUBLIC_URL=https://crm-mcp.example.com OAUTH_SIGNING_KEY=... \
  TWENTY_API_URL=https://crm.example.com \
  ./mcp-twenty-crm

All configuration comes from environment variables; the configuration reference lists every one.

Locally, without TLS

For a quick test from a checkout of the repository:

PUBLIC_URL=http://localhost:8443 INSECURE_HTTP=true \
  OAUTH_SIGNING_KEY=$(openssl rand -hex 32) LOG_FORMAT=pretty \
  cargo run -p mcp-twenty-crm

4. Check it is running

curl -k https://127.0.0.1:8443/healthz

prints ok. The startup log has a twenty settings line with the Twenty origin and default currency, a service enabled line for crm with its URL and tools, and a combined endpoint enabled line for /mcp.

mcp-twenty-crm --version prints the version, and mcp-twenty-crm --healthcheck exits 0 when the local server answers /healthz (the container image uses it as its HEALTHCHECK).

5. Choose what runs

  • TWENTY_READ_ONLY=crm (or all) hides and refuses every tool that changes data.
  • TWENTY_COMBINED=false turns off the combined /mcp endpoint, leaving only /crm.
  • TWENTY_DEFAULT_CURRENCY=GBP sets the currency of opportunity amounts given without one (default USD).

Next: Connecting clients.

Connecting clients

The CRM service is an MCP server at /crm, and /mcp offers it (and any future service) with prefixed tool names. Add one connector, for either endpoint.

EndpointConnector URLTool names
CRMhttps://crm-mcp.example.com/crmsearch_records, create_note, …
All serviceshttps://crm-mcp.example.com/mcpcrm_search_records, crm_create_note, …

Use one of them, not both: with both, every tool appears twice.

claude.ai, Claude Desktop and Claude mobile

These connect from Anthropic’s servers, so the server must be reachable from the internet with a publicly trusted certificate (see TLS and public access).

  1. In claude.ai open Settings, then Connectors, then Add custom connector.
  2. Enter the connector URL, for example https://crm-mcp.example.com/mcp.
  3. A sign-in page from your server opens and asks for a Twenty API key. Paste your own key (see Getting started).

The server checks the key with one request to Twenty, then seals it inside the connector’s tokens. Access tokens last one hour and refresh tokens 30 days; the client refreshes its access token itself. Every grant ends 90 days after sign-in, however often it is refreshed, and you sign in again. Connectors added in claude.ai are also available in Claude Desktop and the Claude mobile apps.

Each person in your team adds their own connector and signs in with their own key, so every call runs with that person’s role in Twenty.

Claude Code

Claude Code can send a header, so it can skip OAuth and send your Twenty API key directly as the bearer token:

claude mcp add --transport http twenty https://crm-mcp.example.com/mcp \
  --header "Authorization: Bearer $TWENTY_API_KEY"

or for the CRM endpoint alone:

claude mcp add --transport http twenty-crm https://crm-mcp.example.com/crm \
  --header "Authorization: Bearer $TWENTY_API_KEY"

The server checks a key sent this way with Twenty too, and remembers the verdict (ten minutes for a valid key, one minute for a rejected one) so it does not check on every request. Claude Code also works against a local self-signed certificate if you trust it (see TLS and public access).

Other MCP clients

Any client that speaks the MCP Streamable HTTP transport works. It either completes the OAuth flow (dynamic client registration, authorization code with PKCE) or sends Authorization: Bearer <Twenty API key>. A request without a token gets a 401 whose WWW-Authenticate header points to /.well-known/oauth-protected-resource/<endpoint>, where discovery starts.

Icons

Each endpoint sends its own icon in serverInfo, and /favicon.ico serves the project icon. Some clients, including claude.ai at the time of writing, show a generic icon for custom connectors regardless.

Configuration reference

Everything is configured with environment variables. The server checks all of them at startup and refuses to start with a list of every problem it found, so a typo never leaves it half configured.

General rules:

  • An empty value counts as unset.
  • Every variable can also be read from a file by appending _FILE to its name, for example OAUTH_SIGNING_KEY_FILE=/run/secrets/oauth_signing_key. A value set directly wins over the file, and a trailing newline in the file is ignored. Use this for secrets with Docker or Kubernetes secrets.
  • Booleans accept true, false, 1, 0, yes, no, on and off, in any case.
  • Lists are comma separated; spaces around names are ignored.

Server and HTTP

VariableRequiredDefaultExampleDescription
PUBLIC_URLyeshttps://crm-mcp.example.comExternal base URL clients use, without a path. Must be https; http is accepted only for localhost
BIND_ADDRno0.0.0.0:8443127.0.0.1:8443Listen address. It must listen on loopback (0.0.0.0 does) so --healthcheck can reach 127.0.0.1
TLS_CERT_PATH, TLS_KEY_PATHno/certs/fullchain.pem, /certs/privkey.pemPEM certificate chain and private key. Both or neither; reloaded automatically when the files change. See TLS and public access
INSECURE_HTTPnofalsetrueServe plain HTTP. Only when TLS is terminated in front of the server on a network you trust
ALLOWED_ORIGINSnohttps://app.example.comExtra Origin values accepted on MCP requests, comma separated. The PUBLIC_URL origin is always allowed
TRUSTED_PROXIESno10.0.0.2,10.0.0.3Proxy IP addresses whose X-Forwarded-For is trusted for sign-in rate limiting

Services

VariableRequiredDefaultExampleDescription
TWENTY_SERVICESnoall (crm)crmServices to run. There is one, crm. An unknown name or an empty list stops startup
TWENTY_READ_ONLYnononecrm or allServices whose write tools are hidden and refused, on their own endpoint and under /mcp. An unknown name stops startup. See Security
TWENTY_COMBINEDnotruefalseServe the combined endpoint /mcp. With false, /mcp returns 404

OAuth

VariableRequiredDefaultExampleDescription
OAUTH_SIGNING_KEYyesoutput of openssl rand -hex 32Key that signs OAuth tokens and seals each person’s Twenty API key inside them, at least 32 bytes. Changing it signs everyone out

People sign in with their own Twenty API key, so there is no OAUTH_ADMIN_PASSWORD, and MCP_AUTH_TOKEN must not be set: the server refuses to start with it.

Twenty

VariableRequiredDefaultExampleDescription
TWENTY_API_URLnohttps://api.twenty.comhttps://crm.example.comYour Twenty server’s address, without /rest. Must be https, or http to a loopback address only; no credentials, query or fragment. Leave it unset for Twenty Cloud. Changing it signs everyone out
TWENTY_DEFAULT_CURRENCYnoUSDGBPThree-letter ISO 4217 code for opportunity amounts given without one. Upper-cased; anything else stops startup

Logging, metrics and audit

VariableRequiredDefaultExampleDescription
LOG_LEVELnoinfoinfo,mcp_twenty_crm_records=debugLog filter in tracing syntax. Targets: mcp_twenty_crm, mcp_twenty_crm_records, mcp_server_core. The audit target always stays on at info
LOG_FORMATnojsonprettyjson or pretty
DEBUGnofalsetrueDebug logging for mcp_twenty_crm* and mcp_server_core: the arguments of read tools (search words, filters), Twenty request paths with record ids, status codes, timings and tool error details. Never API keys or response bodies
DEBUG_UNSAFE_LOG_CONTENTnofalsetrueAlso logs full tool results and Twenty response bodies, which puts your CRM records in the logs. Never in production. API keys are never logged
METRICS_ADDRno0.0.0.0:9090Starts a separate plain-HTTP Prometheus listener. Keep it on an internal network
OTEL_EXPORTER_OTLP_ENDPOINTnohttps://otel.example.comExports traces over OTLP/HTTP. The standard OTEL_EXPORTER_OTLP_HEADERS is honoured. Nothing is exported unless it is set
AUDIT_LOG_PATHno/audit/audit.jsonlFile every write tool call is appended to as one JSON line (created with mode 0600). Audit lines are always logged too. See Security

A complete example

PUBLIC_URL=https://crm-mcp.example.com
OAUTH_SIGNING_KEY_FILE=/run/secrets/oauth_signing_key
TWENTY_API_URL=https://crm.example.com
TWENTY_DEFAULT_CURRENCY=GBP
TLS_CERT_PATH=/certs/fullchain.pem
TLS_KEY_PATH=/certs/privkey.pem
AUDIT_LOG_PATH=/audit/audit.jsonl
LOG_FORMAT=json

TLS and public access

claude.ai connects to your server from Anthropic’s infrastructure, so for claude.ai the server must be reachable from the internet over HTTPS with a publicly trusted certificate. Claude Code and other local clients can also use a certificate you trust yourself.

How the server picks its certificate

At startup, in this order:

  1. INSECURE_HTTP=true: plain HTTP, no TLS. Use only when TLS is terminated in front of the server on a network you trust.
  2. TLS_CERT_PATH and TLS_KEY_PATH both set: the server loads them, and reloads them automatically when the files change (for example after a certificate renewal). If either file is missing or invalid the server refuses to start; it never falls back silently. Setting only one of them is an error. In this mode responses also carry Strict-Transport-Security.
  3. Neither set: the server generates an in-memory self-signed certificate for the PUBLIC_URL host, localhost, 127.0.0.1 and ::1. It is never written to disk, changes on every restart, and its SHA-256 fingerprint is logged.

The self-signed certificate is fine for local use, for Claude Code with the certificate trusted, or for encrypting the hop from a tunnel or reverse proxy that skips verification. It does not work for claude.ai directly.

Option A: a Cloudflare tunnel

The simplest way to get a trusted public address. The tunnel’s edge certificate is trusted by claude.ai, and the server’s self-signed certificate protects the hop from the tunnel to the server. See the Docker Compose example in Getting started. In the tunnel’s public hostname settings, point the service to https://mcp-twenty-crm:8443 and turn on No TLS Verify.

Set PUBLIC_URL to the tunnel hostname, for example https://crm-mcp.example.com.

Option B: a reverse proxy

Run the server behind nginx, Caddy or Traefik with a trusted certificate at the proxy:

  • Proxy to https://<server>:8443 (verification off, or pinned to the logged fingerprint), or set INSECURE_HTTP=true if the proxy and server share a trusted network.
  • Pass the Host header through unchanged and do not buffer responses (MCP uses streaming HTTP responses).
  • Set TRUSTED_PROXIES to the proxy’s IP address so the server uses the client address from X-Forwarded-For for sign-in rate limiting.

The server does not limit request rates itself. If you add a limit at the proxy, limit per bearer token rather than per client address on /mcp, /crm, /token and /register: hosted clients such as claude.ai reach the server from a few shared addresses, so a per-address limit there lets one busy person block everyone. Per address is fine on /authorize, the only page a person’s browser opens.

Option C: the server holds the certificate

Give the server a certificate from Let’s Encrypt or another public CA:

docker run -d --name mcp-twenty-crm -p 443:8443 \
  -v /etc/letsencrypt/live/crm-mcp.example.com:/certs:ro \
  -e TLS_CERT_PATH=/certs/fullchain.pem \
  -e TLS_KEY_PATH=/certs/privkey.pem \
  --env-file app.env \
  ghcr.io/trozz/mcp-twenty-crm:latest

Renewals are picked up without a restart.

A trusted local certificate

For Claude Code or testing on your own machine.

With mkcert:

brew install mkcert
mkcert -install
mkcert -cert-file cert.pem -key-file key.pem localhost 127.0.0.1 ::1
TLS_CERT_PATH=$PWD/cert.pem TLS_KEY_PATH=$PWD/key.pem ./mcp-twenty-crm

With openssl:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 825 \
  -keyout key.pem -out cert.pem -subj "/CN=crm-mcp.example.com" \
  -addext "subjectAltName=DNS:crm-mcp.example.com,DNS:localhost,IP:127.0.0.1"

Add cert.pem to the trust store of the machine running the client, or to NODE_EXTRA_CA_CERTS for Node based clients.

The connection to Twenty

Separately from the certificate your server presents, the server’s own connection to Twenty must be https (with certificate verification), except to a loopback address such as http://127.0.0.1:3000 when Twenty runs on the same host. Redirects from Twenty are not followed: set TWENTY_API_URL to the address that answers directly.

CRM

Endpoint /crm (or crm_<tool> under /mcp). The CRM service reads and changes one Twenty workspace through Twenty’s REST API, with the signed-in person’s own API key.

Objects

Records belong to objects, named by their plural API name: people, companies, opportunities, notes, tasks, workspaceMembers and any custom objects. list_objects lists them and describe_object gives an object’s fields, their types, select values and relations. Records and ids come from the search and list tools; ids are UUIDs.

Tools

ToolKindWhat it doesKey parameters
search_recordsreadFind records by name across objects (people also by email, notes and tasks by title, custom objects by name). Every word of the query must match. Returns up to limit records per objectquery (up to 5 words), objects (default people, companies, opportunities; up to 8), limit (1 to 50, default 5)
list_recordsreadPage through any object with an optional filter and sort. Returns total, count, items and next_cursor while there are more pagesobject, filter, order_by, limit (1 to 200, default 20), cursor, fields, depth (0 or 1, default 0)
get_recordreadRead one record by id, with its related records in full unless depth is 0object, id, depth (0 or 1, default 1)
list_objectsreadThe workspace’s objects: plural API name, singular name, label and description, and whether an object is custominclude_system (default false)
describe_objectreadOne object’s fields: API name, type, label, whether it is required, the values a SELECT or MULTI_SELECT accepts, and for a RELATION its kind and set_with, the field to set with the related record’s id (such as companyId)object (plural or singular name), include_system (default false)
list_workspace_membersreadThe people who use Twenty, with their ids for assigning tasks and owning accountslimit (1 to 200, default 20), cursor
list_record_activityreadThe notes and tasks linked to one person, company or opportunity, newest first, with their Markdown bodies, due dates and statusobject (people, companies or opportunities), id, limit (1 to 200 each, default 20)
create_personwriteCreate a person from plain fields; the composite fields are built for youfirst_name, last_name, email, phone, phone_calling_code, job_title, city, company_id, linkedin_url, extra
create_companywriteCreate a company from plain fieldsname, domain, employees, linkedin_url, address_street, city, postcode, country, account_owner_id, extra
create_opportunitywriteCreate an opportunity (deal) from plain fieldsname, amount, currency_code, stage, close_date, company_id, point_of_contact_id, extra
create_notewriteCreate a note with a Markdown body and link it to people, companies and opportunities in one calltitle, body, person_ids, company_ids, opportunity_ids
create_taskwriteCreate a task with an optional due date, status, assignee and Markdown body, linked to people, companies and opportunitiestitle, body, due_at, status (TODO, IN_PROGRESS or DONE), assignee_id, person_ids, company_ids, opportunity_ids
create_recordwriteCreate one record of any object from its own fieldsobject, data
create_recordswriteCreate 1 to 60 records of one object in a single request, for example to import a listobject, records
update_recordwriteChange fields of one record of any object. Only the fields given changeobject, id, data
delete_recordwriteMove a record to the trash (the default), or destroy it with permanent: trueobject, id, permanent (default false)
restore_recordwriteBring a record back from the trashobject, id

Prompts

Prompts are ready-made requests a client can offer you. They tell the model which tools to call and in what order; they change nothing by themselves.

PromptArgumentsWhat it does
account_briefcompany (required)A briefing on one company: finds it with search_records, reads the record, the people there, its opportunities and its notes and tasks, then writes a summary with company details, people, opportunities, recent activity and next steps. If more than one company could match, it asks which
log_interactionsummary (required)Turns a summary of a call, meeting or email into a note and follow-up tasks linked to the right people, companies and opportunities. It shows you what it will create and changes nothing until you confirm

Arguments are text of up to 4,000 characters. Both prompts tell the model to treat record text as data, not instructions, and how to react to each tool error kind. On a read-only CRM service the prompts are still offered; a step that needs a hidden tool tells the model to say so and skip it.

Filters and sorting

list_records passes filter and order_by to Twenty as they are, so Twenty’s own syntax applies:

  • A filter is field[comparator]:value, for example name[ilike]:%acme% or companyId[eq]:<id>.
  • Comparators: eq, neq, in, containsAny, is (NULL or NOT_NULL), gt, gte, lt, lte, startsWith, like, ilike.
  • Combine with and(...), or(...) and not(...), for example and(stage[eq]:NEW,amount.amountMicros[gte]:1000000000).
  • Composite subfields use dots: emails.primaryEmail, name.lastName, amount.amountMicros.
  • order_by is field names with an optional direction, separated by commas: createdAt[DescNullsLast] or name.firstName,createdAt[AscNullsFirst]. Directions are AscNullsFirst, AscNullsLast, DescNullsFirst and DescNullsLast.
  • fields limits the fields returned (up to 100 names; id is always included).
  • Pass next_cursor from one page as cursor to get the next.

Filters are limited to 2,000 characters.

Composite fields

Twenty stores some fields as objects. create_record and update_record take them in Twenty’s own shape:

FieldShape
name (people){firstName, lastName}
emails{primaryEmail, additionalEmails}
phones{primaryPhoneNumber, primaryPhoneCallingCode}
domainName, linkedinLink{primaryLinkUrl}
address{addressStreet1, addressCity, addressPostcode, addressCountry}
amount{amountMicros, currencyCode}, where amountMicros is the amount times 1,000,000

Relations take the related record’s id, for example {"companyId": "<id>"}. In update_record, a composite field is replaced as a whole, so give every part you want to keep. data must hold at least one field and must not contain id.

The create_person, create_company and create_opportunity tools take plain fields and build these shapes for you. Their extra parameter takes any other field by API name, including custom ones; the plain fields win over extra, which must not contain id.

  • create_opportunity turns amount (for example 12500.50) into micros, with currency_code or else TWENTY_DEFAULT_CURRENCY. Amounts must be below 1,000,000,000,000. stage is upper-cased with spaces and hyphens turned into _, so proposal becomes PROPOSAL; describe_object opportunities lists the stages your workspace has.
  • close_date and due_at are ISO 8601 date-times, for example 2026-06-30T00:00:00Z.

Notes and tasks

  • Bodies are Markdown, up to 100,000 characters, stored in Twenty’s bodyV2 field. Outputs show the Markdown without the editor’s block JSON.
  • Links are made through noteTargets and taskTargets with the targetPerson, targetCompany and targetOpportunity fields, one request per link, up to 60 ids per list.
  • If some links fail, the note or task is still created: the result lists link_errors and says not to create it again. Link the rest with create_record on noteTargets or taskTargets.

Deleting

Twenty’s own DELETE destroys a record unless told otherwise. delete_record always asks for a soft delete unless permanent is true, so by default the record goes to the trash and restore_record brings it back.

Output

Results are compact JSON: Twenty’s envelopes are unwrapped, empty values are dropped, and searchVector and avatarUrl (which carries a signed file token) are removed. Pages carry object, total when Twenty gives it, count, items and next_cursor while there are more.

Twenty notes

  • Twenty allows about 100 API requests per minute per workspace. When the limit is hit, tools fail with rate_limited and say how long to wait, using Twenty’s Retry-After when it sends one.
  • Reads are retried once after a network error or a server error; writes never are, so a slow create cannot run twice.
  • Requests time out after 30 seconds (10 seconds to connect), and responses larger than 16 MiB are refused; ask for fewer records or fields.
  • Listing and describing objects needs a key whose role may read the data model.
  • Free-text arguments such as names and titles are limited to 500 characters.

Combined endpoint

Endpoint /mcp. It offers every enabled service’s tools and prompts in one MCP server. Today there is one service, crm, so /mcp offers the same tools as /crm with a crm_ prefix. The /crm endpoint keeps working unchanged.

Tool names

Under /mcp every tool is prefixed with its service: <service>_<tool>.

ServiceExample names
CRMcrm_search_records, crm_list_records, crm_create_note, crm_delete_record

Descriptions, parameters and annotations are the same as on the service’s own endpoint, so the CRM page describes every tool. Only services selected by TWENTY_SERVICES appear.

Prompts

Every service’s prompts are offered under /mcp with the same prefix as its tools, and their text names the prefixed tools (crm_search_records here, search_records on /crm).

PromptDescribed on
crm_account_briefCRM
crm_log_interactionCRM

Safety switches apply

  • A read-only service (TWENTY_READ_ONLY) is read-only under /mcp too: its write tools are hidden and refused.
  • Audit lines name the real service and tool, with "endpoint": "/mcp".

Sign-in

/mcp is its own OAuth protected resource (https://crm-mcp.example.com/mcp), with metadata at /.well-known/oauth-protected-resource/mcp. A token issued for /mcp works only there, and a token for /crm does not work on /mcp. Because /mcp is a resource of its own, an OAuth client must name the resource it wants (the resource parameter, which claude.ai sends). A Twenty API key sent directly as the bearer token works on every endpoint.

Turning it off

/mcp is served whenever at least one service runs. Set TWENTY_COMBINED=false to turn it off; /mcp then returns 404.

Choosing between them

  • /mcp is what the README and release notes suggest, and keeps working unchanged if more services are added later.
  • /crm keeps tool names short.
  • Do not add both: every tool would appear twice.

Security

mcp-twenty-crm is reachable from the internet and acts in your CRM with each person’s own Twenty API key. This page explains how access is controlled and what the server records.

Sign-in with OAuth

The server is its own OAuth 2.1 authorization server, built for MCP clients such as claude.ai. Instead of a password, the sign-in page asks for the person’s Twenty API key and checks it with Twenty:

sequenceDiagram
    participant C as MCP client
    participant S as mcp-twenty-crm
    participant U as You (browser)
    participant T as Twenty
    C->>S: POST /mcp (no token)
    S-->>C: 401, WWW-Authenticate: resource_metadata=.../oauth-protected-resource/mcp
    C->>S: GET /.well-known/oauth-protected-resource/mcp
    C->>S: GET /.well-known/oauth-authorization-server
    C->>S: POST /register (dynamic client registration)
    C->>U: open /authorize (PKCE S256, resource=.../mcp)
    U->>S: Twenty API key on the sign-in page
    S->>T: GET /rest/workspaceMembers?limit=1 with the key
    T-->>S: 200
    S-->>C: authorization code (redirect)
    C->>S: POST /token (code + verifier)
    S-->>C: access token (1 hour) and refresh token (30 days), audience /mcp, key sealed inside
    C->>S: POST /mcp with Bearer token
  • Each endpoint (/crm, /mcp) is its own OAuth protected resource (RFC 9728) with metadata at /.well-known/oauth-protected-resource/<endpoint>.
  • Tokens are bound to the endpoint they were issued for (RFC 8707): a token for /crm is rejected on /mcp.
  • A key is accepted when Twenty answers the check request, even if the key’s role may not read workspace members. A key Twenty rejects is refused; if Twenty is unreachable or rate limited, the page says so and nothing counts against the person.
  • Values that are not shaped like a Twenty API key (a JWT of three dot-separated parts, 20 to 4,096 characters) are refused without being sent to Twenty.
  • Access tokens last 1 hour, refresh tokens 30 days, and every grant ends 90 days after sign-in however often it is refreshed.

API keys are never stored

The server holds no Twenty key of its own and writes none to disk. A person’s key is encrypted (ChaCha20-Poly1305, under a key derived from OAUTH_SIGNING_KEY) inside their own tokens, and every tool call goes to Twenty with that key, so it runs with exactly that person’s role. Revoking a key in Twenty cuts that person off on their next call.

Anyone who has OAUTH_SIGNING_KEY can mint tokens and open every sealed key, so keep it secret.

Bearer keys

Clients that can send a header, such as Claude Code, may send their Twenty API key directly as Authorization: Bearer <key>. The server checks it with Twenty and remembers a valid key for ten minutes and a rejected one for one minute. At most 16 checks run at once, each times out after 15 seconds, and checks of the same key are shared.

There is no shared static token: MCP_AUTH_TOKEN is refused at startup.

Ending access

The server is stateless, so revocation is all or nothing:

  • A person: revoke their API key in Twenty. Their tokens still exist, but every call fails with auth.
  • Everyone: rotate OAUTH_SIGNING_KEY, or change TWENTY_API_URL. Every client has to sign in again.

Sign-in rate limits

  • At most 5 failed sign-ins per minute per client address (IPv6 per /64), 25 per IPv4 /24 or IPv6 /48, and 30 failures per minute in total. A rejected bearer key counts as a failure too.
  • Because everyone brings their own key, the overall cap only blocks addresses that have failed themselves, so an attacker cannot lock out people on other networks.
  • The client address is the TCP peer. Behind a reverse proxy, set TRUSTED_PROXIES to the proxy addresses so the right-most untrusted X-Forwarded-For entry is used; otherwise every request shares the proxy address.

Read-only mode

TWENTY_READ_ONLY=crm (or all) makes the CRM service read-only on this server.

  • Clients see only the tools marked read-only: search_records, list_records, get_record, list_objects, describe_object, list_workspace_members and list_record_activity.
  • Prompts stay listed; a prompt step that needs a hidden tool tells the model to say so and stop that step.
  • Any other tool call returns read_only: the crm service is read-only on this server.
  • It applies on /crm and under /mcp.

Read-only mode is a server-wide switch. To limit one person, give their API key a Twenty role that can only read.

Audit log

Every call of a tool that is not read-only, including refused ones, produces one audit line:

{"ts": "2026-10-18T10:00:00.000Z", "service": "crm", "endpoint": "/mcp", "tool": "delete_record", "outcome": "ok", "error_kind": null, "caller": "oauth:<client_id>", "subject": "key:<16 hex digits>", "elapsed_ms": 120}
  • outcome is ok, error or refused (a read-only service); error_kind is the tool error kind such as validation (read_only for a refusal, protocol for a malformed call), or null on success.
  • caller is how the request came in: oauth:<client_id>, oauth or credential (a key sent as the bearer token).
  • subject is who made it: key: and a short hash of their API key, keyed by OAUTH_SIGNING_KEY so it cannot be used to guess the key. The same key gives the same subject across clients.
  • Arguments, ids and results are never recorded.
  • Lines go to the audit log target, which stays on at info whatever LOG_LEVEL says, and are appended to AUDIT_LOG_PATH when it is set (the file is created with mode 0600).
  • If the file cannot be written, the server logs a warning at most once a minute and the tool call still succeeds.
  • In Docker, mount a volume for the file, for example ./audit:/audit with AUDIT_LOG_PATH=/audit/audit.jsonl, so it survives container restarts.

What is never logged

  • API keys, tokens and the Authorization header.
  • Twenty response bodies and tool results. Error details that can carry record ids or filter values are logged at debug level only.

The only exception is DEBUG_UNSAFE_LOG_CONTENT=true, which writes tool results and Twenty response bodies into the logs for debugging. API keys are never logged, even then.

Record text is data

Names, notes and other record text are written by people and imports. The server’s instructions to the model, and both prompts, tell it to treat that text as data and to ignore anything in it that reads like an instruction.

Talking to Twenty

  • Requests go only to TWENTY_API_URL, over https with certificate verification (plain http only to a loopback address).
  • Redirects are not followed, so a key is never sent to another host.
  • The user agent is mcp-twenty-crm/<version>.

Other protections

  • MCP sessions are bound to the key that opened them; a session id is useless with any other key.
  • The MCP endpoints check Host and Origin against PUBLIC_URL (plus ALLOWED_ORIGINS) to stop DNS rebinding and cross-site requests.
  • The sign-in page sends X-Frame-Options: DENY, a strict content security policy, no-store and no-referrer. Every response sends X-Content-Type-Options: nosniff, and Strict-Transport-Security when the server holds its certificate files.
  • Request bodies are limited to 1 MiB.

The container

The image is FROM scratch: the static binary and CA certificates only, running as UID 65532.

Operations

Health

  • GET /healthz returns ok without authentication.
  • mcp-twenty-crm --healthcheck connects to 127.0.0.1 on the BIND_ADDR port and exits 0 when /healthz answers. The container image uses it as its HEALTHCHECK, so docker ps shows healthy.
  • /healthz does not contact Twenty, so a healthy server can still fail tool calls when Twenty is down.

Logs

  • Logs go to stdout, as JSON by default (LOG_FORMAT=pretty for humans).
  • Every tool call logs a tool call line with the tool, outcome and duration, and a failed call logs tool failed with its error kind. Startup logs twenty settings (the Twenty origin and default currency), one service enabled line with the URL and tools, and combined endpoint enabled.
  • LOG_LEVEL takes tracing filters, for example info,mcp_twenty_crm_records=debug. The MCP library targets that log session ids are capped at warn whatever you set.
  • DEBUG=true adds read tool arguments, each Twenty request (method, path, status and duration) and tool error details, without response bodies.
  • DEBUG_UNSAFE_LOG_CONTENT=true adds tool results and Twenty response bodies. Use it only briefly; see Security.

Metrics

METRICS_ADDR (for example 0.0.0.0:9090) starts a separate plain-HTTP Prometheus listener. Keep it on an internal network. Metrics:

MetricWhat it counts
mcp_tool_calls_total, mcp_tool_duration_secondsTool calls by tool and outcome, and their duration
twenty_requests_total, twenty_request_duration_secondsRequests to Twenty by endpoint label and status class, and their duration
twenty_rate_limited_totalAnswers from Twenty with HTTP 429
oauth_requests_total, oauth_login_failures_totalOAuth requests by endpoint and outcome, and failed sign-ins
bearer_credential_checks_totalChecks of API keys sent as bearer tokens, by outcome
http_requests_totalHTTP requests by route and status

Traces

OTEL_EXPORTER_OTLP_ENDPOINT, with the standard OTEL_EXPORTER_OTLP_HEADERS when your collector needs them, exports traces over OTLP/HTTP. Nothing is exported unless it is set.

Audit file

With AUDIT_LOG_PATH, the audit file only grows: one short line per write. Rotate it with a tool that truncates in place, such as logrotate with copytruncate. See Security for the line format.

Upgrading

  • Docker: pull the new image and recreate the container (docker compose pull && docker compose up -d). The container is stateless.
  • Binary: replace the file and restart.
  • Connectors stay signed in across upgrades and restarts as long as OAUTH_SIGNING_KEY and TWENTY_API_URL stay the same.
  • Twenty upgrades need nothing on this side, as long as Twenty keeps the REST API this server uses; see Introduction.
  • Release notes list every change; see Releases.

Scaling and replicas

Everything that matters lives in tokens, so several replicas need only share OAUTH_SIGNING_KEY and the same configuration. Sign-in limits and one-time codes are kept per replica. On SIGTERM the server drains open connections for up to ten seconds.

Backups

There is nothing to back up: Twenty holds all data, and the server keeps nothing on disk apart from the optional audit file. Keep your configuration and OAUTH_SIGNING_KEY somewhere safe.

Resource use

The server holds no cache of CRM data; memory is used by open MCP sessions and in-flight requests. The container needs no writable filesystem apart from the audit volume.

Troubleshooting

The server does not start

The server prints every configuration problem as configuration error: ... and exits. Common ones:

MessageFix
PUBLIC_URL is requiredSet PUBLIC_URL to the external https address, without a path
PUBLIC_URL must not contain a pathUse the bare origin, for example https://crm-mcp.example.com
OAUTH_SIGNING_KEY must be at least 32 bytesUse openssl rand -hex 32
MCP_AUTH_TOKEN cannot be used when people sign in with their own Twenty API keyUnset MCP_AUTH_TOKEN. Clients that can send a header send their own Twenty key instead; see Connecting clients
TWENTY_SERVICES contains an unknown service: ... (known: crm)Use crm, or leave it unset
TWENTY_API_URL must use https (http is only allowed for loopback addresses)Use the https address of your Twenty server
TWENTY_API_URL is the Twenty server's address without /rest, e.g. https://crm.example.comRemove /rest from the end
TWENTY_DEFAULT_CURRENCY must be a three-letter ISO 4217 code such as USD, GBP or EURUse a code such as GBP
..._FILE: cannot read ...The secret file is missing or unreadable by UID 65532 in the container

claude.ai cannot connect

  • The server must be reachable from the internet with a publicly trusted certificate. A self-signed certificate works only behind a tunnel or proxy; see TLS and public access.
  • With a Cloudflare tunnel, the public hostname must point to https://mcp-twenty-crm:8443 with No TLS Verify on.
  • PUBLIC_URL must be exactly the address claude.ai uses. OAuth metadata and token audiences are built from it.

The sign-in page refuses the key

  • The key must be a whole Twenty API key, copied when Twenty showed it. A value that is not shaped like one is refused without being sent to Twenty.
  • The key must belong to the workspace at TWENTY_API_URL. Twenty Cloud keys work only with the default https://api.twenty.com; a self-hosted key needs your server’s address.
  • “Twenty’s rate limit is used up” or “Twenty did not answer as expected”: Twenty was busy or unreachable. Wait and try again; check that the server can reach TWENTY_API_URL.
  • After several failures from your address, sign-in is blocked for up to a minute.

A connector stops working with 401

  • Rotating OAUTH_SIGNING_KEY or changing TWENTY_API_URL signs everyone out: remove and add the connector again.
  • Every grant ends 90 days after sign-in. Sign in again.
  • A token for one endpoint is not accepted on another. Make sure the connector URL is the endpoint it signed in to.

Tool errors

Tool errors start with a kind, for example not_found: ...:

KindMeaning
authTwenty rejected the API key: it expired, was revoked, or belongs to another workspace. Sign in again with a new key
permissionThe key is valid but its role may not read or change this object. Ask a Twenty admin to change the role
not_foundThe object or record id does not exist (any more). Find it again with search_records or list_records
rate_limitedTwenty’s limit of about 100 requests per minute per workspace is used up. The message says how long to wait
validationAn argument is wrong, or Twenty rejected the request; the message says why. describe_object shows field names and select values
upstreamTwenty returned an unexpected answer, timed out or could not be reached; usually temporary. “invalid JSON” or “unexpected redirect” means TWENTY_API_URL does not point at the Twenty server
internalAn unexpected error inside the server; check the log and report it if it repeats
read_onlyThe service is read-only on this server (TWENTY_READ_ONLY)

Notes or tasks are not linked

create_note and create_task need a recent Twenty release with the targetPerson, targetCompany and targetOpportunity fields. On an older release the note or task can be created while its links fail; the result then lists them under link_errors. Upgrade Twenty.

list_objects or describe_object fails with permission

Reading the data model needs a role that may read it. Use a key whose role has that permission, or call the record tools with object names you already know.

More detail

Set LOG_LEVEL=info,mcp_twenty_crm_records=debug or DEBUG=true and reproduce the problem. These log request paths, record ids and filters, but not response bodies or API keys. When reporting an issue, include the version (mcp-twenty-crm --version) and the log lines, and check that they contain nothing private.

Architecture

Request flow

flowchart LR
    C1["MCP client<br/>/crm"] --> T
    C2["MCP client<br/>/mcp"] --> T
    T["HTTPS<br/>tunnel, proxy or direct"] --> S

    subgraph S["mcp-twenty-crm binary (:8443)"]
        direction TB
        P["Public routes<br/>/healthz, /favicon.ico<br/>/.well-known/*, /register<br/>/authorize, /token"]
        G["Bearer check per endpoint<br/>token audience, or a Twenty key"]
        W["Policy wrapper<br/>read-only and audit"]
        R["CRM service<br/>tools and prompts"]
        X["Combined /mcp<br/>prefixed tool names"]
        A["Twenty REST client<br/>caller's own key"]
        G --> W
        W --> R
        W --> X
        X --> R
        R --> A
        P -->|"key check at sign-in"| A
    end

    A -->|"HTTPS, /rest"| TW["Twenty<br/>TWENTY_API_URL"]

Crates

CrateRole
mcp-server-coreConfiguration reading, the OAuth authorization server, sign-in with a service credential, bearer guards, TLS, logging, metrics, the read-only and audit wrapper, the combined endpoint, and the Service trait every service implements. A crates.io dependency, shared with mcp-icloud
mcp-twenty-crm-recordsThe crm service: the Twenty REST client (api.rs), the API key check (verifier.rs), the tools (tools/), output shaping (view.rs) and prompts (prompts.rs)
mcp-twenty-crmThe binary: selects services, mounts each at /<name> and the combined /mcp, sets people to sign in with their own Twenty key, and runs the server

A tool call, end to end

sequenceDiagram
    participant C as Client
    participant S as mcp-twenty-crm /mcp
    participant T as Twenty REST API
    C->>S: tools/call crm_create_note (Bearer token)
    S->>S: check token audience, open the sealed API key
    S->>S: policy: writable? record audit line after
    S->>T: POST /rest/notes (Bearer: the caller's key)
    T-->>S: the new note
    S->>T: POST /rest/noteTargets, one per linked record
    T-->>S: the links
    S-->>C: the note and the links made

Design choices

  • No database and no stored keys. Twenty is the only store. Each person’s API key travels sealed inside their own tokens, so restarts and replicas sign nobody out.
  • Every call as the person. Requests go to Twenty with the caller’s own key, so Twenty’s roles and permissions decide what each person can do. The server adds nothing on top except the optional read-only switch.
  • Generic first. The record tools work on any object, including custom ones, by its plural API name. The create_ tools for people, companies, opportunities, notes and tasks only build Twenty’s composite fields from plain arguments.
  • Safe deletes and retries. delete_record soft-deletes unless told otherwise, and writes are never retried, so a slow create cannot run twice.

Contributing

Build and test

You need Rust 1.99.1 (the workspace pins it in rust-toolchain.toml).

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo deny check
bash scripts/test-next-version.sh

The tests run against an in-memory fake of the Twenty REST API (crates/mcp-twenty-crm-records/tests/support/fake_twenty.rs), including end-to-end MCP sessions through the OAuth and bearer guards, so they need no Twenty workspace or API key.

To try the server against a real workspace, run it locally (see Getting started) with TWENTY_API_URL pointing at a test workspace, and connect a client with an API key from that workspace.

This documentation

The site is built with mdBook from docs/. To preview it:

cargo install --locked mdbook@0.5.4 mdbook-mermaid@0.17.1 lychee@0.24.2
bash scripts/build-docs.sh
mdbook serve docs --open

scripts/build-docs.sh builds the book and checks every internal link and anchor. When you add or change a tool, prompt or environment variable, update the CRM page or the configuration reference in the same pull request.

Adding a service

Each service is a library crate that implements the Service trait from mcp_server_core::service:

  1. Create crates/mcp-twenty-crm-<name> as a library. Read its own TWENTY_ variables in pub fn from_env(env: &mut ServiceEnv) -> Option<Arc<dyn Service>>, reporting problems through env.errors. Never read the server variables.
  2. Build the MCP endpoint in the trait’s router() with mcp_server_core::service::mcp_service, and return the same MCP server from handler() so the combined endpoint can offer it. Service names are lowercase path segments without _ (the combined endpoint uses _ to separate the service from the tool name), and mcp is reserved.
  3. Only one enabled service may define how people sign in (credential_verifier()); today that is crm.
  4. Add the crate to crates/mcp-twenty-crm/Cargo.toml and one line to SERVICES in crates/mcp-twenty-crm/src/lib.rs.
  5. Document its tools on a new page under docs/src/services/ and its variables in docs/src/configuration.md.

Routing, OAuth metadata, token audiences, TWENTY_SERVICES, read-only mode, audit, the combined endpoint and the release pipeline pick the new service up without further changes.

Pull requests

  • Branch from develop as feature/<name>, bugfix/<name> or hotfix/<name>, and open the pull request against develop.
  • Use conventional commit messages (feat:, fix:, docs:, test:, chore:, refactor:).
  • Test data must be synthetic: never copy real CRM records or API keys into fixtures.
  • CI runs fmt, clippy, tests, cargo-deny, a Docker build, the script checks and the documentation build.

Releases

  • Work happens on feature/* and bugfix/* branches merged into develop by pull request.
  • Every merge into develop that touches the server (crates/, Cargo.toml, Cargo.lock or the Dockerfile) publishes a pre-release vX.Y.Z-dev.N: a GitHub pre-release and image tags X.Y.Z-dev.N and develop.
  • A pull request from develop into main publishes a release vX.Y.Z when merged: image tags X.Y.Z, X.Y, X and latest, binaries for Linux x86_64 and arm64 and macOS arm64 with checksums and build provenance, and this documentation site.
  • The version bump comes from the develop to main pull request: a major, minor or patch label, otherwise [major], [minor] or [patch] in its title, otherwise patch.
  • Pull requests into main must come from develop in this repository; CI fails any other.
  • Each release starts as a draft with notes generated from the conventional commit messages, and is published automatically (creating its tag) once the binaries and the image are built.

Images are published to ghcr.io/trozz/mcp-twenty-crm. Pin a version tag such as ghcr.io/trozz/mcp-twenty-crm:1.2.3 in production, and read the release notes before upgrading.

Repository setup (maintainers, once)

gh label create major --color B60205 --description "Release: major version bump"
gh label create minor --color FBCA04 --description "Release: minor version bump"
gh label create patch --color 0E8A16 --description "Release: patch version bump"
  • Under Settings > Actions > General > Workflow permissions, the workflows must be allowed to grant contents: write, packages: write, id-token: write, attestations: write and pages: write (the jobs request them explicitly).
  • Under Settings > Pages, the source must be GitHub Actions for this site to publish.
  • Merge develop into main with a merge commit, so the two branches never diverge.
  • After the first image is published, set the mcp-twenty-crm package visibility to public under Packages for anonymous pulls.