@drewling/twenty-mcp
v0.7.1
Published
MCP server for Twenty CRM — full REST + GraphQL surface, self-healing schema, retries, and structured logging.
Readme
@drewling/twenty-mcp
A Model Context Protocol server for Twenty CRM, generated from Twenty's REST and Metadata OpenAPI specs.
Exposes ~59 tools across four APIs so any MCP client (Claude Desktop, Claude Code, Cursor, etc.) can read, write, and manage your CRM:
| API | Tools exposed | Operations covered | What you can do |
|---|---|---|---|
| Core REST | ~35 entity tools | 392 | Companies, people, opportunities, notes, tasks, attachments, workflows … |
| Metadata REST | 13 grouped metadata_* tools | 65 | Custom objects, fields, webhooks, API keys, views … |
| GraphQL | 3 | — | Deep nesting, aggregations, full schema introspection |
| File uploads | 1 | — | Upload files to Twenty storage, then attach them to any CRM record |
| Helper tools | 6 | — | Filter builder, auto-pagination wrappers for major entities |
| Observability | 1 (twenty_health) | — | Spec source, age, tool count, last drift event |
Core REST operations are grouped by entity (e.g. one companies tool with an action parameter) rather than one tool per endpoint. This keeps the tool list at ~35 instead of 392, reducing context overhead by ~90% while covering the full API surface. Call list_twenty_capabilities to discover all entities and their available actions.
v0.3 added agent-ergonomics tooling: filter DSL helper, auto-pagination, structured errors, MCP resources, and guided workflow prompts.
v0.35 adds self-healing schema: the server fetches the live OpenAPI spec from Twenty at boot, polls every 5 minutes for changes, and automatically rebuilds the tool list when your data model changes — no restart or regen needed.
v0.36 collapses the 65 flat metadata_* tools into 13 grouped tools using the same entity+action pattern as core REST, reducing the tool list by ~52 entries.
v0.4 adds production reliability: retries with jitter, request timeouts, client-side idempotency, pino structured logging, optional OTel tracing, and a 260-test suite.
v0.5 adds distribution: published to npm as @drewling/twenty-mcp, Docker image on GHCR, automated daily spec regen CI, and copy-paste examples for all major MCP clients.
v0.6 adds metadata ergonomics: get_object_by_name tool (look up objectMetadataId by name), find_all_custom_fields and find_all_webhooks convenience tools, valid JSON truncation with exposed pagination cursors, and improved webhook/field tool descriptions.
Install
# One-shot via npx (no install needed)
npx @drewling/twenty-mcp
# Or install globally
npm install -g @drewling/twenty-mcp
twenty-mcp
# Docker
docker run --rm -i \
-e TWENTY_API_KEY=your_key \
-e TWENTY_API_URL=https://api.twenty.com/rest \
ghcr.io/drewling/twenty-mcp:latestQuick start
You need:
- Node.js ≥ 20 (or Docker)
- A Twenty API key — Settings → Developers → New API Key in your workspace
- The base URL of your Twenty instance (Twenty Cloud or self-hosted)
1. Get an API key
In Twenty: Settings → Developers → API Keys → Create. Copy the token.
2. Wire it into your MCP client
| Variable | Example |
|---|---|
| TWENTY_API_KEY | eyJhbGciOi… |
| TWENTY_API_URL | https://api.twenty.com/rest (must end in /rest) |
Copy-paste configs for each client are in the examples/ directory.
Claude Desktop
Edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"twenty": {
"command": "npx",
"args": ["-y", "@drewling/twenty-mcp"],
"env": {
"TWENTY_API_KEY": "your_twenty_api_key",
"TWENTY_API_URL": "https://api.twenty.com/rest"
}
}
}
}Restart Claude Desktop. The tools appear under the 🔌 menu.
Security note. MCP client config files store the token in plaintext on disk.
chmod 600the file, and prefer the CLI install path below where available.
Claude Code
claude mcp add twenty \
--env TWENTY_API_KEY=your_twenty_api_key \
--env TWENTY_API_URL=https://api.twenty.com/rest \
-- npx -y @drewling/twenty-mcpCursor
~/.cursor/mcp.json:
{
"mcpServers": {
"twenty": {
"command": "npx",
"args": ["-y", "@drewling/twenty-mcp"],
"env": {
"TWENTY_API_KEY": "your_twenty_api_key",
"TWENTY_API_URL": "https://api.twenty.com/rest"
}
}
}
}Docker
docker run --rm -i \
-e TWENTY_API_KEY=your_key \
-e TWENTY_API_URL=https://api.twenty.com/rest \
ghcr.io/drewling/twenty-mcp:latestFor Claude Desktop with Docker, set command to docker and args to ["run", "--rm", "-i", "-e", "TWENTY_API_KEY=...", "-e", "TWENTY_API_URL=...", "ghcr.io/drewling/twenty-mcp:latest"].
Run locally (development)
git clone https://github.com/drewling/twenty-mcp
cd twenty-mcp
npm install
cp .env.example .env # add your key and URL
npm start # builds and runs on stdioWatch mode:
npm run devPoint a local MCP client at the build:
{
"mcpServers": {
"twenty": {
"command": "node",
"args": ["/absolute/path/to/twenty-mcp/build/index.js"],
"env": {
"TWENTY_API_KEY": "...",
"TWENTY_API_URL": "https://api.twenty.com/rest"
}
}
}
}What's exposed
Core REST tools
Operations from /rest/open-api/core are grouped into one tool per entity. Examples:
companies(action: "findMany", filter: "name[ILIKE]:%acme%")people(action: "createOne", requestBody: { ... })opportunities(action: "findOne", id: "<uuid>")notes(action: "deleteOne", id: "<uuid>")
Call list_twenty_capabilities first to see all available entities and which actions each supports. Actions include findMany, findOne, createOne, createMany, updateOne, updateMany, deleteOne, deleteMany, restoreOne, groupBy, findDuplicates, and entity-specific variants.
Filter syntax: Use build_twenty_filter to construct filter strings without trial-and-error, or write them manually: field[COMPARATOR]:value — e.g. name[ILIKE]:%acme%, createdAt[GTE]:"2024-01-01". Combine with commas (AND). Use and(…), or(…), not(…) for complex conditions. Operators: EQ, NEQ, GT, GTE, LT, LTE, IN, LIKE, ILIKE, IS, STARTS_WITH.
Pagination: Pass limit (default 60, max 60) and starting_after = pageInfo.endCursor for the next page. Or use find_all_* to auto-paginate.
Depth: depth=1 (default) includes direct relations; depth=0 returns the bare object.
Metadata tools (metadata_ prefix)
The 65 metadata operations are grouped into 13 entity tools using the same action pattern as core REST tools:
metadata_objects(action: "findMany")— list custom object types;action: "createOne"/"updateOne"/"deleteOne"to manage themmetadata_fields(action: "findMany")— custom fields;createOne/updateOne/deleteOneto manage themmetadata_webhooks(action: "findMany")— webhook subscriptionsmetadata_apiKeys(action: "findMany")— API key managementmetadata_views(action: "findMany")— saved views; alsometadata_viewFields,metadata_viewFilters,metadata_viewSorts,metadata_viewGroups,metadata_viewFilterGroupsmetadata_pageLayouts(action: "findMany")— page layouts; alsometadata_pageLayoutTabs,metadata_pageLayoutWidgets
All 13 grouped tools support: findMany, findOne, createOne, updateOne, deleteOne.
GraphQL escape-hatch tools
| Tool | Purpose |
|---|---|
| graphql_query | Arbitrary query or mutation against /graphql |
| graphql_introspect | Full schema introspection; pass type_name to filter |
| graphql_metadata | Query/mutation against the metadata GraphQL endpoint |
Use these when REST doesn't reach: deep nested fetches, aggregations, batched mutations.
File upload tool
Twenty's file upload endpoint isn't part of the REST OpenAPI spec, so it's hand-written in src/file-tools.ts.
| Tool | Purpose |
|---|---|
| upload_file | Upload a file to Twenty storage; returns the path string |
Typical workflow:
- Call
upload_filewith a localfile_path(orfile_content+file_namefor in-memory data). - The tool returns a JSON object containing a
pathfield. - Pass that path as
fullPathwhen callingcreateOneAttachment, linking the file to a company, person, note, etc.
upload_file(file_path="/tmp/report.pdf")
→ { "path": "workspace-id/report-uuid.pdf" }
attachments(action="createOne", requestBody={
name: "Q4 Report",
fullPath: "workspace-id/report-uuid.pdf",
fileCategory: "PRESENTATION",
targetCompanyId: "<company-uuid>"
})Helper tools (v0.3+)
build_twenty_filter
Construct a valid Twenty filter string from a structured input — no syntax trial-and-error:
build_twenty_filter({ field: "name", operator: "ILIKE", value: "acme" })
→ { "filter": "name[ILIKE]:\"%acme%\"" }
build_twenty_filter({ field: "revenue", operator: "GT", value: 1000000 })
→ { "filter": "revenue[GT]:1000000" }
build_twenty_filter({ and: [
{ field: "name", operator: "ILIKE", value: "acme" },
{ field: "revenue", operator: "GT", value: 0 }
]})
→ { "filter": "and(name[ILIKE]:\"%acme%\",revenue[GT]:0)" }Supported operators: EQ, NEQ, GT, GTE, LT, LTE, IN, IS, LIKE, ILIKE, STARTS_WITH. Strings and dates are automatically quoted; numbers and booleans are not. ILIKE/LIKE wraps values in % wildcards if none are present.
find_all_* (auto-pagination)
Fetch all records without a manual pagination loop:
find_all_companies(filter: "name[ILIKE]:\"%acme%\"", max: 500)
→ { records: [...], totalFetched: 42, wasCapped: false, endCursor: "..." }Available wrappers: find_all_companies, find_all_people, find_all_opportunities, find_all_notes, find_all_tasks. All accept the same filter, order_by, depth, and max parameters. Default max is 10000.
MCP Resources (v0.3+)
Fetch a record by URI without calling a tool:
| Resource URI | Description |
|---|---|
| twenty://company/{id} | Fetch company by ID |
| twenty://person/{id} | Fetch person by ID |
| twenty://opportunity/{id} | Fetch opportunity by ID |
| twenty://note/{id} | Fetch note by ID |
| twenty://task/{id} | Fetch task by ID |
MCP Prompts (v0.3+)
Guided multi-step workflows accessible via ListPrompts / GetPrompt:
| Prompt | Description |
|---|---|
| log-a-call | Log a call activity linked to a company and contact |
| create-lead-from-email | Create a lead from an inbound email (company, contact, note, task) |
| weekly-pipeline-review | Summarize pipeline by stage and list tasks due this week |
Error responses (v0.3+)
All errors return a structured JSON object instead of raw axios details:
{ "error": { "code": "DUPLICATE", "message": "Company 'Acme' already exists", "hint": "Use updateOne to modify the existing company." } }Error codes: VALIDATION, AUTH_FAILED, NOT_FOUND, DUPLICATE, CONFLICT, RATE_LIMIT, NETWORK_ERROR, SERVER_ERROR.
Self-healing schema (v0.35+)
The server fetches Twenty's live OpenAPI spec at boot and polls for changes every 5 minutes (configurable via TWENTY_SPEC_REFRESH_MS). When a spec change is detected, the tool list is rebuilt and connected MCP clients receive a notifications/tools/list_changed event so they re-discover without reconnecting.
What this means in practice:
- Add a custom field to
Personin Twenty's UI → within 5 minutes,people(action: "updateOne", ...)accepts the new field. - Delete a custom object → tools for it disappear from the list automatically.
- No restart, no
npm run regen, no redeploy.
Drift recovery: If a tool call returns a NOT_FOUND error that looks like schema drift (e.g. "Route not found", "Field X does not exist"), the server immediately forces a spec refresh and retries the call once. If the retry succeeds, you get the result. If not, you get a structured error with the drift signal.
Monitoring: Call twenty_health to see the current spec state:
{
"specSource": "live",
"specEtag": "\"abc123\"",
"specAgeSeconds": 42,
"lastRefreshAt": "2026-01-15T10:30:00.000Z",
"toolCount": 111,
"lastDriftEventAt": null
}Offline / airgapped: Set TWENTY_OFFLINE=true to skip all live fetching and serve tools from the bundled openapi/twenty-core.json.
Reliability & Observability (v0.4+)
Retries and timeouts
Failed requests are retried up to 3 times with exponential backoff + jitter. Retries trigger on 5xx responses, 429 rate limits, and network errors (ECONNABORTED, ETIMEDOUT). 4xx errors (except 429) are not retried. The Retry-After header is respected.
| Env var | Default | Description |
|---|---|---|
| TWENTY_TIMEOUT_MS | 30000 | Per-request timeout in milliseconds |
| TWENTY_RETRY_BASE_MS | 100 | Exponential backoff base delay |
| TWENTY_RETRY_MAX_MS | 10000 | Maximum backoff delay cap |
| TWENTY_RETRY_JITTER_MS | 100 | Random jitter added to each backoff interval |
Client-side idempotency
Create operations (createOne, createMany) support an idempotency_key parameter. When the same key is seen twice within 24 hours, the cached response is returned without hitting the API — preventing duplicate records on network retries.
companies(action: "createOne", idempotency_key: "onboard-acme-2026", requestBody: { name: "Acme" })
# Second call with same key → returns cached result, no duplicate created| Env var | Default | Description |
|---|---|---|
| TWENTY_IDEMPOTENCY_MAX_ENTRIES | 1000 | LRU cache capacity (entries evicted LRU when full) |
| TWENTY_IDEMPOTENCY_TTL_MS | 86400000 | Cache TTL in milliseconds (default 24h) |
Structured logging
All tool calls emit structured JSON logs to stderr (never stdout — keeps the MCP stdio transport clean). Sensitive fields (apiKey, token, password, secret, authorization) are redacted automatically.
LOG_LEVEL=debug # trace | debug | info | warn | error (default: info)Each log line includes tool, status, latency_ms, and retryCount for observability tooling.
OpenTelemetry tracing (optional)
No-op by default. Set OTEL_EXPORTER_OTLP_ENDPOINT to enable tracing — the server will dynamically import @opentelemetry/sdk-node and emit spans for every tool call. If the packages aren't installed, it falls back to no-op gracefully.
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318Install the OTel packages separately if you want traces:
npm install @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-httpOpenAPI spec drift CI
Three GitHub Actions workflows handle ongoing maintenance:
| Workflow | Trigger | What it does |
|---|---|---|
| ci.yml | Every push + PR | Build, typecheck, full test suite |
| check-spec-drift.yml | Daily + manual | SHA-256 diff of bundled spec vs live |
| spec-regen.yml | Daily 06:00 UTC | Fetches fresh specs, reruns regen, opens PR if changed |
| npm-publish.yml | Push to master (when package.json changes) or manual | Publishes to npm |
| docker-publish.yml | Push to master or v*.*.* tag | Builds + pushes multi-arch image to GHCR |
Required GitHub secrets: NPM_TOKEN, TWENTY_SPEC_API_KEY (a Twenty Cloud API key for spec regen).
Run the drift check locally:
TWENTY_API_URL=https://api.twenty.com/rest TWENTY_API_KEY=your_key npm run check:specRegenerating against a newer Twenty version
# Fetch both specs (requires TWENTY_API_URL and TWENTY_API_KEY in env)
npm run fetch:specs
# Regenerate core tools (regenerates src/index.ts — see "Local patches" below)
npm run regen
# Regenerate metadata tools (always safe to re-run, no patches needed)
npm run regen:metadata
npm run buildLocal patches on src/index.ts
After every npm run regen, re-apply these patches on top of the generated src/index.ts:
dotenvimport at the top.- Friendly env aliases + fail-fast + error handlers mapping
TWENTY_API_KEY→BEARER_TOKEN_BEARERAUTHandTWENTY_API_URL→API_BASE_URL. - TypeScript build fix — coerce
response.headers['content-type']withString(...). - MCP
instructionsstring documenting filter DSL, pagination, and API groups. - Metadata + GraphQL + file-upload imports and registration — the
importstatements formetadata-tools.js/graphql-tools.js/file-tools.js, snapshot ofrestToolDefinitions, the registration loops,executeGraphqlTool, and the multipartFormDatabranch inexecuteApiTool. - Entity-grouped tool layer —
restToolDefinitionssnapshot,inferAction,entityGroups,buildEntitySchema,list_twenty_capabilities, and the groupedListTools/CallToolhandlers.
src/metadata-tools.ts, src/graphql-tools.ts, src/file-tools.ts, src/filter-tools.ts, src/pagination-tools.ts, src/error-handler.ts, src/resources.ts, and src/prompts.ts survive regen untouched — only src/index.ts needs patching. Phase 4 of the roadmap automates this via a patches/ directory.
Configuration reference
| Env var | Required | Default | Description |
|---|---|---|---|
| TWENTY_API_KEY | yes | — | Workspace API key from Twenty Settings → Developers. Server refuses to start if unset. |
| TWENTY_API_URL | yes | — | Twenty REST base URL, must end in /rest. Metadata and GraphQL paths are derived from this. |
| BEARER_TOKEN_BEARERAUTH | — | — | Raw internal form; set TWENTY_API_KEY instead. |
| API_BASE_URL | — | — | Raw internal form; set TWENTY_API_URL instead. |
| TWENTY_TOOLS | — | all entities | Comma-separated entity names to expose (e.g. companies,people,notes). Reduces registered tools further. |
| TWENTY_MAX_RESPONSE | — | 102400 | Max response size in bytes before truncation. Increase if large payloads are being cut off. |
| TWENTY_OBJECTS_ALLOWLIST | — | all objects | Comma-separated object names to expose from the live spec (e.g. companies,people,deals). Applied on every refresh cycle. |
| TWENTY_SPEC_REFRESH_MS | — | 300000 (5 min) | How often to poll for spec changes. Set to 0 to disable polling. |
| TWENTY_OFFLINE | — | unset | Set to true to skip live spec fetching and always use the bundled openapi/twenty-core.json. Useful for airgapped environments. |
| TWENTY_TIMEOUT_MS | — | 30000 | Per-request timeout in milliseconds. |
| TWENTY_RETRY_BASE_MS | — | 100 | Exponential backoff base delay (ms). |
| TWENTY_RETRY_MAX_MS | — | 10000 | Maximum backoff delay cap (ms). |
| TWENTY_RETRY_JITTER_MS | — | 100 | Random jitter added to each retry delay (ms). |
| TWENTY_IDEMPOTENCY_MAX_ENTRIES | — | 1000 | LRU cache size for idempotency deduplication. |
| TWENTY_IDEMPOTENCY_TTL_MS | — | 86400000 | Idempotency cache TTL (ms, default 24h). |
| LOG_LEVEL | — | info | pino log level: trace, debug, info, warn, error. Logs go to stderr. |
| OTEL_EXPORTER_OTLP_ENDPOINT | — | unset | Set to enable OpenTelemetry tracing. Requires @opentelemetry/sdk-node installed. |
Project layout
.
├── examples/
│ ├── claude-desktop.json # Copy-paste config for Claude Desktop
│ ├── cursor.json # Copy-paste config for Cursor (~/.cursor/mcp.json)
│ ├── zed.json # Copy-paste config for Zed (.zed/settings.json)
│ └── n8n.json # Reference snippet for n8n MCP Tool node
├── openapi/
│ ├── twenty-core.json # Cached core REST spec (fetch:spec)
│ └── twenty-metadata.json # Cached metadata REST spec (fetch:spec:metadata)
├── scripts/
│ ├── gen-metadata-tools.js # Generator for src/metadata-tools.ts
│ └── check-spec-drift.js # SHA256 spec comparison for CI drift detection (v0.4)
├── src/
│ ├── index.ts # Core REST tools + patches + request handlers
│ ├── spec-loader.ts # ETag-aware spec fetch with disk fallback (v0.35)
│ ├── tool-registry.ts # Runtime tool registry with diff + health (v0.35)
│ ├── request-executor.ts # Axios request executor with retries + idempotency (v0.35/v0.4)
│ ├── retry-strategy.ts # Exponential backoff, isRetryable, Retry-After (v0.4)
│ ├── idempotency-cache.ts # LRU idempotency cache for create operations (v0.4)
│ ├── logger.ts # pino structured logger to stderr (v0.4)
│ ├── telemetry.ts # Optional OTel tracer hook (v0.4)
│ ├── metadata-tools.ts # Generated metadata tools (metadata_* prefix)
│ ├── graphql-tools.ts # Hand-written GraphQL escape-hatch tools
│ ├── file-tools.ts # Hand-written file upload tool (upload_file)
│ ├── filter-tools.ts # build_twenty_filter DSL helper (v0.3)
│ ├── pagination-tools.ts # find_all_* auto-pagination wrappers (v0.3)
│ ├── error-handler.ts # Structured error formatting + drift detection (v0.3/v0.35)
│ ├── resources.ts # MCP resource templates (v0.3)
│ └── prompts.ts # MCP guided workflow prompts (v0.3)
├── tests/ # Vitest test suite (260 tests)
├── build/ # Compiled JS output (gitignored)
├── Dockerfile # Multi-stage build for ghcr.io/drewling/twenty-mcp (v0.5)
├── smithery.yaml # Smithery registry manifest (v0.5)
├── .mcp-so.json # mcp.so registry manifest (v0.5)
├── .env.example # Template for local development
├── ROADMAP.md # Phase plan
└── package.jsonLicense
MIT.
