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:
| Endpoint | Service | What it does |
|---|---|---|
/crm | CRM | 17 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 |
/mcp | Combined | Every 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 scratchcontainer image (ghcr.io/trozz/mcp-twenty-crm). - TLS end to end: the server terminates TLS itself, and talks to Twenty over
httpsonly (plainhttponly 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=crmhides 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:
| Purpose | Path |
|---|---|
| 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,targetCompanyandtargetOpportunityfields, and bodies usebodyV2Markdown. - 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:
- Opens their Twenty workspace and goes to Settings, then APIs & Webhooks.
- 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 withopenssl rand -hex 32and 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(orall) hides and refuses every tool that changes data.TWENTY_COMBINED=falseturns off the combined/mcpendpoint, leaving only/crm.TWENTY_DEFAULT_CURRENCY=GBPsets the currency of opportunity amounts given without one (defaultUSD).
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.
| Endpoint | Connector URL | Tool names |
|---|---|---|
| CRM | https://crm-mcp.example.com/crm | search_records, create_note, … |
| All services | https://crm-mcp.example.com/mcp | crm_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).
- In claude.ai open Settings, then Connectors, then Add custom connector.
- Enter the connector URL, for example
https://crm-mcp.example.com/mcp. - 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
_FILEto its name, for exampleOAUTH_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,onandoff, in any case. - Lists are comma separated; spaces around names are ignored.
Server and HTTP
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
PUBLIC_URL | yes | https://crm-mcp.example.com | External base URL clients use, without a path. Must be https; http is accepted only for localhost | |
BIND_ADDR | no | 0.0.0.0:8443 | 127.0.0.1:8443 | Listen address. It must listen on loopback (0.0.0.0 does) so --healthcheck can reach 127.0.0.1 |
TLS_CERT_PATH, TLS_KEY_PATH | no | /certs/fullchain.pem, /certs/privkey.pem | PEM certificate chain and private key. Both or neither; reloaded automatically when the files change. See TLS and public access | |
INSECURE_HTTP | no | false | true | Serve plain HTTP. Only when TLS is terminated in front of the server on a network you trust |
ALLOWED_ORIGINS | no | https://app.example.com | Extra Origin values accepted on MCP requests, comma separated. The PUBLIC_URL origin is always allowed | |
TRUSTED_PROXIES | no | 10.0.0.2,10.0.0.3 | Proxy IP addresses whose X-Forwarded-For is trusted for sign-in rate limiting |
Services
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
TWENTY_SERVICES | no | all (crm) | crm | Services to run. There is one, crm. An unknown name or an empty list stops startup |
TWENTY_READ_ONLY | no | none | crm or all | Services whose write tools are hidden and refused, on their own endpoint and under /mcp. An unknown name stops startup. See Security |
TWENTY_COMBINED | no | true | false | Serve the combined endpoint /mcp. With false, /mcp returns 404 |
OAuth
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
OAUTH_SIGNING_KEY | yes | output of openssl rand -hex 32 | Key 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
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
TWENTY_API_URL | no | https://api.twenty.com | https://crm.example.com | Your 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_CURRENCY | no | USD | GBP | Three-letter ISO 4217 code for opportunity amounts given without one. Upper-cased; anything else stops startup |
Logging, metrics and audit
| Variable | Required | Default | Example | Description |
|---|---|---|---|---|
LOG_LEVEL | no | info | info,mcp_twenty_crm_records=debug | Log filter in tracing syntax. Targets: mcp_twenty_crm, mcp_twenty_crm_records, mcp_server_core. The audit target always stays on at info |
LOG_FORMAT | no | json | pretty | json or pretty |
DEBUG | no | false | true | Debug 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_CONTENT | no | false | true | Also 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_ADDR | no | 0.0.0.0:9090 | Starts a separate plain-HTTP Prometheus listener. Keep it on an internal network | |
OTEL_EXPORTER_OTLP_ENDPOINT | no | https://otel.example.com | Exports traces over OTLP/HTTP. The standard OTEL_EXPORTER_OTLP_HEADERS is honoured. Nothing is exported unless it is set | |
AUDIT_LOG_PATH | no | /audit/audit.jsonl | File 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:
INSECURE_HTTP=true: plain HTTP, no TLS. Use only when TLS is terminated in front of the server on a network you trust.TLS_CERT_PATHandTLS_KEY_PATHboth 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 carryStrict-Transport-Security.- Neither set: the server generates an in-memory self-signed certificate for the
PUBLIC_URLhost,localhost,127.0.0.1and::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 setINSECURE_HTTP=trueif the proxy and server share a trusted network. - Pass the
Hostheader through unchanged and do not buffer responses (MCP uses streaming HTTP responses). - Set
TRUSTED_PROXIESto the proxy’s IP address so the server uses the client address fromX-Forwarded-Forfor 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
| Tool | Kind | What it does | Key parameters |
|---|---|---|---|
search_records | read | Find 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 object | query (up to 5 words), objects (default people, companies, opportunities; up to 8), limit (1 to 50, default 5) |
list_records | read | Page through any object with an optional filter and sort. Returns total, count, items and next_cursor while there are more pages | object, filter, order_by, limit (1 to 200, default 20), cursor, fields, depth (0 or 1, default 0) |
get_record | read | Read one record by id, with its related records in full unless depth is 0 | object, id, depth (0 or 1, default 1) |
list_objects | read | The workspace’s objects: plural API name, singular name, label and description, and whether an object is custom | include_system (default false) |
describe_object | read | One 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_members | read | The people who use Twenty, with their ids for assigning tasks and owning accounts | limit (1 to 200, default 20), cursor |
list_record_activity | read | The notes and tasks linked to one person, company or opportunity, newest first, with their Markdown bodies, due dates and status | object (people, companies or opportunities), id, limit (1 to 200 each, default 20) |
create_person | write | Create a person from plain fields; the composite fields are built for you | first_name, last_name, email, phone, phone_calling_code, job_title, city, company_id, linkedin_url, extra |
create_company | write | Create a company from plain fields | name, domain, employees, linkedin_url, address_street, city, postcode, country, account_owner_id, extra |
create_opportunity | write | Create an opportunity (deal) from plain fields | name, amount, currency_code, stage, close_date, company_id, point_of_contact_id, extra |
create_note | write | Create a note with a Markdown body and link it to people, companies and opportunities in one call | title, body, person_ids, company_ids, opportunity_ids |
create_task | write | Create a task with an optional due date, status, assignee and Markdown body, linked to people, companies and opportunities | title, body, due_at, status (TODO, IN_PROGRESS or DONE), assignee_id, person_ids, company_ids, opportunity_ids |
create_record | write | Create one record of any object from its own fields | object, data |
create_records | write | Create 1 to 60 records of one object in a single request, for example to import a list | object, records |
update_record | write | Change fields of one record of any object. Only the fields given change | object, id, data |
delete_record | write | Move a record to the trash (the default), or destroy it with permanent: true | object, id, permanent (default false) |
restore_record | write | Bring a record back from the trash | object, 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.
| Prompt | Arguments | What it does |
|---|---|---|
account_brief | company (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_interaction | summary (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 examplename[ilike]:%acme%orcompanyId[eq]:<id>. - Comparators:
eq,neq,in,containsAny,is(NULLorNOT_NULL),gt,gte,lt,lte,startsWith,like,ilike. - Combine with
and(...),or(...)andnot(...), for exampleand(stage[eq]:NEW,amount.amountMicros[gte]:1000000000). - Composite subfields use dots:
emails.primaryEmail,name.lastName,amount.amountMicros. order_byis field names with an optional direction, separated by commas:createdAt[DescNullsLast]orname.firstName,createdAt[AscNullsFirst]. Directions areAscNullsFirst,AscNullsLast,DescNullsFirstandDescNullsLast.fieldslimits the fields returned (up to 100 names;idis always included).- Pass
next_cursorfrom one page ascursorto 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:
| Field | Shape |
|---|---|
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_opportunityturnsamount(for example12500.50) into micros, withcurrency_codeor elseTWENTY_DEFAULT_CURRENCY. Amounts must be below 1,000,000,000,000.stageis upper-cased with spaces and hyphens turned into_, soproposalbecomesPROPOSAL;describe_object opportunitieslists the stages your workspace has.close_dateanddue_atare ISO 8601 date-times, for example2026-06-30T00:00:00Z.
Notes and tasks
- Bodies are Markdown, up to 100,000 characters, stored in Twenty’s
bodyV2field. Outputs show the Markdown without the editor’s block JSON. - Links are made through
noteTargetsandtaskTargetswith thetargetPerson,targetCompanyandtargetOpportunityfields, 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_errorsand says not to create it again. Link the rest withcreate_recordonnoteTargetsortaskTargets.
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_limitedand say how long to wait, using Twenty’sRetry-Afterwhen 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>.
| Service | Example names |
|---|---|
| CRM | crm_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).
Safety switches apply
- A read-only service (
TWENTY_READ_ONLY) is read-only under/mcptoo: 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
/mcpis what the README and release notes suggest, and keeps working unchanged if more services are added later./crmkeeps 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
/crmis 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 changeTWENTY_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_PROXIESto the proxy addresses so the right-most untrustedX-Forwarded-Forentry 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_membersandlist_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
/crmand 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}
outcomeisok,errororrefused(a read-only service);error_kindis the tool error kind such asvalidation(read_onlyfor a refusal,protocolfor a malformed call), ornullon success.calleris how the request came in:oauth:<client_id>,oauthorcredential(a key sent as the bearer token).subjectis who made it:key:and a short hash of their API key, keyed byOAUTH_SIGNING_KEYso 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
auditlog target, which stays on atinfowhateverLOG_LEVELsays, and are appended toAUDIT_LOG_PATHwhen 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:/auditwithAUDIT_LOG_PATH=/audit/audit.jsonl, so it survives container restarts.
What is never logged
- API keys, tokens and the
Authorizationheader. - 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, overhttpswith certificate verification (plainhttponly 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
HostandOriginagainstPUBLIC_URL(plusALLOWED_ORIGINS) to stop DNS rebinding and cross-site requests. - The sign-in page sends
X-Frame-Options: DENY, a strict content security policy,no-storeandno-referrer. Every response sendsX-Content-Type-Options: nosniff, andStrict-Transport-Securitywhen 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 /healthzreturnsokwithout authentication.mcp-twenty-crm --healthcheckconnects to127.0.0.1on theBIND_ADDRport and exits 0 when/healthzanswers. The container image uses it as itsHEALTHCHECK, sodocker psshowshealthy./healthzdoes 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=prettyfor humans). - Every tool call logs a
tool callline with the tool, outcome and duration, and a failed call logstool failedwith its error kind. Startup logstwenty settings(the Twenty origin and default currency), oneservice enabledline with the URL and tools, andcombined endpoint enabled. LOG_LEVELtakestracingfilters, for exampleinfo,mcp_twenty_crm_records=debug. The MCP library targets that log session ids are capped atwarnwhatever you set.DEBUG=trueadds read tool arguments, each Twenty request (method, path, status and duration) and tool error details, without response bodies.DEBUG_UNSAFE_LOG_CONTENT=trueadds 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:
| Metric | What it counts |
|---|---|
mcp_tool_calls_total, mcp_tool_duration_seconds | Tool calls by tool and outcome, and their duration |
twenty_requests_total, twenty_request_duration_seconds | Requests to Twenty by endpoint label and status class, and their duration |
twenty_rate_limited_total | Answers from Twenty with HTTP 429 |
oauth_requests_total, oauth_login_failures_total | OAuth requests by endpoint and outcome, and failed sign-ins |
bearer_credential_checks_total | Checks of API keys sent as bearer tokens, by outcome |
http_requests_total | HTTP 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_KEYandTWENTY_API_URLstay 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:
| Message | Fix |
|---|---|
PUBLIC_URL is required | Set PUBLIC_URL to the external https address, without a path |
PUBLIC_URL must not contain a path | Use the bare origin, for example https://crm-mcp.example.com |
OAUTH_SIGNING_KEY must be at least 32 bytes | Use openssl rand -hex 32 |
MCP_AUTH_TOKEN cannot be used when people sign in with their own Twenty API key | Unset 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.com | Remove /rest from the end |
TWENTY_DEFAULT_CURRENCY must be a three-letter ISO 4217 code such as USD, GBP or EUR | Use 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:8443with No TLS Verify on. PUBLIC_URLmust 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 defaulthttps://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_KEYor changingTWENTY_API_URLsigns 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: ...:
| Kind | Meaning |
|---|---|
auth | Twenty rejected the API key: it expired, was revoked, or belongs to another workspace. Sign in again with a new key |
permission | The key is valid but its role may not read or change this object. Ask a Twenty admin to change the role |
not_found | The object or record id does not exist (any more). Find it again with search_records or list_records |
rate_limited | Twenty’s limit of about 100 requests per minute per workspace is used up. The message says how long to wait |
validation | An argument is wrong, or Twenty rejected the request; the message says why. describe_object shows field names and select values |
upstream | Twenty 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 |
internal | An unexpected error inside the server; check the log and report it if it repeats |
read_only | The 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
| Crate | Role |
|---|---|
mcp-server-core | Configuration 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-records | The 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-crm | The 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_recordsoft-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:
- Create
crates/mcp-twenty-crm-<name>as a library. Read its ownTWENTY_variables inpub fn from_env(env: &mut ServiceEnv) -> Option<Arc<dyn Service>>, reporting problems throughenv.errors. Never read the server variables. - Build the MCP endpoint in the trait’s
router()withmcp_server_core::service::mcp_service, and return the same MCP server fromhandler()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), andmcpis reserved. - Only one enabled service may define how people sign in (
credential_verifier()); today that iscrm. - Add the crate to
crates/mcp-twenty-crm/Cargo.tomland one line toSERVICESincrates/mcp-twenty-crm/src/lib.rs. - Document its tools on a new page under
docs/src/services/and its variables indocs/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
developasfeature/<name>,bugfix/<name>orhotfix/<name>, and open the pull request againstdevelop. - 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/*andbugfix/*branches merged intodevelopby pull request. - Every merge into
developthat touches the server (crates/,Cargo.toml,Cargo.lockor theDockerfile) publishes a pre-releasevX.Y.Z-dev.N: a GitHub pre-release and image tagsX.Y.Z-dev.Nanddevelop. - A pull request from
developintomainpublishes a releasevX.Y.Zwhen merged: image tagsX.Y.Z,X.Y,Xandlatest, 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
developtomainpull request: amajor,minororpatchlabel, otherwise[major],[minor]or[patch]in its title, otherwise patch. - Pull requests into
mainmust come fromdevelopin 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: writeandpages: write(the jobs request them explicitly). - Under Settings > Pages, the source must be GitHub Actions for this site to publish.
- Merge
developintomainwith a merge commit, so the two branches never diverge. - After the first image is published, set the
mcp-twenty-crmpackage visibility to public under Packages for anonymous pulls.