MCP server
Give Claude Code, Cursor, Codex or any MCP client read access to a Capa project with @capacms/mcp.
This page is for giving an AI coding assistant (Claude Code, Codex, Cursor or any MCP client) access to a Capa project, so it can answer "what content is there?" and write the query your page needs, checked against real data.
The assistant talks to Capa through the Capa MCP server, a small stdio server that reads with your key, sees exactly what the key sees, and never writes content.
What it needs
The server is the npm package @capacms/mcp, and your client starts it with
npx -y @capacms/mcp. It needs Node 20.3 or later and nothing else: it has no
dependencies, so npx fetches the package and runs it with nothing else to
install.
Set it up
- Mint a read key in the Capa admin under Developers > Keys. A key that reads one model is enough to work on that model; see Keys and scopes. Mint a production key for work on what your site shows, and for any code the assistant writes that a site will ship: it reads published entries only, exactly what visitors see. Mint a development key only when the assistant should see unpublished work too, such as checking a draft before it goes live or building a preview. Every answer it gets then says it includes drafts and unpublished changes, and the code it writes still reads with whatever key your site uses.
- Keep the key in your shell or your secret store, never in a file you commit.
- Register the server with your client.
Claude Code:
claude mcp add capa \
-e CAPA_API_URL=https://cdn.capacms.com \
-e CAPA_KEY="$CAPA_KEY" \
-e CAPA_API_VERSION=2026-10-01 \
-- npx -y @capacms/mcpCursor, in ~/.cursor/mcp.json. Use the file in your home folder, not the
project's .cursor/mcp.json, so the key stays out of the repository:
{
"mcpServers": {
"capa": {
"command": "npx",
"args": ["-y", "@capacms/mcp"],
"env": {
"CAPA_API_URL": "https://cdn.capacms.com",
"CAPA_KEY": "cap_live_...",
"CAPA_API_VERSION": "2026-10-01"
}
}
}
}Codex, in ~/.codex/config.toml:
[mcp_servers.capa]
command = "npx"
args = ["-y", "@capacms/mcp"]
env = { CAPA_API_URL = "https://cdn.capacms.com", CAPA_KEY = "cap_live_...", CAPA_API_VERSION = "2026-10-01" }Any other MCP client takes the same command and the same variables in its own config.
CAPA_API_URL and CAPA_KEY are the names every Capa tool reads
(@capacms/sdk/nextjs, capa-codegen and the SDK code the assistant writes),
so one .env serves your app and the assistant. CAPA_BASE_URL and
CAPA_API_KEY still work as aliases. CAPA_API_VERSION is optional. A legacy
key (pk_, sk_, or an older key with no prefix) also needs
CAPA_TENANT_ID; a cap_ key does not.
The server needs CAPA_API_URL and CAPA_KEY to start. Without them it exits
at once and names the missing variable, rather than starting with nothing to
offer.
What the assistant can do
Five tools do the querying, and their names are final:
| Ask it to | Tool it uses |
|---|---|
| list the models and fields it can read, or show the GraphQL schema | capa_graphql_schema |
| show what the content holds: counts, ranges, the values a field takes | capa_explore_data |
| write a query for a goal, as GraphQL, a REST URL and SDK code | capa_graphql_build |
| run a GraphQL query | capa_graphql_query |
| explain an API error from a log or a failed call | capa_explain_error |
The REST URL an assistant hands you is written exactly as the API writes it
in extensions.capa.rest, the filter as where JSON, so it matches what the
GraphQL Explorer shows for the same query. The SDK code comes when the
assistant asks for it with code, once the query is right, in two forms:
graphql() from @capacms/sdk/nextjs for a Next.js server component
(code: "next"), and createClient from @capacms/sdk/next for anywhere
else (code: "node"); code: "all" gives both and the REST read. Both read with a
cap_ key and with the legacy key a site already holds: pk_, sk_, or an
older key with no prefix. For a legacy key the code comes with a note: the
SDK warns once about that key, and draft previews need a cap_ key, minted
under Developers > Keys.
capa_explore_data counts what is stored, the way a filter does: its
missing count is what { null: true } matches. It also says how many
references point at nothing a query returns, and how many values do not fit
their field's type, since a query returns those as null.
Some keys get more tools:
- Where the deployment serves pages, a key that reads
every model gets
capa_list_pages,capa_get_pageandcapa_suggest_queries, whichever key family it is. Acap_key needs the unscopedinstance:readfor them. - A legacy key also gets the older tools for models, content, search, generated types, editor layouts and workspaces.
The legacy and page tools are described, with worked examples for the page tools, in the server's README.
Every tool is marked as reading or writing (MCP tool annotations), so a client
can run the reads without asking and ask you before a write. Only two tools
write, and neither touches content: capa_set_model_layout changes a model's
editor layout and capa_set_workspace changes a workspace of the admin's
rail. Both are offered to legacy keys only, and the API refuses them to a key
without the permission: agent for a layout, write for a workspace. The five
query tools also return their answers as structured content, with an output
schema.
The server also offers two resources, one resource template and three prompts, each only when the key has every tool it uses:
| Resource | What it holds |
|---|---|
capa://graphql/schema.graphql | the whole GraphQL schema the key can read, as SDL, for a client that attaches a schema as context; the list gives its size |
capa://graphql/schema/{model}.graphql | one model's SDL, by namespace or type name, e.g. capa://graphql/schema/articles.graphql |
capa://guide/querying | the order to call the tools in, the filter and sort grammar, and the limits |
The prompts are explore-content, write-query and fix-query-error.
Tool reference
One call each, with what it answered on the sample project (a production key,
answers shortened where they show ...). The assistant picks these itself;
the calls show what it can ask for.
capa_graphql_schema lists what the key reads; with model, one model's
filters, sorts and a runnable example:
{"model":"authors"}
{"model":"authors","type":"Authors","name":"Author","graphql":{"list":"authors","single":"author","filter":"AuthorsFilter","sort":"AuthorsSort"},"rest":"/api/entries/authors",
"fields":[{"name":"name","type":"String","capa":"string","filter":["eq","ne","in","nin","contains","startsWith","endsWith","exists","null"],"sortable":true},...],
"sort":["createdAt_ASC",...,"name_ASC","name_DESC","bio_ASC","bio_DESC"],"example":{"query":"query AuthorsList($first: Int) {...}","variables":{"first":5},...},
"system":"Every model type also has id, model, status, createdAt, updatedAt, publishedAt, _version, _tags, _folder. Filters and sorts take ..."}capa_explore_data measures what the content holds:
{"model":"articles","field":"featured","sample":0}
{"model":"articles","environment":"production","total":13,"scanned":13,"statuses":{"published":13},"counts":"Counts are of what is stored, ...",
"fields":[{"field":"featured","type":"true_false","present":13,"empty":0,"missing":0,"true":6,"false":7}],"samples":[]}capa_graphql_build writes a query from a goal, runs it once, and hands back
GraphQL, the REST request and, with code, SDK code:
{"model":"articles","fields":["title",{"field":"author","fields":["name"]}],"filter":{"featured":{"eq":true}},"first":1,"run":true,"code":"all"}
{"query":"query ArticlesList($first: Int, $filter: ArticlesFilter) {...}","variables":{"first":1,"filter":{"featured":{"eq":true}}},"operationName":"ArticlesList",
"rest":"/api/entries/articles?select=title,author(name)&where=%7B%22featured%22:%7B%22eq%22:true%7D%7D&limit=1","sdk":{...},"environment":"production",
"result":{"data":{...},"cost":{"depth":3,"rootFields":1,"connections":1,"nodesBound":2,"scans":1,"nodes":2,"fields":12},"budget":{"counted":502,"limit":5000}}}capa_graphql_query runs any read:
{"query":"{ authors(first: 2, sort: [name_ASC]) { nodes { name } } }"}
{"data":{"authors":{"nodes":[{"name":"Ada Vale"},{"name":"Brin Cole"}]}},"cost":{...},"budget":{"counted":502,"limit":5000},"rest":[{"field":"authors","url":"/api/entries/authors?select=name&sort=name&limit=2"}],"environment":"production"}budget is what the API counted against its 5,000-entry limit before it read:
sorting by a field of the list's own model costs 500, so two names count 502.
A read that used a deprecated field also gets deprecations, each with its
coordinate and the reason.
capa_explain_error explains an error, or just its code, without calling
Capa:
{"code":"invalid_cursor"}
{"errors":[{"code":"invalid_cursor","meaning":"The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry.",
"fix":"Pass a cursor from a page of the same query, unchanged, with the same sort: its endCursor as after, or its startCursor as before.","next":"capa_graphql_query","docs":"https://docs.capacms.com/errors/invalid_cursor"}]}capa_read_entries reads over REST, with REST's parameters. It takes the
GraphQL tools' place where GraphQL is off, and sits beside them for the models
GraphQL leaves out because their type names collide (capa_graphql_schema
lists those as readable over REST only). The sample project leaves no model
out, so this call runs there only where GraphQL is off:
{"model":"authors","select":"name","sort":["name"],"limit":1}
{"data":[{"id":"00000000-0000-4000-8000-000000000011","model":"authors","status":"published","fields":{"name":"Ada Vale"}}],
"page":{"limit":1,"hasNext":true,"next":"c1.eyJ2...","hasPrev":false,"prev":null},"environment":"production","rest":"/api/entries/authors?select=name&sort=name&limit=1"}Prompts that work
- "What models can you read in Capa, and what does an article look like?"
- "Which tags do our articles use, and how many articles have none?"
- "Write the query for the blog index: the 10 newest featured articles with their author's name. Give me the SDK code for a Next.js server component."
- "This fails with
invalid_cursor. Why, and what should the code do?"
The assistant builds every query with capa_graphql_build, which runs it once
before handing it over, so the query in the code you get has already worked
against your content. The code declares the query as a #graphql literal:
run capa-codegen --graphql and its data and variables are typed. For a
Next.js server component it reads through graphql() from
@capacms/sdk/nextjs, tagged with every model the query reads, so a publish
refreshes the page.
What it will not do
- Write, publish or delete content. Content is edited in the Capa admin.
- See a model the key cannot read. A one-model key gets a schema with only that model, relations to other models show as plain ids, and no answer names a model the key cannot read.
- Flood the conversation. Every tool answer has a size budget of 20,000
characters.
capa_graphql_build,capa_graphql_queryandcapa_explore_datatakemaxCharsto set another, from 1,000 to 100,000. A cut answer says what was cut and how to ask for less. A cut list can still be paged without skipping an entry. When the answer holds each entry's cursor (edges { cursor }, whichcapa_graphql_buildselects itself),endCursoris moved to the last entry shown. When it does not,endCursorcomes backnull, and the cut'sresumesays how to read on: run the query again withfirstset to the number shown. A long text value, such as an article body, is clipped to the room the answer has, and the assistant reads the rest in parts withcapa_graphql_query'sslice. - Pass off a draft as published. Every answer says which key read it
(
environment), and a development key's answers say they include drafts and unpublished changes. A production key reads published entries only, so when it reads one entry by id and finds nothing, the answer says that an entry that exists only as a draft reads as null too.
Three answers are never cut, because the assistant edits them and writes them
back: a model with its editor layout (capa_get_model), the layout
capa_set_model_layout saved, and a workspace document
(capa_get_workspace). The schema resource is never cut either. A client
attaches it only when you ask it to; the tools read the schema in bounded
pieces.
When something fails
Every refusal says what to change for its own error code and names the tool
that helps, so an assistant usually recovers on its own. A query over one of
the API's budgets gets the API's own message and the change its hint names,
for example "articles: could read 40,200 entries, over the limit of 5,000.
Pass coauthors(first: 24) to fit, or lower first elsewhere.", and
capa_graphql_build names the root first that fits as well. A call Capa
does not answer within 25 seconds fails with a next step too.
A server that cannot reach Capa says where it tried and why, for example:
Could not reach Capa at http://localhost:6199 for POST /api/graphql (ECONNREFUSED). Next: check that CAPA_API_URL is right and that the API is running and reachable from this machine, then retry.When the assistant is stuck, paste the error into the chat and ask what it
means. capa_explain_error knows every /api/ error code and answers without
calling Capa. It also tells a failed connection from an API error: a site log's
fetch failed ... ECONNREFUSED gets the same next step as above, not a code
the API never sent.
If the tool list is short
The server decides the tool list once, at startup, from the key and the deployment. When the deployment lacks a feature, or Capa does not answer, it says so on stderr. Most clients show that in their MCP log.
- The GraphQL tools go to any key that reads at least one model.
capa_explain_errorgoes to every key, so a key with no read scope gets only that one. - A deployment with GraphQL switched off gets no
GraphQL tool. The assistant gets
capa_read_entriesinstead, which reads the same content over REST (GET /api/entries/<model>) for as long as GraphQL is off, and the startup line names it. - A deployment without the page routes gets no page tool.
- When Capa does not answer at startup, every tool the key could use is offered anyway, and the first call reports the real error.