Um servidor Model Context Protocol que dá ao Claude uma janela deliberadamente pequena para o seu workspace do Attio: descoberta de objetos, consulta de registros, leitura de um registro individual e consulta de entradas de lista como operações de leitura, mais exatamente uma escrita que fica desligada até você ligar e restrita por atributo quando você liga. Seu time pergunta “quais empresas da lista de pipeline do Q3 estão sem owner?” no chat e recebe uma resposta estruturada, sem que um agente segure um botão capaz de reescrever o CRM. O scaffold fica no bundle de artefatos em apps/web/public/artifacts/mcp-server-attio-revops/ — um README.md, um pyproject.toml e src/attio_revops_mcp/server.py, instalável com pip install -e ..
Leia a próxima seção antes de construir qualquer coisa, porque o Attio já publica um desses.
Quando usar
O Attio hospeda o próprio MCP server em https://mcp.attio.com/mcp. Ele autentica via OAuth sem nenhuma chave para guardar ou rotacionar, expõe mais de 30 ferramentas entre registros, listas, comentários, notas, tarefas, reuniões, emails, workspace e reporting — mais uma ferramenta SQL —, aprova leituras automaticamente e pede confirmação antes de escrever. Para a maioria dos times essa é a resposta certa e este scaffold é trabalho jogado fora. Instale o servidor hospedado, conecte e siga em frente.
Construa o seu quando uma destas quatro condições for verdadeira.
Você precisa de uma identidade de service account em vez de identidade de usuário. O servidor hospedado roda com as permissões de Attio da pessoa logada. Se um agente compartilhado — plugado num bot do Slack, num job de reporting, num workflow que o time inteiro dispara — deve enxergar estritamente menos do que qualquer humano individual, não existe como expressar isso com uma concessão OAuth por usuário. Uma API key de workspace com um conjunto de scopes que você escolhe, existe.
Você precisa estreitar a superfície de ferramentas. Mais de 30 ferramentas, incluindo SQL e busca semântica de emails, é uma concessão ampla para um agente cujo trabalho real é responder perguntas de pipeline. Este scaffold dá cinco ferramentas ao Claude, e ATTIO_ALLOWED_OBJECTS limita até as leituras aos objetos que você nomear.
Você precisa de escritas restritas por atributo, não confirmadas por um humano. Um prompt de confirmação vale exatamente o quanto vale a atenção de quem lê às 16h de uma sexta-feira. ATTIO_WRITABLE_ATTRIBUTES recusa tudo que não estiver na lista, independentemente de quem clicou no quê.
Você precisa do log de chamadas na sua própria infraestrutura. Um processo local escreve onde você apontar.
Os dois papéis que extraem valor aqui são o líder de RevOps que quer responder perguntas de pipeline no mesmo chat onde acontece o resto da análise, e o GTM engineer que já subiu os servidores de Apollo e Salesforce desta série e quer a mesma postura majoritariamente-de-leitura em todo sistema de registro, para que os prompts continuem portáveis entre eles.
Quando NÃO usar
Você não tem motivo para recusar o servidor hospedado. Já está coberto acima, e vale repetir: o default é o servidor do próprio Attio. Este aqui é para os quatro casos em que uma concessão OAuth por usuário tem o formato errado.
Você está no Attio Free. O tier gratuito cobre até 3 usuários. Um workspace desse tamanho não tem problema de permissão de agente compartilhado — o servidor hospedado e seu fluxo OAuth encaixam perfeitamente.
Compliance proíbe registros do CRM num LLM de terceiros. Todo campo que uma consulta devolve entra na conversa: nomes, emails corporativos, valores de negócios, o que quer que seu time guarde como atributo. A lista de objetos permitidos encolhe esse conjunto; não o elimina. Se dados de contato não podem chegar a um LLM de jeito nenhum, nenhum MCP server sobre o seu CRM é o projeto certo.
O trabalho é uma limpeza em massa. Reatribuir 40 owners são 40 chamadas de ferramenta aqui, por design. Escreva um script contra a API do Attio, revise o diff e rode. O chat é a interface errada para um lote.
O que expõe
Cinco ferramentas, divididas pelo que podem alterar.
Descoberta:list_objects chama GET /v2/objects e devolve o api_slug de cada objeto, seus substantivos no singular e no plural, e se ele está dentro da sua allowlist. Slugs de objetos e atributos do Attio são específicos de cada workspace, então esta é a primeira chamada, não um chute.
Leituras de registros:query_records chama POST /v2/objects/{object}/records/query com um filtro do Attio e sorts opcionais; get_record chama GET /v2/objects/{object}/records/{record_id} e devolve a web_url do registro para um humano abrir.
Leituras de pipeline:query_list_entries chama POST /v2/lists/{list}/entries/query. As listas são onde o Attio guarda o estado do pipeline, então perguntas sobre estágio vão para lá e não para o objeto pai.
A única escrita:update_record_attribute chama PATCH /v2/objects/{object}/records/{record_id}. Um atributo, um registro, por chamada — condicionada a ATTIO_ALLOW_WRITES, a uma entrada {object}.{attribute} em ATTIO_WRITABLE_ATTRIBUTES, e a uma justificativa de pelo menos 10 caracteres.
Sem ferramenta de delete, sem update em massa, sem SQL e sem rota PUT.
Postura de engenharia
Quatro escolhas que vale entender antes de adotar o scaffold.
PATCH, nunca PUT. O Attio divide as atualizações de registro entre dois verbos: PATCH prepende valores em atributos multiselect, PUT sobrescreve e remove. Só o PATCH está ligado. A consequência é estrutural, não procedimental — este servidor não tem nenhum caminho de código capaz de apagar um valor multiselect existente, então o pior resultado de uma instrução mal interpretada é uma tag a mais, não uma tag deletada.
A resposta é enxugada antes de o modelo ver. O Attio devolve cada atributo como um array de objetos de valor carregando active_from, active_until e created_by_actor — o histórico completo daquele campo, não o estado atual. Entregar o formato bruto ao modelo multiplica várias vezes o custo em tokens para responder uma pergunta sobre hoje. _slim_record mantém as entradas em que active_until é null e reduz cada uma ao seu conteúdo.
O tamanho de página padrão é 25 contra os 500 do Attio. Os endpoints de consulta usam limit 500 por padrão. Esse é o default certo para um pipeline de dados e o errado para uma pergunta que quer dez linhas — 500 registros de dados pessoais aterrissam na janela de contexto e ficam lá pelo resto da conversa. Este scaffold usa 25 por padrão e recusa qualquer valor acima de 100.
As escritas são três comportas, e a justificativa não é uma delas. A flag de ambiente e a allowlist de atributos são o que de fato barra uma escrita; o texto de justificativa existe para o log. Confiar só na justificativa deixa a escrita a uma leitura equivocada e confiante de distância.
A realidade de custo
Três linhas, e o assento do CRM é a única grande.
Assentos do Attio. Free cobre até 3 usuários. Plus custa $35/usuário/mês na cobrança anual ($44 mensal), Pro custa $79/usuário/mês anual ($99 mensal), e Enterprise é só sob cotação — verificado na página de preços do Attio em 2026-07-31. O acesso à API não é um SKU separado.
Hospedar o servidor você mesmo. Um processo Python local por usuário do Claude Desktop não custa nada num notebook. Rodar como serviço compartilhado é uma VM pequena, $20-50/mês em qualquer nuvem (estimativa).
Tokens do Claude. O que você já paga — Claude Pro a $20/usuário/mês, tiers Max a $100-200/usuário/mês, ou consumo de API. Uma consulta enxugada de 25 registros fica na casa dos poucos milhares de tokens; aos $5 por milhão de tokens de entrada publicados do Claude Opus 5, um líder de RevOps fazendo 20-30 perguntas por semana soma bem menos de $1/usuário/mês em custo de API (estimativa — meça seus próprios payloads antes de orçar).
Throughput não é a restrição. A API REST do Attio permite 100 requisições de leitura e 25 de escrita por segundo, e o MCP server hospedado publica os mesmos tiers de leitura e escrita mais 300 buscas por minuto e 2 por segundo para busca semântica, reporting e SQL. Uma carga conversacional roda três ordens de grandeza abaixo disso. O limite que você vai encontrar de verdade é o baseado em pontuação dos endpoints de consulta, descrito nos pontos de atenção.
Como é o sucesso
O sinal mensurável em um mês: responder “o que mudou no pipeline esta semana e quem é dono dos buracos?” deixa de ser uma sequência de dez minutos abrindo o Attio, reconstruindo uma view, exportando e colando, e vira uma pergunta com resposta estruturada. O segundo sinal, mais difícil, é o que não acontece — ninguém concede ao agente um token mais amplo “só por enquanto”, porque as perguntas que as pessoas realmente fazem cabem em três objetos e quatro ferramentas de leitura.
Contra as alternativas
O MCP server hospedado do Attio. Mais ferramentas, sem infraestrutura, OAuth em vez de chave e confirmações antes de escrever. Você abre mão do scoping de service account, do controle de escrita por atributo e do seu próprio destino de auditoria. Este é o default; o scaffold é a exceção.
Um script descartável contra a API REST do Attio. Controle total, e cada time reconstrói do zero a autenticação bearer, a paginação, o achatamento do histórico de valores e o tratamento de 429. O scaffold são cerca de 400 linhas com as quatro coisas já resolvidas.
Uma plataforma no-code (Clay, n8n). O formato certo para pipelines agendados de enriquecimento e routing que você definiu de antemão. Problema diferente de uma pergunta pontual para a qual ninguém pré-construiu um flow. Use os dois: a plataforma para a cascata recorrente, isto para a conversa. Se o problema de fundo é que os próprios registros não são confiáveis, comece pela higiene de CRM em vez de uma ferramenta de consulta.
Pontos de atenção
Leituras amplas demais. Um query_records sem filtro contra people arrasta centenas de registros de contato para a conversa. Guarda: limit vem em 25 por padrão e é limitado a 100, ATTIO_ALLOWED_OBJECTS bloqueia os objetos que você não nomeou, e o parâmetro attributes descarta as colunas que você não pediu.
429 baseados em pontuação nas consultas. O Attio precifica cada consulta pela complexidade — sorts, filtros e o total de registros do objeto elevam a pontuação, e as pontuações somam numa janela deslizante de 10 segundos, então uma única consulta pesada pode ser recusada sozinha. Guarda: o scaffold captura o 429, expõe o Retry-After e devolve o conselho específico (estreite o filtro, tire o sort) em vez de um stack trace. Nada tenta de novo automaticamente; esse é o TODO #1 do README.
Excesso de scopes na criação da chave. O Attio fixa os scopes de uma integração quando a chave é criada e não deixa editar depois, o que empurra os times a conceder tudo de uma vez. Guarda: o README mapeia cada ferramenta aos seus scopes mínimos, e deixar as escritas desligadas significa nunca conceder record_permission:read-write.
Slugs de atributos desatualizados. Slugs de atributos são específicos de cada workspace e mudam quando alguém renomeia um campo, e aí todo prompt hardcoded quebra em silêncio. Guarda: list_objects é a primeira chamada documentada, e os erros nomeiam o slug do objeto que falhou.
Ativação silenciosa de escritas. Alguém liga o ATTIO_ALLOW_WRITES e esquece a allowlist. Guarda: um ATTIO_WRITABLE_ATTRIBUTES vazio recusa toda escrita independentemente da flag, então a direção da falha é “nada acontece”, não “qualquer coisa acontece”.
# mcp-server-attio-revops
A read-mostly MCP server over the [Attio](https://attio.com) REST API. Gives Claude four read tools — object discovery, record query, single-record fetch, list-entry query — and exactly one gated write, `update_record_attribute`, which is off by default and allowlisted per attribute. Built so a RevOps team can ask "which companies in the Q3 pipeline list have no owner set?" in chat without granting an agent the run of the CRM.
> **STATUS: scaffold — not runtime-tested.** The code follows the official `mcp` Python SDK conventions and the endpoint paths, scopes, and parameters track the public Attio API docs (docs.attio.com) as of July 2026, but it has not been executed against a live Attio workspace. Attribute slugs are workspace-specific and object configuration varies; verify against your own workspace before relying on it.
## Read this first: Attio ships an official hosted MCP server
Attio hosts its own MCP server at `https://mcp.attio.com/mcp`. It authenticates over OAuth (no key to store or rotate), exposes 30+ tools across nine areas — records, lists, comments, notes, tasks, meetings, emails, workspace, reporting — plus an SQL tool, auto-approves reads, and prompts for confirmation on writes. It is the right default for most teams, and it is less work than this.
Run this scaffold instead when one of the following is true:
- **You need a service-account identity, not a user identity.** The hosted server grants the signed-in user's own permissions. If a shared agent should see less than any individual human does, a workspace API key with a chosen scope set is the only way to express that.
- **You need to narrow the tool surface.** 30+ tools including SQL and semantic email search is a wide grant for an agent that only answers pipeline questions. Here you get five, and `ATTIO_ALLOWED_OBJECTS` bounds even the reads.
- **You need writes allowlisted per attribute.** Confirmation prompts depend on a human reading them. `ATTIO_WRITABLE_ATTRIBUTES` does not.
- **You need your own audit log.** A local process logs to your infrastructure.
If none of those apply, use the hosted server.
## What it exposes
### Reads
- `list_objects()` — `GET /v2/objects`. Returns each object's `api_slug`, nouns, and whether it is inside your allowlist. Call this before guessing a slug; Attio object and attribute slugs are per-workspace.
- `query_records(object, filter?, sorts?, limit=25, offset=0, attributes?)` — `POST /v2/objects/{object}/records/query`. The object must be in `ATTIO_ALLOWED_OBJECTS`. `limit` is clamped to 100 (Attio's own default is 500). Pass `attributes` to keep only the columns you care about.
- `get_record(object, record_id)` — `GET /v2/objects/{object}/records/{record_id}`. Returns the slimmed attribute map plus `web_url` so a human can open the record.
- `query_list_entries(list, filter?, sorts?, limit=25, offset=0)` — `POST /v2/lists/{list}/entries/query`. Lists are Attio's pipeline surface; use this rather than querying the parent object for stage questions.
### Write (gated)
- `update_record_attribute(object, record_id, attribute, value, justification)` — `PATCH /v2/objects/{object}/records/{record_id}`. Requires `ATTIO_ALLOW_WRITES=true`, a `{object}.{attribute}` entry in `ATTIO_WRITABLE_ATTRIBUTES`, and a justification of at least 10 characters. One attribute, one record, per call.
There is no delete tool, no bulk update, no SQL tool, and no `PUT` path. `PUT` is what overwrites and removes multiselect values; only `PATCH` is wired, and `PATCH` prepends. This server cannot erase an existing multiselect value.
## Setup
### 1. Install
```bash
git clone <wherever you put this>
cd mcp-server-attio-revops
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e .
```
### 2. Create an Attio API key
In Attio: **Workspace settings → Developers → create an integration**, then generate an access token for it. Scopes are chosen at creation and cannot be edited afterwards — to change them, create a new integration.
Grant the minimum for the tools you want:
| Tool | Scopes |
|---|---|
| `list_objects` | `object_configuration:read` |
| `query_records`, `get_record` | `record_permission:read`, `object_configuration:read` |
| `query_list_entries` | `list_entry:read`, `list_configuration:read` |
| `update_record_attribute` | `record_permission:read-write`, `object_configuration:read` |
If writes stay off — the default — do not grant `record_permission:read-write`. A read-only token means the write tool cannot fire even if someone flips `ATTIO_ALLOW_WRITES` by accident.
### 3. Configure environment
#### `ATTIO_API_KEY` (required)
The access token from step 2. Sent as `Authorization: Bearer <token>`. Store it in your OS keychain or secret manager, not in a dotfile that syncs.
#### `ATTIO_BASE_URL` (optional)
Defaults to `https://api.attio.com/v2`. Override only to point at a proxy.
#### `ATTIO_ALLOWED_OBJECTS` (recommended)
Comma-separated object `api_slug` values the agent may read. Defaults to `companies,people,deals`. Run `list_objects` first to see what your workspace actually has — custom objects holding contract terms, compensation, or investor notes are common in Attio, and they should not be in this list. An empty value disables the check; do not ship that.
#### `ATTIO_ALLOW_WRITES` (default `false`)
Master switch for `update_record_attribute`. Attio has no undo API. Leave it off unless you have decided, deliberately, that chat-driven CRM writes are acceptable.
#### `ATTIO_WRITABLE_ATTRIBUTES` (required if writes are on)
Comma-separated `object_slug.attribute_slug` pairs, e.g. `companies.lifecycle_stage,deals.owner`. Anything not listed is refused. Empty means no write is permitted regardless of `ATTIO_ALLOW_WRITES`.
### 4. Register with Claude
Claude Desktop — edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/`; Windows: `%APPDATA%\Claude\`):
```json
{
"mcpServers": {
"attio-revops": {
"command": "/absolute/path/to/mcp-server-attio-revops/.venv/bin/python",
"args": ["-m", "attio_revops_mcp.server"],
"env": {
"ATTIO_API_KEY": "your-token-here",
"ATTIO_ALLOWED_OBJECTS": "companies,people,deals",
"ATTIO_ALLOW_WRITES": "false"
}
}
}
}
```
Claude Code — from the repo root:
```bash
claude mcp add attio-revops -- /absolute/path/to/.venv/bin/python -m attio_revops_mcp.server
```
Then set the environment variables in the shell Claude Code inherits, or add them to the generated config.
### 5. Sanity check
Restart Claude, then ask, in order:
1. **"List the objects in my Attio workspace."** Exercises `list_objects` and confirms auth. A `403` here means the token lacks `object_configuration:read`.
2. **"Query 5 companies and show me their name and domain."** Exercises `query_records`, the object allowlist, and the slimming layer. If `values` comes back with attribute slugs you do not recognize, that is the workspace's real schema — note the slugs you care about.
3. **"Set the lifecycle stage on company X to Customer."** With writes off, this must refuse and name `ATTIO_ALLOW_WRITES`. If it succeeds, your config is not what you think it is.
## Security model
- **The token is a workspace credential, not a user credential.** Everything the agent reads is what the token's scopes allow, independent of who is chatting. Scope it down, and treat the key as production infrastructure.
- **Records reach the model as text.** Every field returned by `query_records` — names, emails, deal values, notes stored as attributes — enters the Claude conversation. `ATTIO_ALLOWED_OBJECTS` and the `attributes` parameter are the controls that keep that set small. If any object is off-limits for a third-party LLM, it must not be in the allowlist.
- **Writes are three-gated:** the env flag, the per-attribute allowlist, and the mandatory justification. The justification is for the audit trail, not the enforcement — the first two gates are what actually stop a write.
- **`PATCH` only, by construction.** Multiselect values can be added, never removed.
- **The credential never reaches the model.** It lives in the server process; Claude sees tool names and results.
## Known limits
None of the following are wired. Address them before any unattended or multi-user deployment:
1. **No retry or backoff on 429.** The error is surfaced with `Retry-After` and an explanation of Attio's score-based query limits, but nothing retries. Add exponential backoff if an agent will loop.
2. **No pagination loop.** `offset` is exposed; walking pages is left to the caller. This is deliberate — an automatic loop is how a "quick question" turns into ten thousand records of PII in the context window.
3. **No audit log.** Tool calls are not written anywhere. Wrap `call_tool` with structured logging to your own sink before this serves more than one person.
4. **`_simplify_value` probes rather than dispatches.** It checks common payload keys in order instead of switching on `attribute_type`. Rare attribute types fall through to a stripped object. Replace it with an explicit type map once you know which types your workspace uses.
5. **No tests.** `pytest` and `pytest-httpx` are in the dev extras and nothing uses them. Record fixtures from your workspace and pin the slimming behavior first.
6. **Single-record writes only.** Intentional, but it means a 40-record cleanup is 40 calls. Do bulk work with a script and Attio's own API, reviewed as a diff — not from chat.
"""
attio-revops-mcp — a read-mostly MCP server over the Attio REST API.
Exposes schema discovery, record query, single-record fetch, and list-entry query
as reads, plus one gated write (update_record_attribute). Every read is bounded by
an object allowlist and a page-size cap; the write is off unless ATTIO_ALLOW_WRITES
is set, restricted to an explicit attribute allowlist, and requires a justification.
This exists alongside Attio's own hosted MCP server at https://mcp.attio.com/mcp.
The hosted server authenticates the individual user over OAuth and grants that
user's full permissions across 30+ tools. This scaffold instead runs on a workspace
API key whose scope set you choose, and narrows what an agent can reach to the
objects and attributes you name. Use the hosted server when you want breadth; use
this when you want a small, auditable surface.
STATUS: scaffold — not runtime-tested. Endpoint paths, scopes, and parameters track
the public Attio API docs (docs.attio.com) as of 2026-07. Attribute slugs are
workspace-specific; verify against your own workspace before relying on it.
Run as: python -m attio_revops_mcp.server
"""
from __future__ import annotations
import json
import os
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
# ----- Configuration (read from env at startup) -----
ATTIO_API_KEY = os.environ.get("ATTIO_API_KEY")
ATTIO_BASE_URL = os.environ.get("ATTIO_BASE_URL", "https://api.attio.com/v2").rstrip("/")
# Objects the agent may touch at all, by api_slug. Attio workspaces routinely carry
# custom objects holding contract terms, comp data, or investor notes that have no
# business reaching an LLM. Empty means "no restriction", which you should not ship.
ATTIO_ALLOWED_OBJECTS = [
s.strip() for s in os.environ.get("ATTIO_ALLOWED_OBJECTS", "companies,people,deals").split(",") if s.strip()
]
# Writes are off unless explicitly enabled. Attio has no undo API — a wrong value
# written from chat is repaired by hand, record by record.
ATTIO_ALLOW_WRITES = os.environ.get("ATTIO_ALLOW_WRITES", "false").lower() == "true"
# Attributes the write tool may set, as "object_slug.attribute_slug" pairs. The
# hosted server can update any attribute the signed-in user can; this list is the
# reason to run your own.
ATTIO_WRITABLE_ATTRIBUTES = [
s.strip() for s in os.environ.get("ATTIO_WRITABLE_ATTRIBUTES", "").split(",") if s.strip()
]
# Attio's query endpoints default to limit=500. That is a large payload of personal
# data to hand a model for a question that usually wants ten rows.
MAX_LIMIT = 100
DEFAULT_LIMIT = 25
def require_config() -> None:
if not ATTIO_API_KEY:
raise RuntimeError("ATTIO_API_KEY env var is required")
def auth_headers() -> dict[str, str]:
# Attio authenticates with a standard bearer token, whether the credential is a
# workspace API key or an OAuth access token.
return {
"Authorization": f"Bearer {ATTIO_API_KEY}",
"Content-Type": "application/json",
}
def clamp_limit(value: Any) -> int:
try:
n = int(value)
except (TypeError, ValueError):
return DEFAULT_LIMIT
return max(1, min(n, MAX_LIMIT))
def check_object(slug: str) -> str:
if ATTIO_ALLOWED_OBJECTS and slug not in ATTIO_ALLOWED_OBJECTS:
raise PermissionError(
f"Object {slug!r} is not in ATTIO_ALLOWED_OBJECTS "
f"({', '.join(ATTIO_ALLOWED_OBJECTS)}). Add it deliberately if the agent should read it."
)
return slug
# ----- Attio REST helpers -----
async def attio_request(method: str, path: str, *, json_body: dict[str, Any] | None = None) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.request(
method, f"{ATTIO_BASE_URL}{path}", headers=auth_headers(), json=json_body
)
_raise_for_attio(r)
return r.json() if r.content else {}
def _raise_for_attio(r: httpx.Response) -> None:
if r.status_code == 403:
raise PermissionError(
"Attio returned 403. The token is missing a scope this call needs. Reads need "
"record_permission:read, object_configuration:read, list_entry:read, and "
"list_configuration:read; the write tool additionally needs "
"record_permission:read-write. Scopes are fixed when the key is created — "
"generate a new one in workspace settings rather than editing this one."
)
if r.status_code == 429:
retry_after = r.headers.get("Retry-After", "unknown")
raise RuntimeError(
f"Attio returned 429 (rate limit); Retry-After: {retry_after}s. The record and "
"list query endpoints price each request by complexity — sorts, filters, and the "
"object's total record count all raise the score, and scores are summed over a "
"10-second sliding window. Narrow the filter or drop the sort and retry."
)
r.raise_for_status()
# ----- Server + tool registry -----
server = Server("attio-revops")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="list_objects",
description=(
"List the objects configured in the workspace (GET /v2/objects) so you can "
"discover api_slug values before querying. Attribute slugs are workspace-"
"specific; never guess one. Read-only."
),
inputSchema={"type": "object", "properties": {}},
),
Tool(
name="query_records",
description=(
"Query records of one object (POST /v2/objects/{object}/records/query). "
"Read-only. Pass an Attio filter object and optional sorts. Results are "
"slimmed to the currently-active value per attribute. Capped at 100 rows "
"per call; defaults to 25."
),
inputSchema={
"type": "object",
"properties": {
"object": {
"type": "string",
"description": "Object api_slug or UUID, e.g. 'companies'.",
},
"filter": {
"type": "object",
"description": "Attio filter object, e.g. {'name': {'$contains': 'Acme'}}.",
},
"sorts": {
"type": "array",
"items": {"type": "object"},
"description": "Each entry takes direction, attribute, and optional field.",
},
"limit": {"type": "integer", "default": DEFAULT_LIMIT},
"offset": {"type": "integer", "default": 0},
"attributes": {
"type": "array",
"items": {"type": "string"},
"description": "Attribute slugs to keep in the response. Omit for all.",
},
},
"required": ["object"],
},
),
Tool(
name="get_record",
description=(
"Fetch one record by id (GET /v2/objects/{object}/records/{record_id}). "
"Read-only. Returns the slimmed attribute map plus the record's web_url so "
"a human can open it in Attio."
),
inputSchema={
"type": "object",
"properties": {
"object": {"type": "string"},
"record_id": {"type": "string", "description": "Record UUID."},
},
"required": ["object", "record_id"],
},
),
Tool(
name="query_list_entries",
description=(
"Query entries on a list (POST /v2/lists/{list}/entries/query). Read-only. "
"A list is Attio's pipeline surface — use this for 'what is in stage X' "
"questions rather than querying the parent object. Capped at 100 entries."
),
inputSchema={
"type": "object",
"properties": {
"list": {"type": "string", "description": "List api_slug or UUID."},
"filter": {"type": "object"},
"sorts": {"type": "array", "items": {"type": "object"}},
"limit": {"type": "integer", "default": DEFAULT_LIMIT},
"offset": {"type": "integer", "default": 0},
},
"required": ["list"],
},
),
Tool(
name="update_record_attribute",
description=(
"Set ONE attribute on ONE record (PATCH /v2/objects/{object}/records/"
"{record_id}). A write. Disabled unless ATTIO_ALLOW_WRITES=true, restricted "
"to ATTIO_WRITABLE_ATTRIBUTES, and requires a justification of at least 10 "
"characters. PATCH is used deliberately: it prepends to multiselect values "
"rather than replacing them, so this tool cannot erase existing values."
),
inputSchema={
"type": "object",
"properties": {
"object": {"type": "string"},
"record_id": {"type": "string"},
"attribute": {
"type": "string",
"description": "Attribute api_slug, e.g. 'owner' or 'lifecycle_stage'.",
},
"value": {
"description": "Scalar for single-value attributes, array for multiselect."
},
"justification": {"type": "string", "minLength": 10},
},
"required": ["object", "record_id", "attribute", "value", "justification"],
},
),
]
# ----- Tool dispatch -----
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
if name == "list_objects":
data = await attio_request("GET", "/objects")
rows = [
{
"api_slug": o.get("api_slug"),
"singular_noun": o.get("singular_noun"),
"plural_noun": o.get("plural_noun"),
"readable": (not ATTIO_ALLOWED_OBJECTS) or o.get("api_slug") in ATTIO_ALLOWED_OBJECTS,
}
for o in data.get("data", [])
]
return [TextContent(type="text", text=json.dumps({"objects": rows}, indent=2))]
if name == "query_records":
obj = check_object(arguments["object"])
body: dict[str, Any] = {
"limit": clamp_limit(arguments.get("limit", DEFAULT_LIMIT)),
"offset": int(arguments.get("offset", 0)),
}
if v := arguments.get("filter"):
body["filter"] = v
if v := arguments.get("sorts"):
body["sorts"] = v
data = await attio_request("POST", f"/objects/{obj}/records/query", json_body=body)
keep = arguments.get("attributes")
rows = [_slim_record(rec, keep) for rec in data.get("data", [])]
return [
TextContent(
type="text",
text=json.dumps({"object": obj, "returned": len(rows), "records": rows}, indent=2),
)
]
if name == "get_record":
obj = check_object(arguments["object"])
record_id = arguments["record_id"]
data = await attio_request("GET", f"/objects/{obj}/records/{record_id}")
return [TextContent(type="text", text=json.dumps(_slim_record(data.get("data", {}), None), indent=2))]
if name == "query_list_entries":
list_ref = arguments["list"]
body = {
"limit": clamp_limit(arguments.get("limit", DEFAULT_LIMIT)),
"offset": int(arguments.get("offset", 0)),
}
if v := arguments.get("filter"):
body["filter"] = v
if v := arguments.get("sorts"):
body["sorts"] = v
data = await attio_request("POST", f"/lists/{list_ref}/entries/query", json_body=body)
rows = [_slim_entry(e) for e in data.get("data", [])]
return [
TextContent(
type="text",
text=json.dumps({"list": list_ref, "returned": len(rows), "entries": rows}, indent=2),
)
]
if name == "update_record_attribute":
justification = (arguments.get("justification") or "").strip()
if len(justification) < 10:
raise ValueError("justification is mandatory and must be at least 10 characters.")
if not ATTIO_ALLOW_WRITES:
raise PermissionError(
"update_record_attribute is disabled. Set ATTIO_ALLOW_WRITES=true to allow "
"chat-driven CRM writes, and list the permitted attributes in "
"ATTIO_WRITABLE_ATTRIBUTES."
)
obj = check_object(arguments["object"])
attribute = arguments["attribute"]
qualified = f"{obj}.{attribute}"
if qualified not in ATTIO_WRITABLE_ATTRIBUTES:
raise PermissionError(
f"{qualified!r} is not in ATTIO_WRITABLE_ATTRIBUTES "
f"({', '.join(ATTIO_WRITABLE_ATTRIBUTES) or 'empty'}). Writes are allowlisted "
"per attribute, not per object."
)
record_id = arguments["record_id"]
body = {"data": {"values": {attribute: arguments["value"]}}}
data = await attio_request(
"PATCH", f"/objects/{obj}/records/{record_id}", json_body=body
)
web_url = (data.get("data") or {}).get("web_url")
return [
TextContent(
type="text",
text=(
f"Set {qualified} on record {record_id} ({justification!r}). "
f"Multiselect values were prepended, not replaced. Open in Attio: {web_url}"
),
)
]
raise ValueError(f"Unknown tool: {name}")
# ----- Response slimming (keep model payloads tractable) -----
def _slim_record(rec: dict[str, Any], keep: list[str] | None) -> dict[str, Any]:
"""Reduce Attio's value-history shape to one current value per attribute.
Attio returns every attribute as an array of value objects carrying active_from,
active_until, created_by_actor, and type-specific fields — the full history, not
just the present. Handing that to a model multiplies token cost several times over
for information nobody asked for. We keep the entries where active_until is null.
"""
values = rec.get("values", {}) or {}
out: dict[str, Any] = {}
for slug, entries in values.items():
if keep and slug not in keep:
continue
if not isinstance(entries, list):
continue
current = [e for e in entries if isinstance(e, dict) and e.get("active_until") is None]
simplified = [_simplify_value(e) for e in current]
if not simplified:
continue
out[slug] = simplified[0] if len(simplified) == 1 else simplified
ids = rec.get("id", {}) or {}
return {
"record_id": ids.get("record_id"),
"web_url": rec.get("web_url"),
"created_at": rec.get("created_at"),
"values": out,
}
def _simplify_value(entry: dict[str, Any]) -> Any:
"""Pull the human-meaningful field out of one Attio value object.
Attio's value shape is discriminated by `attribute_type`, and each type puts its
payload under a different key. Rather than enumerate every type, we probe the
common carriers in order and fall back to the stripped object.
"""
for key in (
"value",
"full_name",
"email_address",
"phone_number",
"domain",
"status",
"option",
"target_record_id",
"referenced_actor_name",
"currency_value",
):
if key in entry and entry[key] is not None:
v = entry[key]
if isinstance(v, dict):
return v.get("title") or v.get("name") or v
return v
return {k: v for k, v in entry.items() if k not in ("active_from", "active_until", "created_by_actor")}
def _slim_entry(entry: dict[str, Any]) -> dict[str, Any]:
ids = entry.get("id", {}) or {}
parent = entry.get("parent_record_id")
values = entry.get("entry_values", entry.get("values", {})) or {}
out = {}
for slug, entries in values.items():
if isinstance(entries, list) and entries:
current = [e for e in entries if isinstance(e, dict) and e.get("active_until") is None]
if current:
out[slug] = _simplify_value(current[0])
return {
"entry_id": ids.get("entry_id"),
"parent_record_id": parent,
"parent_object": entry.get("parent_object"),
"created_at": entry.get("created_at"),
"values": out,
}
# ----- Entrypoint -----
async def main() -> None:
require_config()
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())