Docs

MCP server

Give Claude Code, Cursor, Codex or any MCP client read access to a Capa project with @capacms/mcp.

View as Markdown

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

  1. 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.
  2. Keep the key in your shell or your secret store, never in a file you commit.
  3. 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/mcp

Cursor, 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 toTool it uses
list the models and fields it can read, or show the GraphQL schemacapa_graphql_schema
show what the content holds: counts, ranges, the values a field takescapa_explore_data
write a query for a goal, as GraphQL, a REST URL and SDK codecapa_graphql_build
run a GraphQL querycapa_graphql_query
explain an API error from a log or a failed callcapa_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_page and capa_suggest_queries, whichever key family it is. A cap_ key needs the unscoped instance:read for 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:

ResourceWhat it holds
capa://graphql/schema.graphqlthe 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}.graphqlone model's SDL, by namespace or type name, e.g. capa://graphql/schema/articles.graphql
capa://guide/queryingthe 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_query and capa_explore_data take maxChars to 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 }, which capa_graphql_build selects itself), endCursor is moved to the last entry shown. When it does not, endCursor comes back null, and the cut's resume says how to read on: run the query again with first set 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 with capa_graphql_query's slice.
  • 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_error goes 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_entries instead, 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.