Un serveur Model Context Protocol qui donne à Claude une fenêtre délibérément étroite sur votre workspace Attio : découverte des objets, requête sur les records, lecture d’un record isolé et requête sur les entrées de liste côté lecture, plus exactement une écriture, désactivée tant que vous ne l’activez pas et restreinte par attribut quand vous le faites. Votre équipe demande dans le chat « quelles entreprises de la liste pipeline Q3 n’ont pas d’owner ? » et obtient une réponse structurée, sans qu’un agent tienne un bouton capable de réécrire le CRM. Le scaffold vit dans le bundle d’artefacts sous apps/web/public/artifacts/mcp-server-attio-revops/ — un README.md, un pyproject.toml et src/attio_revops_mcp/server.py, installable avec pip install -e ..
Lisez la section suivante avant de construire quoi que ce soit, parce qu’Attio en publie déjà un.
Quand l’utiliser
Attio héberge son propre MCP server sur https://mcp.attio.com/mcp. Il s’authentifie en OAuth sans clé à stocker ni à faire tourner, expose plus de 30 outils couvrant records, listes, commentaires, notes, tâches, réunions, emails, workspace et reporting — plus un outil SQL —, approuve automatiquement les lectures et demande confirmation avant d’écrire. Pour la plupart des équipes c’est la bonne réponse et ce scaffold est du travail perdu. Installez le serveur hébergé, connectez-le, passez à la suite.
Construisez le vôtre quand l’une de ces quatre conditions est vraie.
Vous avez besoin d’une identité de compte de service plutôt que d’une identité utilisateur. Le serveur hébergé tourne avec les permissions Attio de la personne connectée. Si un agent partagé — branché sur un bot Slack, un job de reporting, un workflow que toute l’équipe déclenche — doit voir strictement moins que n’importe quel humain pris individuellement, aucune autorisation OAuth par utilisateur ne permet de l’exprimer. Une clé API de workspace avec un jeu de scopes que vous choisissez, si.
Vous avez besoin de réduire la surface d’outils. Plus de 30 outils, dont SQL et la recherche sémantique sur les emails, c’est une autorisation large pour un agent dont le vrai travail consiste à répondre à des questions de pipeline. Ce scaffold donne cinq outils à Claude, et ATTIO_ALLOWED_OBJECTS borne même les lectures aux objets que vous nommez.
Vous avez besoin d’écritures autorisées par attribut, pas confirmées par un humain. Une fenêtre de confirmation vaut exactement l’attention de celui qui la lit un vendredi à 16 heures. ATTIO_WRITABLE_ATTRIBUTES refuse tout ce qui n’est pas sur la liste, quel que soit le clic de qui que ce soit.
Vous avez besoin du journal des appels dans votre propre infrastructure. Un process local écrit là où vous le pointez.
Les deux rôles qui en tirent de la valeur : le responsable RevOps qui veut traiter les questions de pipeline dans le même chat que le reste de l’analyse, et le GTM engineer qui a déjà déployé les serveurs Apollo et Salesforce de cette série et veut la même posture majoritairement en lecture sur chaque système de référence, pour que les prompts restent portables de l’un à l’autre.
Quand NE PAS l’utiliser
Vous n’avez aucune raison de refuser le serveur hébergé. C’est traité plus haut, et cela mérite d’être répété : le défaut, c’est le serveur d’Attio. Celui-ci est pour les quatre cas où une autorisation OAuth par utilisateur a la mauvaise forme.
Vous êtes sur Attio Free. Le tier gratuit couvre jusqu’à 3 utilisateurs. Un workspace de cette taille n’a pas de problème de permissions d’agent partagé — le serveur hébergé et son flux OAuth lui vont exactement.
La conformité interdit les records CRM dans un LLM tiers. Chaque champ renvoyé par une requête entre dans la conversation : noms, emails professionnels, montants de deals, tout ce que votre équipe stocke en attribut. La liste d’objets autorisés réduit cet ensemble ; elle ne l’élimine pas. Si les données de contact ne peuvent pas atteindre un LLM du tout, aucun MCP server posé sur votre CRM n’est le bon projet.
Le travail est un nettoyage de masse. Réattribuer 40 owners, ce sont 40 appels d’outil ici, par construction. Écrivez un script contre l’API Attio, relisez le diff, exécutez-le. Le chat est la mauvaise interface pour un lot.
Ce qu’il expose
Cinq outils, répartis selon ce qu’ils peuvent modifier.
Découverte :list_objects appelle GET /v2/objects et renvoie pour chaque objet son api_slug, ses noms au singulier et au pluriel, et s’il figure dans votre allowlist. Les slugs d’objets et d’attributs Attio sont propres à chaque workspace, donc c’est le premier appel, pas une supposition.
Lectures de records :query_records appelle POST /v2/objects/{object}/records/query avec un filtre Attio et des sorts optionnels ; get_record appelle GET /v2/objects/{object}/records/{record_id} et renvoie la web_url du record pour qu’un humain puisse l’ouvrir.
Lectures de pipeline :query_list_entries appelle POST /v2/lists/{list}/entries/query. Les listes sont l’endroit où Attio range l’état du pipeline, donc les questions de stage vont là plutôt que vers l’objet parent.
L’unique écriture :update_record_attribute appelle PATCH /v2/objects/{object}/records/{record_id}. Un attribut, un record, par appel — conditionné à ATTIO_ALLOW_WRITES, à une entrée {object}.{attribute} dans ATTIO_WRITABLE_ATTRIBUTES, et à une justification d’au moins 10 caractères.
Pas d’outil de suppression, pas de mise à jour en masse, pas de SQL et pas de chemin PUT.
Posture d’ingénierie
Quatre choix à comprendre avant d’adopter le scaffold.
PATCH, jamais PUT. Attio répartit les mises à jour de record sur deux verbes : PATCH place les valeurs en tête des attributs multiselect, PUT les écrase et les supprime. Seul PATCH est câblé. La conséquence est structurelle plutôt que procédurale — ce serveur n’a aucun chemin de code capable d’effacer une valeur multiselect existante, donc le pire résultat d’une instruction mal lue est un tag en trop, pas un tag supprimé.
La réponse est allégée avant que le modèle la voie. Attio renvoie chaque attribut sous forme de tableau d’objets de valeur portant active_from, active_until et created_by_actor — l’historique complet du champ, pas son état actuel. Livrer la forme brute au modèle multiplie plusieurs fois le coût en tokens pour répondre à une question portant sur aujourd’hui. _slim_record conserve les entrées dont active_until vaut null et réduit chacune à son contenu.
La taille de page par défaut est 25 face aux 500 d’Attio. Les endpoints de requête mettent limit à 500 par défaut. C’est le bon défaut pour un pipeline de données et le mauvais pour une question qui veut dix lignes — 500 records de données personnelles atterrissent dans la fenêtre de contexte et y restent pour le reste de la conversation. Ce scaffold prend 25 par défaut et refuse tout ce qui dépasse 100.
Les écritures sont trois verrous, et la justification n’en est pas un. Le flag d’environnement et l’allowlist d’attributs sont ce qui arrête réellement une écriture ; le texte de justification existe pour le journal. Se fier à la seule justification laisse l’écriture à une mauvaise lecture assurée de distance.
La réalité des coûts
Trois lignes, et le siège CRM est la seule grosse.
Sièges Attio. Free couvre jusqu’à 3 utilisateurs. Plus est à $35/utilisateur/mois en facturation annuelle ($44 en mensuel), Pro à $79/utilisateur/mois en annuel ($99 en mensuel), et Enterprise est sur devis — vérifié sur la page de prix d’Attio le 2026-07-31. L’accès API n’est pas un SKU séparé.
Héberger le serveur vous-même. Un process Python local par utilisateur de Claude Desktop ne coûte rien sur un portable. En service partagé, c’est une petite VM, $20-50/mois sur n’importe quel cloud (estimation).
Tokens Claude. Ce que vous payez déjà — Claude Pro à $20/utilisateur/mois, tiers Max à $100-200/utilisateur/mois, ou consommation API. Une requête allégée de 25 records tombe dans les quelques milliers de tokens ; aux $5 par million de tokens d’entrée publiés pour Claude Opus 5, un responsable RevOps posant 20-30 questions par semaine ajoute nettement moins de $1/utilisateur/mois de coût API (estimation — mesurez vos propres payloads avant de budgéter).
Le débit n’est pas la contrainte. L’API REST d’Attio autorise 100 requêtes de lecture et 25 d’écriture par seconde, et son MCP server hébergé publie les mêmes tiers de lecture et d’écriture plus 300 recherches par minute et 2 par seconde pour la recherche sémantique, le reporting et SQL. Une charge conversationnelle tourne trois ordres de grandeur en dessous. La limite que vous rencontrerez vraiment est celle, à base de score, des endpoints de requête, décrite dans les points de vigilance.
À quoi ressemble le succès
Le signal mesurable au bout d’un mois : répondre à « qu’est-ce qui a bougé dans le pipeline cette semaine et qui possède les trous ? » cesse d’être une séquence de dix minutes — ouvrir Attio, reconstruire une vue, exporter, coller — pour devenir une question et une réponse structurée. Le second signal, plus difficile, est ce qui n’arrive pas : personne n’accorde à l’agent un token plus large « juste pour l’instant », parce que les questions réellement posées tiennent dans trois objets et quatre outils de lecture.
Face aux alternatives
Le MCP server hébergé d’Attio. Plus d’outils, aucune infrastructure, OAuth au lieu d’une clé et des confirmations avant écriture. Vous abandonnez le scoping en compte de service, le contrôle d’écriture par attribut et votre propre destination d’audit. C’est le défaut ; le scaffold est l’exception.
Un script jetable contre l’API REST d’Attio. Contrôle total, et chaque équipe reconstruit l’authentification bearer, la pagination, l’aplatissement de l’historique de valeurs et la gestion du 429. Le scaffold fait environ 400 lignes avec les quatre déjà en place.
Une plateforme no-code (Clay, n8n). La bonne forme pour des pipelines planifiés d’enrichissement et de routing que vous avez définis à l’avance. Problème différent d’une question ponctuelle pour laquelle personne n’a préconstruit de flow. Faites tourner les deux : la plateforme pour la cascade récurrente, ceci pour la conversation. Si le problème de fond est que les records eux-mêmes ne sont pas fiables, commencez par l’hygiène du CRM plutôt que par un outil de requête.
Points de vigilance
Lectures trop larges. Un query_records sans filtre contre people tire des centaines de records de contact dans la conversation. Garde-fou : limit vaut 25 par défaut et est plafonné à 100, ATTIO_ALLOWED_OBJECTS bloque les objets que vous n’avez pas nommés, et le paramètre attributes écarte les colonnes que vous n’avez pas demandées.
429 à base de score sur les requêtes. Attio tarife chaque requête selon sa complexité — les sorts, les filtres et le nombre total de records de l’objet font monter le score, et les scores se cumulent sur une fenêtre glissante de 10 secondes, si bien qu’une seule requête lourde peut être refusée à elle seule. Garde-fou : le scaffold intercepte le 429, expose le Retry-After et renvoie le conseil précis (resserrer le filtre, retirer le sort) au lieu d’une stack trace. Rien ne réessaie automatiquement ; c’est le TODO #1 du README.
Scopes trop larges à la création de la clé. Attio fige les scopes d’une intégration à la création de la clé et ne permet pas de les modifier ensuite, ce qui pousse les équipes à tout accorder d’un coup. Garde-fou : le README associe chaque outil à ses scopes minimaux, et laisser les écritures désactivées revient à ne jamais accorder record_permission:read-write.
Slugs d’attributs périmés. Les slugs d’attributs sont propres à chaque workspace et changent quand quelqu’un renomme un champ, après quoi chaque prompt en dur casse en silence. Garde-fou : list_objects est le premier appel documenté, et les erreurs nomment le slug d’objet qui a échoué.
Activation silencieuse des écritures. Quelqu’un bascule ATTIO_ALLOW_WRITES et oublie l’allowlist. Garde-fou : un ATTIO_WRITABLE_ATTRIBUTES vide refuse toute écriture quel que soit le flag, donc la direction de l’échec est « rien ne se passe », pas « n’importe quoi se passe ».
Stack
Attio — CRM : objets, records, listes, attributs
MCP Python SDK — le paquet mcp>=1.2.0 ; fournit Server, stdio_server et les décorateurs du registre d’outils
httpx — client REST asynchrone contre api.attio.com/v2, authentifié avec Authorization: Bearer
Claude Desktop ou Claude Code — interface en langage naturel, appelant d’outils
ATTIO_ALLOWED_OBJECTS et ATTIO_WRITABLE_ATTRIBUTES — les deux listes qui décident de ce que l’agent peut lire et de ce qu’il peut modifier
# 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())