# MCP server

Source: https://capacms.com/docs/ai/mcp

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

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](https://capacms.com/docs/api/authentication).
   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:

```bash
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:

```json
{
  "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`:

```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_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](https://www.npmjs.com/package/@capacms/mcp).

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:

```json
{"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:

```json
{"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:

```json
{"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:

```json
{"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:

```json
{"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:

```json
{"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:

```text
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.
