@capacms/mcp 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,881 @@
1
+ # @capacms/mcp
2
+
3
+ stdio MCP server for Capa. Seventeen reads, two writes, two resources and three
4
+ prompts. People set it up from the
5
+ [AI assistants guide](https://docs.capacms.com/api/ai-assistants), which has
6
+ the Claude Code, Cursor and Codex config.
7
+
8
+ It has no dependencies, so `npx` fetches the package and runs it with nothing
9
+ else to install. It needs Node 20.3 or later: every request is bounded with
10
+ `AbortSignal.any`, and Node 18's `fetch` tried only `::1` for `localhost`, so
11
+ an API listening on IPv4 looked down. A Node without `AbortSignal.any` exits
12
+ at once and says so.
13
+
14
+ With a `cap_` key minted in the Capa admin under **Developers > Keys**:
15
+
16
+ ```sh
17
+ CAPA_API_URL=https://api.capacms.com CAPA_KEY=cap_live_... npx -y @capacms/mcp
18
+ ```
19
+
20
+ An MCP client starts it the same way: the command is `npx`, the arguments are
21
+ `-y @capacms/mcp`, and the variables below go in the client's `env`.
22
+
23
+ | variable | needed | value |
24
+ |---|---|---|
25
+ | `CAPA_API_URL` | always | the API, `https://api.capacms.com` |
26
+ | `CAPA_KEY` | always | the key the server reads with |
27
+ | `CAPA_TENANT_ID` | a legacy key only: `pk_`, `sk_` or unprefixed | the tenant's id |
28
+ | `CAPA_API_VERSION` | no | the dated `/api/` version, `2026-10-01` |
29
+
30
+ `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads
31
+ (`@capacms/sdk/nextjs`, `capa-codegen`, the docs' curl examples), so one
32
+ `.env` serves a site and this server. `CAPA_BASE_URL` and
33
+ `CAPA_API_KEY`, this server's first names, still work as aliases; when both
34
+ are set, `CAPA_API_URL` and `CAPA_KEY` win.
35
+
36
+ `CAPA_API_URL` is the API's address with no route after it: every tool adds
37
+ the route it calls (`/api/graphql`, `/v2/api/...`). An address that holds
38
+ `/api`, `/v2` or `/v3` as a path segment is refused at startup, naming the
39
+ part to drop, since each call would repeat it. A path before the routes, such
40
+ as a proxy's `/capa`, is kept. Spaces around `CAPA_API_URL`, `CAPA_KEY` and
41
+ `CAPA_TENANT_ID`, as a pasted line can carry, are trimmed.
42
+
43
+ `CAPA_TENANT_ID` is needed by a **legacy key only**: `pk_`, `sk_`, or an
44
+ older key with no prefix, since every key but `cap_` is legacy. The `/v2` and
45
+ `/v3` mounts every legacy tool calls read it as `X-Tenant-Key`. A `cap_` key
46
+ reaches `/api/`, which resolves the tenant from the key and never reads that
47
+ header, and no legacy tool that could send it is registered for a `cap_` key,
48
+ so the server starts without it rather than refusing over a value nothing it
49
+ can call will read. Started with no key at all, the server names
50
+ `CAPA_API_URL` and `CAPA_KEY` as missing, and labels `CAPA_TENANT_ID` as for
51
+ legacy keys only.
52
+
53
+ `CAPA_API_VERSION` is the dated `/api/` version the `/api/` tools ask for. Unset,
54
+ they send the newest date this package knows. A date the deployment does not
55
+ serve is refused with a list of the ones it does.
56
+
57
+ Nineteen tools, of which a given key is offered a subset. The `needs` column is
58
+ the key permission or the scope a tool asks for, and the `surface` column is
59
+ which half of the API it calls, which is what decides whether it is registered
60
+ at all (see [the page tools](#the-page-tools)).
61
+
62
+ | tool | answers | surface | needs |
63
+ |---|---|---|---|
64
+ | `capa_list_models` | what content exists here, and what relates to what | legacy | read |
65
+ | `capa_get_model` | what shape is this model: fields, types, relations, and how its editor is laid out | legacy | read |
66
+ | `capa_set_model_layout` | design that model's entry editor, or reset it to linear | legacy | **agent** |
67
+ | `capa_list_content` | which instances of this model exist | legacy | read |
68
+ | `capa_get_content` | this one instance, in full, with relations | legacy | read |
69
+ | `capa_search_content` | where does this text live, when you don't know the model | legacy | read |
70
+ | `capa_get_types` | the generated TypeScript, for writing code against a tenant | legacy | read |
71
+ | `capa_list_workspaces` | which saved arrangements of the admin rail exist | legacy | read |
72
+ | `capa_get_workspace` | one workspace as a document you can edit and write back | legacy | read |
73
+ | `capa_set_workspace` | create a workspace, or apply a document to one | legacy | **write** |
74
+ | `capa_list_pages` | which pages this site has, what they read and how busy they are | `/api/` | `instance:read` |
75
+ | `capa_get_page` | one page: its models, its entries, and the exact queries it issues | `/api/` | `instance:read` |
76
+ | `capa_suggest_queries` | what Capa suggests changing about how one page fetches | `/api/` | `instance:read` |
77
+ | `capa_graphql_schema` | what this key can read: models, fields, relations; with `model`, filters, sorts and an example; SDL on request | `/api/` | `instance:read` for any model |
78
+ | `capa_graphql_build` | write a query from an intent, as GraphQL, REST and SDK code, checked | `/api/` | `instance:read` for any model |
79
+ | `capa_graphql_query` | run a GraphQL read and get data, errors with hints, cost and budget | `/api/` | `instance:read` for any model |
80
+ | `capa_explore_data` | what the content actually holds: counts, ranges, values, fan-out | `/api/` | `instance:read` for any model |
81
+ | `capa_explain_error` | what an API error means and what to do next | none (offline) | nothing |
82
+ | `capa_read_entries` | read entries over REST, where the deployment serves no GraphQL, or of the models GraphQL leaves out | `/api/` | `instance:read` for any model |
83
+
84
+ Every tool has a `title` and MCP annotations, so a client can run the reads
85
+ without asking and confirm a write. The seventeen reads are
86
+ `readOnlyHint: true`. The two writes are `readOnlyHint: false` and
87
+ `destructiveHint: true`, since each can replace what is there.
88
+ `capa_set_model_layout` is `idempotentHint: true`: the same layout twice is one
89
+ layout. `capa_set_workspace` is not, because without an `id` it creates a new
90
+ workspace on every call. Every tool is `openWorldHint: false`, since none
91
+ reaches past this Capa deployment.
92
+
93
+ Every answer is held to 20,000 characters. The GraphQL tools and
94
+ `capa_read_entries` take `maxChars` for another budget, and every tool cuts the same way: list items from the end,
95
+ then long text, each cut named in `truncated` or `clipped`, with a `hint` on
96
+ how to ask for less. `capa_get_types` is cut between whole declarations and
97
+ names the ones it left out in `notPrinted`. Three answers are never cut,
98
+ because an agent edits them and writes them back: `capa_get_model`,
99
+ `capa_set_model_layout` and `capa_get_workspace`.
100
+
101
+ A host that does not answer is named with the cause and the next step, on
102
+ every tool and in the startup line on stderr:
103
+
104
+ ```text
105
+ 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.
106
+ ```
107
+
108
+ It names the origin only. A `CAPA_API_URL` with a user name or password in
109
+ it is refused by `fetch`, and the message says so without repeating them.
110
+
111
+ Calls that reach Capa run one at a time, in the order they came. `ping`,
112
+ `initialize`, the lists, the prompts and `capa_explain_error` answer at once
113
+ beside a call in flight, so a slow host never makes the server look dead.
114
+ `notifications/cancelled` stops the call it names, running or still waiting
115
+ its turn, and that call is never answered, as MCP requires.
116
+
117
+ The legacy tools call a model `namespace`, as `/v2` does, and
118
+ `capa_set_model_layout` calls it `modelId`. Each also takes `model`, the name
119
+ the GraphQL tools teach. An argument that another tool of this server, or
120
+ REST, calls by a different name is refused with the name this tool takes:
121
+ `limit` on `capa_graphql_build` suggests `first`, `where` suggests `filter`,
122
+ `select` suggests `fields`.
123
+
124
+ ## The GraphQL tools
125
+
126
+ Five tools take an agent from "what is in this project?" to a working query,
127
+ in the order it needs them:
128
+
129
+ | step | tool | returns |
130
+ |---|---|---|
131
+ | what is here? | `capa_graphql_schema` | the models, fields and relations this key can read; with `model`, filters, sorts and an example; with `sdl`, the SDL |
132
+ | what does it look like? | `capa_explore_data` | real values, measured: counts, ranges, distinct values, relation fan-out, samples |
133
+ | write me a query | `capa_graphql_build` | GraphQL and the equal REST URL, checked by running it, and `@capacms/sdk` code when asked |
134
+ | run this | `capa_graphql_query` | data, errors with hints, cost, budget, and the REST request per root field |
135
+ | what went wrong? | `capa_explain_error` | what a code means, the fix, and the tool to call next (offline) |
136
+
137
+ These five names are final. The four that read cost 3,991 characters of context (descriptions plus
138
+ input schemas), and the tests hold them under 4,000. All five also declare an
139
+ `outputSchema` and return `structuredContent` beside the JSON text, a refusal
140
+ in band included. The output schemas, for a client that reads them, are held
141
+ under 4,000 characters by the tests too.
142
+
143
+ Every answer the API ran says which key read it: `environment` is
144
+ `production` or `development`. A development key's answer also carries
145
+ `drafts`, `"A development key reads drafts and unpublished changes; a
146
+ production key reads published entries only."`, so an assistant never
147
+ describes a draft title as what the site shows. A production key's read of
148
+ one entry that answers `null` carries `notFound`: `"article is null: no entry
149
+ this key reads has that id. A production key reads published entries only, so
150
+ an entry that exists only as a draft reads as null too."`
151
+
152
+ They all read through `/api/graphql`, whose schema is built per key: a key
153
+ holding `instance:read:<articles>` sees articles and nothing else, its
154
+ relations to other models are plain ids, and no tool output names a model it
155
+ cannot read. Every answer has a character budget (`maxChars`, default 20,000)
156
+ that holds for the whole answer. A text value longer than half the budget is
157
+ clipped first, to the room the answer has, so one long body does not cost the
158
+ entries after it. Then a big result loses list items from the end, measured
159
+ one by one, and `truncated` says which list, how many were kept and how many
160
+ there were; a connection that selects both `edges` and `nodes` loses entries
161
+ from both, and both are listed. Then the longest text values are clipped, each
162
+ to the room left and never below 200 characters, each listed in `clipped`
163
+ with its `path`, the characters `kept` and the `total`; an id, a cursor, a URL
164
+ or an error's text never is. The hint names the longest clipped value, and
165
+ `capa_graphql_query` with the same query and `slice: { "path", "offset" }`
166
+ reads the rest of it from `offset` on, as `text`, as much as the budget
167
+ holds, with `clipped` and a hint for the next call while more follows. The
168
+ REST twins (`rest`) are cut last, unless they take more than half the budget
169
+ and more than 2,000 characters, as a `nodes(ids:)` read's can, which lists
170
+ every id: then their last rows go first. When even one entry per list does
171
+ not fit, the hint gives way first, and then `capa_graphql_query` leaves out
172
+ the `drafts` note and then the REST twin, each named in `truncated`, before
173
+ it gives up the data; `environment` still says which key read it. A
174
+ cut list stays
175
+ pageable: `hasNextPage` turns true, and `endCursor` becomes the cursor of the
176
+ last entry kept, so passing it as `after` skips nothing. A page read backward
177
+ (`last` and `before`) loses entries from its start instead, the ones farthest
178
+ from `before`, and says `"cutFrom": "start"`: `hasPreviousPage` turns true and
179
+ `startCursor` becomes the cursor of the first entry kept, so passing it as
180
+ `before` skips nothing. When the answer holds no cursor for that entry (no
181
+ `edges { cursor }` selected), the cursor is `null` and the cut's `resume` says
182
+ how to go on: run again with `first` (or `last` and the same `before`) set to
183
+ the number kept. The uncut page's cursor would skip every entry cut. The
184
+ connections are read from the document, so `items: nodes` and `info:
185
+ pageInfo` are repaired under the names they answer with.
186
+
187
+ A relation list inside an entry is a page of its own: `first` entries, 100
188
+ when the document gives none. When one holds more than it shows, the answer
189
+ says so at its top, in `more` and a `note`, so 100 of a bin's 250 items never
190
+ read as all of them:
191
+
192
+ ```json
193
+ "more": [{ "path": "data.bins.nodes[0].items", "shown": 100, "endCursor": "c1.eyJ...", "entry": "084f404f-..." }],
194
+ "note": "1 relation list holds more entries than it shows: see more. A relation list pages on only in a read of one entry: ..."
195
+ ```
196
+
197
+ `capa_graphql_query` names a list whose `pageInfo` says `hasNextPage`, and one
198
+ that selected no `pageInfo` but fills its page (`"pageInfo": false`: it may
199
+ hold more). The API pages a relation list with `after` only in a read of one
200
+ entry, so `entry` is the id to read it from, with the model's single field.
201
+ `capa_graphql_build` hands over plan 1.3's document, where a relation list
202
+ selects `nodes` only, but its run reads each list's `pageInfo` and cursors:
203
+ each list in its `more` carries `next`, the `capa_graphql_query` arguments
204
+ that read that one entry's list on from where it stopped, cut or not. Its
205
+ `result` keeps the shape of the document handed over. `capa_graphql_query`
206
+ gives a list its `next` too, the same document with that list's `after` set,
207
+ where the list sits in a read of one entry; a list under a list is read on
208
+ by hand, from `entry`.
209
+
210
+ Arguments are checked against each tool's input schema,
211
+ so a misspelled one (`frist`, `filters`) is refused by name with the allowed
212
+ names and a suggestion, never silently dropped. A relation spec in `capa_graphql_build`'s
213
+ `fields` is checked the same way at every depth:
214
+ `Unknown argument frist in fields[1]. Did you mean first? Allowed: field, fields, first, sort.` Every request this server makes, on
215
+ either surface, gives up after 25 seconds with the next step. Every other
216
+ refusal is in band and names the next tool for its own error: a typo points
217
+ at `capa_graphql_schema`, a bad argument at `capa_graphql_build`, and a
218
+ mutation at nothing, since no tool can write. A budget refusal passes on what
219
+ the API measured and the limit, and names the tool for that budget:
220
+ `capa_graphql_build` to lower `first` or nest less, `capa_graphql_query` to
221
+ split a document with too many root fields, connections or `in` values, and
222
+ `capa_graphql_schema` for an introspection query too deep.
223
+
224
+ Where GraphQL is switched off (`CAPA_API_GRAPHQL=off`), `/api/graphql`
225
+ answers as any path the API does not serve: `405 mutations_not_enabled` to a
226
+ POST, in the REST envelope `{ error }`. The GraphQL handler never answers in
227
+ that envelope, a real mutation's refusal included, so the envelope tells the
228
+ two apart. The server asks once at startup, with `POST /api/graphql` and
229
+ `{ __typename }`, and on such a deployment registers no GraphQL tool, resource
230
+ or prompt, with a line on stderr. A GraphQL tool called there anyway, because
231
+ the startup probe could not tell, answers:
232
+
233
+ ```text
234
+ This deployment does not serve GraphQL: POST /api/graphql answered 405 mutations_not_enabled as a path it does not serve, which is what CAPA_API_GRAPHQL=off does. No GraphQL tool can answer until it is switched back on.
235
+ Next: tell the person running Capa that GraphQL is off here. Code can read the same content over REST with this key: GET /api/entries/<model>.
236
+ ```
237
+
238
+ A legacy key is also pointed at `capa_list_content` and `capa_get_content`.
239
+ A real mutation is still told it tried to write, and nothing more.
240
+
241
+ Where the startup probe finds GraphQL off, the server registers
242
+ `capa_read_entries` in its place, so a key keeps a read tool for as long as
243
+ GraphQL is off. It reads `GET /api/entries/<model>` with REST's own
244
+ parameters (`select`, `where`, `sort`, `limit`, `after`, `before`, `count`),
245
+ answers the models the key reads when called without `model`, and is cut like
246
+ every other answer. A cut page's `next` would skip what was cut, so it is
247
+ `null`, and `resume` says how to read on:
248
+
249
+ ```json
250
+ {"data":[{"id":"00000000-0000-4000-8000-00000000002e","model":"articles","status":"published",...}],
251
+ "page":{"limit":10,"hasNext":true,"next":null,"hasPrev":false,"prev":null},"environment":"production","rest":"/api/entries/articles?limit=10",
252
+ "truncated":[{"path":"data","kept":1,"total":10,"resume":"page.next is null because entries were cut; the uncut page's would skip them. Read again with limit: 1 for a next after the last entry shown."}],
253
+ "hint":"Pass select for fewer fields, or a smaller limit."}
254
+ ```
255
+
256
+ The examples below are real outputs of this server, driven over stdio against
257
+ a local API on the seed (`pnpm -F @capa/api seed:local`) with a `cap_` key
258
+ holding `instance:read`, `model:read` and `media:read`, captured on
259
+ 2026-09-26. Outputs are shortened where they show `...`.
260
+
261
+ `capa_graphql_schema` with no arguments is the map:
262
+
263
+ ```json
264
+ {"version":"2026-10-01","key":{"environment":"production","bundle":"scoped","scopes":["instance:read","model:read","media:read"]},
265
+ "models":[{"model":"articles","name":"Article","type":"Articles","graphql":"articles, article(id)","rest":"/api/entries/articles",
266
+ "fields":["title: String","body: String","views: Float","featured: Boolean","tags: [String]","author -> authors","coauthors ->> authors"]},
267
+ {"model":"authors","name":"Author","type":"Authors","graphql":"authors, author(id)","rest":"/api/entries/authors","fields":["name: String","bio: String"]},
268
+ {"model":"snippets","name":"Snippet","type":"Snippets","graphql":"snippets, snippet(id)","rest":"/api/entries/snippets","fields":["title: String","code: String"]}],
269
+ "system":"Every model type also has id, model, status, createdAt, updatedAt, publishedAt, _version, _tags, _folder. Filters and sorts take id, createdAt, updatedAt and publishedAt of those. Filters also take _tags.",
270
+ "next":"Pass model for filters, sorts and an example, or call capa_graphql_build."}
271
+ ```
272
+
273
+ `key.scopes` is listed where the deployment reports scopes
274
+ (`CAPA_KEY_SCOPES=on`), as the one above does. The switch is off by default,
275
+ and then `key` has no `scopes`.
276
+
277
+ `model` takes any of a model's names, in any case: the namespace
278
+ (`articles`), the name the admin shows (`Article`) or the type (`Articles`).
279
+ A near miss comes back with the nearest (`Authr` suggests `authors`). On a
280
+ project with too many models to list with their fields, the map keeps every
281
+ model, with its roots, and shortens the field lists; past that it lists the
282
+ names alone and says to pass `model`. A deprecated field is marked
283
+ `(deprecated)`.
284
+
285
+ `capa_graphql_schema { "model": "articles" }` adds, per field, its filter
286
+ operators, the fields a relation filter reaches (`"author"` has
287
+ `"filter":["eq","ne","in","nin","exists","null"]` and `"hops":["id","name","bio"]`),
288
+ whether it sorts, the sort values (`views_DESC`, `author__name_ASC`, ...), the
289
+ reason a deprecated field is deprecated, and an example query with its REST
290
+ twin. For a model
291
+ with an image, video or file field it adds `media`, what a media value holds,
292
+ read from the schema: `"Media fields: id, url, alt, type, width, height. type:
293
+ The kind of file, or null when it is not one of MediaKind's. MediaKind: image,
294
+ video, audio, document, pdf, file, unknown."`
295
+
296
+ `sdl: true` adds the SDL, in a compact form: types, fields and arguments keep
297
+ their descriptions (each field's label and Capa type), while input fields and
298
+ enum values print without theirs, since an operator's or sort value's name
299
+ says what it does. With `model: "authors"` that is about 20 types: the authors
300
+ types, `Query` narrowed to them, and the shared filter, paging and entry
301
+ types; a kept type that points at a model left out is named in
302
+ `alsoReferenced`. The details and the SDL each come whole: when both do not
303
+ fit in 20,000 characters, `sdl` is left out whole and the note says so and
304
+ points at the resource `capa://graphql/schema.graphql`. The SDL is never cut
305
+ mid-type. Without `model` it is the whole schema, with every
306
+ `@deprecated(reason:)`. Over 20,000 characters it is cut between whole
307
+ models: each model printed comes with `Query` and every type it uses, and the
308
+ note names the models left out ("Printed 40 of 55 models, each with the types
309
+ it uses. Not printed: ... Pass model for one."). The resource prints the full
310
+ form, every description included.
311
+
312
+ `capa_explore_data { "model": "articles", "field": "tags", "sample": 0 }`:
313
+
314
+ ```json
315
+ {"model":"articles","environment":"production","total":13,"scanned":13,"statuses":{"published":13},
316
+ "counts":"Counts are of what is stored, so they agree with filters. present: has a value. empty: blank text or an empty list. missing: null or absent, what { null: true } matches; ...",
317
+ "fields":[{"field":"tags","type":"array of string","present":12,"empty":1,"missing":0,
318
+ "items":17,"perEntry":{"min":0,"max":2,"mean":1.31,"median":1},"distinct":3,"values":[["news",6],["tech",6],["life",5]]}],"samples":[]}
319
+ ```
320
+
321
+ So "how many articles have no tags?" is `empty` plus `missing`: 1. `statuses`
322
+ counts the entries read by status, and `environment` says which key read
323
+ them: a production key reads published entries only.
324
+
325
+ Every count is of what is stored, so it agrees with a filter: `missing` is
326
+ what `{ null: true }` matches, and `present` plus `empty` is what
327
+ `{ exists: true }` matches. A query returns something else for two kinds of
328
+ stored value: a reference to an entry it cannot return (deleted, draft only,
329
+ another model), and a value that does not fit its field's type, such as the
330
+ text `"42"` in a number field. It returns both as `null`. So each page the
331
+ query returns is read again over REST, relations unexpanded, for the same
332
+ ids, and the two reads side by side give `dangling` and `mistyped`. Here is
333
+ the seed with four values changed by hand: one article's author and one
334
+ coauthor pointed at authors that do not exist, one `views` stored as `"42"`
335
+ and another as `null`:
336
+
337
+ ```json
338
+ {"field":"author","type":"relation","present":13,"empty":0,"missing":0,"target":"authors","dangling":1,"distinct":4,"top":[...]}
339
+ {"field":"views","type":"number","present":12,"empty":0,"missing":1,"min":5,"max":150,"mean":44,"median":33,"mistyped":1}
340
+ {"field":"coauthors","type":"array of relation","present":8,"empty":5,"missing":0,"target":"authors","items":12,"fanOut":{"min":0,"max":3,"mean":0.92,"median":1},"dangling":1}
341
+ ```
342
+
343
+ On the same data the API's filters count 0 articles for
344
+ `{ author: { null: true } }` and 1 for `{ views: { null: true } }`, as those
345
+ counts say. The range of `views` is over the values of its type only.
346
+
347
+ REST shows three stored relation lists the same way, as an empty list: one
348
+ stored empty, one stored as `null` or not at all, and one stored as
349
+ something that is not a list, such as text an import wrote. So the entries
350
+ whose list REST shows empty are read twice more. `{ null: true }` finds the
351
+ missing ones. `not` of `has` an id no entry holds finds the ones stored as a
352
+ list, since `not` never matches a value that is not a list. Those count as
353
+ `empty`, and the rest as `present` and `mistyped`.
354
+
355
+ REST shows some wrong-typed values as `null` too: text stored in an image
356
+ field, a media list that is not a list, a reference that is not an id. For
357
+ media and single relation fields, the entries REST shows as `null` are
358
+ checked once more with `{ exists: true }`, which tests what is stored. Each
359
+ one it matches counts as `present` and `mistyped` (never `dangling`), so the
360
+ counts still agree with the filters.
361
+
362
+ A media field reports `mediaTypes`, how many values are of each kind of
363
+ file, counted across every item for a list of media, which also reports
364
+ `items` and `perEntry` like any list.
365
+
366
+ Cut to fit its budget, the answer leaves out `samples` first, then `counts`
367
+ (the legend of what each count means, which the output schema's `counts`
368
+ also carries), and only then shortens `fields`, from the end, with the hint
369
+ `Pass field to measure one field, or sample: 0.` On the zoo's 33-field
370
+ `scalars` model, `maxChars: 3000` keeps 16 fields' numbers, and `1000` keeps 3.
371
+
372
+ It reads up to `maxEntries` (default 200, at most 1,000) and says so in `note`
373
+ when that is fewer than `total`. The query reads array relations as ids, at
374
+ most 20 per entry, and the page size shrinks with each relation, leaving room
375
+ for the 500 its entry count costs, so the query stays inside the API's
376
+ 5,000-entry budget. `items` and `fanOut` count every id stored, from the REST
377
+ read, and `fanOut` is per entry scanned, so a list stored as null counts as 0.
378
+ `dangling` on a relation list checks the first 20 ids of each entry, and
379
+ `danglingChecked` says so when an entry stores more. One query expands at most
380
+ 12 relation fields; past that, `skipped` names the rest, and `field` measures
381
+ one of them.
382
+
383
+ `capa_graphql_build`:
384
+
385
+ ```json
386
+ {"model":"articles","fields":["title",{"field":"author","fields":["name"]}],"filter":{"featured":{"eq":true}},"sort":["views_DESC"],"first":2,"run":true,"code":"all"}
387
+ ```
388
+
389
+ ```json
390
+ {"query":"query ArticlesList($first: Int, $sort: [ArticlesSort!], $filter: ArticlesFilter) {\n articles(first: $first, sort: $sort, filter: $filter) {\n nodes {\n id\n title\n author {\n id\n name\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}",
391
+ "variables":{"first":2,"sort":["views_DESC"],"filter":{"featured":{"eq":true}}},"operationName":"ArticlesList",
392
+ "rest":"/api/entries/articles?select=title,author(name)&where=%7B%22featured%22:%7B%22eq%22:true%7D%7D&sort=-views&limit=2",
393
+ "sdk":{"document":"// Run capa-codegen --graphql after saving this: ...","next":"import { draftMode, headers } from \"next/headers\";\n...","node":"...","rest":"..."},
394
+ "environment":"production",
395
+ "result":{"data":{"articles":{"nodes":[{"id":"00000000-0000-4000-8000-000000000025","title":"Epsilon engineering notes","author":{"id":"00000000-0000-4000-8000-000000000012","name":"Brin Cole"}},
396
+ {"id":"00000000-0000-4000-8000-00000000002a","title":"Kappa keeps it simple","author":{"id":"00000000-0000-4000-8000-000000000011","name":"Ada Vale"}}],
397
+ "pageInfo":{"hasNextPage":true,"endCursor":"c1.eyJ2IjoiMjAyNi0xMC0wMSIs..."}}},
398
+ "cost":{"depth":3,"rootFields":1,"connections":1,"nodesBound":4,"scans":1,"nodes":4,"fields":12},"budget":{"counted":504,"limit":5000}}}
399
+ ```
400
+
401
+ `sdk` is code for the user's app, typed the way `@capacms/sdk` types its own
402
+ examples, and it comes only when `code` asks for it: `next` for a Next.js
403
+ server component, `node` for anywhere else, `rest` for the REST read, `all`
404
+ for the three, and `none`, the default. `next` and `node` bring `document`
405
+ with them. An agent refining a query pays for no code, and asks for it once,
406
+ on the final build. For the call above it is:
407
+
408
+ ```ts
409
+ // sdk.document
410
+ // Run capa-codegen --graphql after saving this: it types data and the variables from this text.
411
+ const ArticlesList = `#graphql
412
+ query ArticlesList($first: Int, $sort: [ArticlesSort!], $filter: ArticlesFilter) {
413
+ ...the query above...
414
+ }
415
+ `;
416
+
417
+ // sdk.next
418
+ import { draftMode, headers } from "next/headers";
419
+ import { graphql, tagsFor } from "@capacms/sdk/nextjs";
420
+ import { ArticlesListModels } from "./capa-graphql"; // written by capa-codegen --graphql
421
+ // In a server component:
422
+ const { data, errors } = await graphql(ArticlesList, {"first":2,"sort":["views_DESC"],"filter":{"featured":{"eq":true}}}, {
423
+ draftMode,
424
+ headers,
425
+ tags: tagsFor({ namespace: ArticlesListModels }),
426
+ revalidate: 60,
427
+ });
428
+
429
+ // sdk.node
430
+ import { createClient } from "@capacms/sdk/next";
431
+ const capa = createClient({ baseUrl: process.env.CAPA_API_URL!, apiKey: process.env.CAPA_KEY!, version: "2026-10-01" });
432
+ const { data, errors } = await capa.graphql(ArticlesList, {"first":2,"sort":["views_DESC"],"filter":{"featured":{"eq":true}}});
433
+
434
+ // sdk.rest, with the same capa
435
+ const page = await capa.entries.list("articles", { select: "title,author(name)", where: {"featured":{"eq":true}}, sort: ["-views"], limit: 2 });
436
+ ```
437
+
438
+ `sdk.document` is the only copy of the document in the code: `sdk.next` and
439
+ `sdk.node` name it. Once `capa-codegen --graphql` has read the literal,
440
+ `data` and the variables are typed from it. `sdk.next` is the SDK's Next.js
441
+ path: drafts and edit marks under preview, the read kept in Next's data
442
+ cache under a tag for every model the query reads (so `revalidateFromWebhook`
443
+ refreshes it on a publish, and `revalidate: 60` at most a minute later if a
444
+ webhook is missed), and an answer with errors never cached. The models are
445
+ `ArticlesListModels`, which `capa-codegen --graphql` writes beside the
446
+ document's types, so the tags follow the query when it is edited. `sdk.node` is for anywhere else. The SDK's
447
+ type test compiles all of it as handed over
448
+ (`packages/sdk/test/types/mcp-snippets.ts`, written by
449
+ `test/support/sdk-snippets.mjs`). `result` carries no `rest` of its own,
450
+ since the API's REST twin is the `rest` above; one that differs comes back as
451
+ `restFromApi`.
452
+
453
+ A filter value needs its operator: `{ "featured": true }` is refused with the
454
+ form to write, `featured takes operators, not a bare value: write {"featured":{"eq":true}}`,
455
+ since the API would refuse it too, and a REST twin written from it would read
456
+ every entry.
457
+
458
+ With `run`, the answer is cut to `maxChars` without cutting the document:
459
+ the SDK code goes first, whole, then entries from the result. `query`,
460
+ `variables` and `rest` are never cut. When not even one entry fits beside
461
+ them, `result` is left out, and the note says so and points at
462
+ `capa_graphql_query`, whose answer does not repeat the document. The note,
463
+ then the hint, give way before the budget does, while `truncated` still names
464
+ what was left out. The budget always holds: a document too long for
465
+ `maxChars` is left out whole, the largest part first, and named in
466
+ `truncated`, and the hint gives the `maxChars` that returns it.
467
+
468
+ The SDK code is for the key the server holds. `@capacms/sdk/next` reads with
469
+ a `cap_` key and with the legacy keys sites already hold (`pk_`, `sk_` or
470
+ unprefixed), so every key gets the same code; for a legacy key `sdk` also
471
+ carries a `note`:
472
+ the SDK warns once about that key, and draft previews need a `cap_` key,
473
+ minted under Developers > Keys.
474
+
475
+ Each filter value goes as the type the schema declares for its operator,
476
+ whichever way it was written: `{ "a_bool": { "has": "true" } }` on a list of
477
+ true/false values goes as `true`, since that list filters as a
478
+ `BooleanListFilter`, and `"10"` on a number as `10`.
479
+
480
+ Without `run` it still runs the query once, as a check, and answers
481
+ `check: { ok, entries, cost }` instead of the data. When the API refuses the
482
+ query, the document still comes back, with
483
+ `check: { ok: false, status, errors, next }`, `next` being the tool that helps
484
+ with that error. Over the entries budget, `check.fix` works out the `first`
485
+ that fits, with the API's numbers as it wrote them, and when the API's hint
486
+ names a relation list's `first` instead, names both changes:
487
+ `"Either change fits the limit of 5,000: coauthors(first: 24), as the API's
488
+ hint says, or first: 24 on articles, since each Articles entry can read 201
489
+ with its relations."` A production key's read of one entry that finds
490
+ nothing answers `check: { ok: true, entries: 0 }` beside `notFound`, which
491
+ says a draft-only entry reads as null too. `rest` is written exactly as the API writes it in
492
+ `extensions.capa.rest` (the filter as `where` JSON, the root's default page
493
+ size left out, a relation list's `first` written as `limit:`, 100 included),
494
+ so an agent meets one REST syntax everywhere; if the API ever
495
+ prints a different twin, the answer carries it as `restFromApi`. A name the
496
+ schema does not have comes back as `{"error":"unknown field titel on Articles","didYouMean":["title"],"available":[...],"next":"capa_graphql_schema"}`;
497
+ a query the key cannot build says how to change it, for example `"author on Articles is an id here: this key cannot read the model it points at. Select author without fields for the id"`.
498
+ The GraphQL and REST it writes are byte-identical to `@capacms/sdk`'s
499
+ `buildGraphQLQuery` and `graphqlToSelect`: both packages are tested against
500
+ `packages/sdk/test/fixtures/graphql-vectors.json`, whose REST twins are
501
+ captured from the live API. The admin's GraphQL Explorer builder is pinned to
502
+ the plan's vectors B1 to B5 in the same format.
503
+
504
+ `capa_graphql_query` runs any read:
505
+
506
+ ```json
507
+ {"query":"{ articles(first: 2, filter: { author: { name: { eq: \"Ada Vale\" } } }) { nodes { title views } } }"}
508
+ ```
509
+
510
+ ```json
511
+ {"data":{"articles":{"nodes":[{"title":"Kappa keeps it simple","views":74},{"title":"Eta on effort estimation","views":3}]}},
512
+ "cost":{"depth":2,"rootFields":1,"connections":1,"nodesBound":2,"scans":1,"nodes":2,"fields":4},"budget":{"counted":502,"limit":5000},
513
+ "rest":[{"field":"articles","url":"/api/entries/articles?select=title,views&where=%7B%22author.name%22:%7B%22eq%22:%22Ada%20Vale%22%7D%7D&limit=2"}],
514
+ "environment":"production"}
515
+ ```
516
+
517
+ `cost` is the API's own measure from `extensions.capa.cost`, `rest` the
518
+ REST request each root field equals, from `extensions.capa.rest`, and
519
+ `environment` the key's, from `extensions.capa.environment`. The API sends
520
+ `extensions.capa` to a production key only when the request asks for it, and
521
+ this server always asks.
522
+
523
+ `budget` is `extensions.cost.budget`, which every answer carries: `counted`
524
+ is the figure the API checked against the 5,000-entry `limit` before it read.
525
+ Each relation filter costs 500, so this read counts 502 although it returns 2
526
+ entries; a document whose `counted` nears the limit is the one to trim before
527
+ it is refused. A read that used a field a later API version deprecates also
528
+ gets `deprecations`, one `{ coordinate, reason }` for each, from
529
+ `extensions.deprecations`, which is left out when there is none.
530
+
531
+ A refused query (a typo, a mutation, a budget) is an `isError` result that
532
+ lists each error with its code and hint, then under `Next:` the fix for each
533
+ code and the tool that helps with it, if one does. A refusal is a body with
534
+ `errors` and no `data`, which the API sends as a 200 on `application/json`
535
+ (what this server asks for) and as a 4xx on
536
+ `application/graphql-response+json`, as GraphQL over HTTP has it. Every tool
537
+ reads both the same way, as `HTTP 400`; a query the API ran answers `data`
538
+ with any `errors` beside it. The commonest mistakes get
539
+ the exact fix: a field selected on a list says to select it inside
540
+ `nodes { ... }`, `where` or `limit` says GraphQL calls it `filter` or
541
+ `first`, a bare filter value gets the form with its operator, and a missing
542
+ variable names the variable and its type, and a document of several
543
+ operations names the ones `operationName` can take. For a budget the line
544
+ quotes the API's message and the change its hint names, as the API wrote
545
+ them, then the tool:
546
+
547
+ ```text
548
+ Next:
549
+ - query_too_complex: articles: could read 40,200 entries, over the limit of 5,000. Pass coauthors(first: 24) to fit, or lower first elsewhere. Tool: capa_graphql_build.
550
+ ```
551
+
552
+ Where the hint names no change, the line says which knob the budget turns,
553
+ still with the API's numbers.
554
+
555
+ A cursor passed with another sort than it was issued for is refused by the
556
+ API with `This cursor was issued for a different sort.`, which is what an
557
+ agent pastes:
558
+
559
+ `capa_explain_error { "error": "invalid_cursor: This cursor was issued for a different sort." }`:
560
+
561
+ ```json
562
+ {"errors":[{"code":"invalid_cursor","message":"invalid_cursor: This cursor was issued for a different sort.",
563
+ "meaning":"The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry.",
564
+ "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.",
565
+ "next":"capa_graphql_query","docs":"https://docs.capacms.com/errors/invalid_cursor"}]}
566
+ ```
567
+
568
+ A budget refusal also gets `budget: { measured, limit }`, from the API's own
569
+ message; `measured` keeps an "at least" where the API stopped counting early.
570
+
571
+ It takes a GraphQL `errors` body, a REST error envelope, an SDK `CapaError`,
572
+ plain text or a bare `code`. A request that never reached Capa is told apart
573
+ from an API error: `fetch failed`, `ECONNREFUSED`, `ENOTFOUND` and a timeout
574
+ answer `code: "unreachable"` with the step this server gives for a host it
575
+ cannot reach, and no tool, since none helps. A status is read only where the
576
+ text states one (`HTTP 404`, `status 400`, `Capa API 401`), never from an
577
+ address such as `127.0.0.1`. It calls nothing, so it needs no scope and is
578
+ offered to every key. The server itself still needs `CAPA_API_URL` and
579
+ `CAPA_KEY` to start: one that started with a single tool would hide the
580
+ misconfiguration instead of naming it.
581
+
582
+ `mutations_not_enabled` is the one code with two causes, so its advice
583
+ depends on what came with it. The GraphQL handler's refusal of a mutation
584
+ carries a hint, and gets "Send a query instead; nothing here can write." A
585
+ bare code also gets the other cause: a query sent where GraphQL is off.
586
+
587
+ ### Resources and prompts
588
+
589
+ | resource | what |
590
+ |---|---|
591
+ | `capa://graphql/schema.graphql` | the key's SDL, whole and never cut, for clients that attach a schema as context. `resources/list` gives its `size` in bytes and says to read one model's instead; the tools read the schema in bounded pieces |
592
+ | `capa://graphql/schema/{model}.graphql` | a template: one model's SDL, its type, connection, filter and sort and the shared types they use. `{model}` is a namespace or type name; an unknown one is MCP's resource-not-found error (-32002), with the nearest names |
593
+ | `capa://guide/querying` | the order to call the tools in, the filter and sort grammar, and the limits |
594
+
595
+ `initialize` also answers `instructions`, a few sentences a client can put
596
+ before the model: start with `capa_graphql_schema`, never guess a name it has
597
+ not shown, what a production and a development key read, and where the guide
598
+ is. They name only the tools this key was given.
599
+
600
+ | prompt | arguments | what it asks the agent to do |
601
+ |---|---|---|
602
+ | `explore-content` | `model?` | describe, explore and summarise a model's content, quoting the numbers |
603
+ | `write-query` | `goal`, `model?` | turn a plain request into a working query and hand back GraphQL, REST and SDK code |
604
+ | `fix-query-error` | `error` | explain a failed call and rebuild the query |
605
+
606
+ A prompt is offered only to a key that has every tool it sends the agent to,
607
+ and the guide only with `capa_graphql_schema`, so a key holding nothing but
608
+ `capa_explain_error` gets neither.
609
+
610
+ ## The page tools
611
+
612
+ A **page** is a string starting with `/` that Capa learned about in one of two
613
+ ways, and the list is their union (`docs/api/pages.md`):
614
+
615
+ * **declared**: a model carries a route, `/blog/[slug]` or `/pricing`, set in
616
+ the Capa admin. It exists with zero traffic, because a route somebody just set
617
+ up should show up immediately rather than after the first visitor.
618
+ * **observed**: a read arrived carrying the page as the `Capa-Page` header. It
619
+ exists whether or not any model declares it, because most sites have pages
620
+ Capa knows nothing about.
621
+
622
+ They join on the string, so a page in both is `kind: "both"`. That is what makes
623
+ these tools worth an agent's time: asked to change `/pricing`, an agent
624
+ otherwise has to guess which models render it.
625
+
626
+ The examples in this section are real outputs on the same seed, with the page
627
+ routes on (`CAPA_SITE_PREVIEW=on`) and six entry reads sent with `Capa-Page`.
628
+ The seed declares no routes, so every page here is `observed`.
629
+
630
+ `capa_list_pages` is the map, busiest first:
631
+
632
+ ```json
633
+ {"pages":[
634
+ {"id":"/blog","kind":"observed","models":[{"id":"00000000-0000-4000-8000-000000000003","namespace":"articles","name":"Article"}],
635
+ "reads30d":3,"lastReadAt":"2026-09-25T05:03:06.788Z","slugField":null},
636
+ {"id":"/blog/[slug]","kind":"observed","models":[...],"reads30d":2,"lastReadAt":"2026-09-25T05:03:06.808Z","slugField":null},
637
+ {"id":"/authors","kind":"observed","models":[{"id":"00000000-0000-4000-8000-000000000010","namespace":"authors","name":"Author"}],
638
+ "reads30d":1,"lastReadAt":"2026-09-25T05:03:06.817Z","slugField":null}],
639
+ "total":3,"insights":[]}
640
+ ```
641
+
642
+ Pass `entry: "<uuid>"` and it answers **which pages read that entry**, each row
643
+ carrying an extra `entryReads`: for the article "Epsilon engineering notes",
644
+ `/blog` with `"entryReads":3` and `/blog/[slug]` with `"entryReads":1`. That is the "what breaks if I unpublish this?"
645
+ question. `entryReads` covers a shorter window than `reads30d` (seven days,
646
+ because entry ids live only on the raw read rows), which is why it is its own
647
+ key rather than a narrowing of that number. An entry nothing reads and an id
648
+ that never existed both answer an empty list, deliberately, so the route cannot
649
+ be used to find out which ids exist. That is why a malformed `entry` is
650
+ refused here instead of sent.
651
+
652
+ `capa_get_page` is one page in full, passed through exactly as the API sends it:
653
+
654
+ ```json
655
+ {"page":"/blog/[slug]","kind":"observed",
656
+ "models":[{"id":"00000000-0000-4000-8000-000000000003","namespace":"articles","name":"Article"}],
657
+ "reads30d":2,"readsByDay":[{"day":"2026-09-25T00:00:00.000Z","reads":2}],
658
+ "entries":[{"id":"00000000-0000-4000-8000-000000000011","modelId":"00000000-0000-4000-8000-000000000010","namespace":"authors","title":"Ada Vale","reads":1},
659
+ ...,
660
+ {"id":"00000000-0000-4000-8000-00000000002a","modelId":"00000000-0000-4000-8000-000000000003","namespace":"articles","title":"Kappa keeps it simple","reads":1}],
661
+ "queries":[{"modelId":"00000000-0000-4000-8000-000000000003","namespace":"articles","selectText":"title,body,author(name,bio)","reads":2,
662
+ "lastAt":"2026-09-25T05:03:06.808Z","keyIds":["..."],
663
+ "url":"/api/entries/articles/00000000-0000-4000-8000-000000000025?select=title,body,author(name,bio)",
664
+ "selection":{...},"selectionError":null}],
665
+ "since":"2026-08-27T00:00:00.000Z","slugField":null,"lastReadAt":"2026-09-25T05:03:06.808Z","insights":[]}
666
+ ```
667
+
668
+ `queries` is one row per distinct (model, select) the page asked for, which is
669
+ how a page fetching far more than it renders shows up. An entry whose `modelId`
670
+ and `namespace` are empty is one Capa no longer holds a row for, and it is kept
671
+ on purpose: "this page reads something that is gone" is the most useful thing
672
+ that list can say. A `null` title alone means only that the entry's row stores
673
+ no title, which is common. The
674
+ response is handed over **verbatim**, so fields the API adds later reach an
675
+ agent without this package being edited. A page nobody declared and nobody has
676
+ read answers `page_not_found` in band, with the pages this project does have.
677
+
678
+ `capa_suggest_queries` is the same page's suggestions, plus the queries they are
679
+ about:
680
+
681
+ ```json
682
+ {"page":"/blog",
683
+ "insights":[{"kind":"overfetch","severity":"warn","title":"This page asks for the whole entry.",
684
+ "detail":"This read sends no select, so every field comes back and each relation is expanded as well. Naming the fields the page renders stops the relation rows being fetched at all.",
685
+ "evidence":{"fieldCount":7,"relationCount":2,"reads":1},"modelId":"00000000-0000-4000-8000-000000000003",
686
+ "queryKey":"00000000-0000-4000-8000-000000000003:*","rewrite":{"select":"title,body,views,featured,tags"}}],
687
+ "queries":[{"modelNamespace":"articles","selectText":"title,views,author(name)","reads":2},
688
+ {"modelNamespace":"articles","selectText":"*","reads":1}]}
689
+ ```
690
+
691
+ **It computes nothing.** The rules that read a page's queries and say "this one
692
+ fetches more than it renders" run on the API, so the Capa admin and an agent
693
+ read one answer rather than two implementations that drift, and this package
694
+ stays dependency-free rather than growing a select parser. An empty `insights`
695
+ is an answer, not a failure, and `note` says which kind of empty it is: no reads
696
+ in the window at all, or reads the API had nothing to say about.
697
+
698
+ ### Which tools a key is offered
699
+
700
+ The server takes one key and registers by its **family**, decided at startup
701
+ from the prefix:
702
+
703
+ | key | gets |
704
+ |---|---|
705
+ | `cap_…` | the eight `/api/` and offline tools, and nothing else (`capa_read_entries` in place of the four GraphQL tools where GraphQL is off) |
706
+ | any other key: `pk_…`, `sk_…` or unprefixed | those eight **and** all ten legacy tools |
707
+
708
+ A `cap_` key is refused on every legacy mount before the lookup even runs, so
709
+ offering it `capa_list_models` would be offering a tool that cannot work. A
710
+ legacy key loses nothing: `/api/` accepts it and derives its scopes from the
711
+ key's bundle.
712
+
713
+ Then `GET /api/me` is asked what the key holds, and an `/api/` tool whose scope
714
+ is missing is left out rather than registered and refused every time. The page
715
+ routes want **unscoped** `instance:read`: a key holding only
716
+ `instance:read:<modelId>` does not get them, because a page list spans every
717
+ model a page touches and there is no per-model projection of it that is still a
718
+ page list.
719
+
720
+ The GraphQL tools are the opposite case, on purpose: they register for any key
721
+ holding `instance:read` or an `instance:read:<modelId>` scope, because
722
+ `/api/graphql` builds its schema per key and a one-model key reads that model
723
+ there and nothing else. `capa_explain_error` never calls Capa and is offered
724
+ to every key. When the key's scopes leave a tool out, a line on stderr says
725
+ which scopes the key holds and what the missing tools need, so a person
726
+ reading the tool list knows why it is short.
727
+
728
+ The page routes are served only where `CAPA_SITE_PREVIEW` is on, and it is
729
+ off by default. So at startup, beside `/api/me`, the server asks
730
+ `GET /api/preview` with no token: `route_not_found` means the deployment does
731
+ not serve pages, and the three page tools are left out, with a line on
732
+ stderr. Any other answer, a 401 included, means the route is there. A page
733
+ tool called anyway on such a deployment answers `pages_not_enabled` and points
734
+ at `capa_graphql_schema` and `capa_explore_data`.
735
+
736
+ GraphQL is on by default and `CAPA_API_GRAPHQL=off` switches it off, so the
737
+ server also asks `POST /api/graphql` with `{ __typename }`, a POST because an
738
+ admin-role host serves GraphQL by POST only. Only the answer of a path the API
739
+ does not serve, in the REST envelope, means no: then the four GraphQL tools,
740
+ the schema resource, the guide and the prompts are left out, and
741
+ `capa_read_entries` is registered in their place, with a line on stderr that
742
+ names it. Any answer from the GraphQL handler, a refusal included, means the
743
+ route is there. `capa_explain_error` stays, since it calls nothing.
744
+
745
+ GraphQL leaves a model out when its type name would be the same as another's
746
+ (`twin_a` and `twin-a`), and the schema lists it as `Models not exposed in
747
+ GraphQL`. REST still reads it, so where the key's schema leaves one out, the
748
+ server also reads the schema at startup and registers `capa_read_entries`
749
+ beside the GraphQL tools, described as the read for those models, with a line
750
+ on stderr that names them. `capa_graphql_schema` lists them among the models
751
+ as `"graphql": "none: readable over REST only; capa_read_entries reads it"`
752
+ with their REST path, and every GraphQL tool asked for one answers
753
+ `"twin_a is readable over REST only: GET /api/entries/twin_a. GraphQL leaves it
754
+ out, since it and twin-a would have the same GraphQL type name."` with
755
+ `"next": "capa_read_entries"`, never a suggestion of another model.
756
+
757
+ If `/api/me` does not answer (a deployment with the `/api/` surface switched
758
+ off answers 404, an unreachable host answers nothing), the `/api/` tools register
759
+ anyway and the reason goes to stderr. Neither failure is evidence about what the
760
+ key may do, and dropping the tools there would turn a reachability problem into
761
+ "Capa has no page tools", which is the wrong conclusion for an agent and an
762
+ impossible one for a person to debug from a tool list. After a 404 from
763
+ `/api/me`, a GraphQL tool whose request is refused the same way says that
764
+ either `CAPA_API_URL` is not the API's address or the deployment does not
765
+ serve `/api/`, rather than that GraphQL is off.
766
+
767
+ ## The layout tool
768
+
769
+ A **layout** says how one model's entry editor is arranged: cards in a main
770
+ column and a side column, each field placed in one of them, and relation fields
771
+ optionally shown as editable line items
772
+ (`docs/design/ADMIN_UI_OVERHAUL.md` section 0m, and `apps/api/LAYOUT.md` for the
773
+ whole contract). `layout: null` is the plain linear editor, which is what every
774
+ model has until somebody designs one.
775
+
776
+ A relation field takes `display: "picker"`, `"inline"` or `"embedded"`.
777
+ `embedded` (section 0m.8) draws the related entry's own fields directly in the
778
+ parent card rather than as a link or a row. With no `display` on the node, the
779
+ RELATED model's own `embedByDefault` decides, which is why `capa_get_model`
780
+ reports that flag as well: it is the fact a layout written for a parent depends
781
+ on. A node's explicit `display` always wins over it.
782
+
783
+ `capa_get_model` returns the document, and `capa_set_model_layout` takes the
784
+ same one back, so an agent reads, edits and writes. If the key could not read
785
+ the model's detail, `capa_get_model` omits `layout` and `embedByDefault`
786
+ ALTOGETHER and says why in `layoutUnavailable`. `layout: null` is a claim that
787
+ the model has no page, `embedByDefault: false` a claim that it does not pass
788
+ through, and neither is ever made on a guess:
789
+
790
+ ```json
791
+ {
792
+ "v": 1,
793
+ "main": [
794
+ { "id": "c1", "title": "Hero", "collapsible": false, "collapsed": false,
795
+ "items": [{ "id": "n1", "fieldId": "<field id>", "width": "full" }] }
796
+ ],
797
+ "aside": []
798
+ }
799
+ ```
800
+
801
+ Fields are placed by **id**, which is why `capa_get_model` now returns one per
802
+ field. It costs a second request for one model, and without it the write tool
803
+ could not be used at all. A field may appear at most once in the whole
804
+ document; the fields left out are not lost, they render in a "More fields" card
805
+ at the end.
806
+
807
+ A rejected document is answered with the NODE it is about,
808
+ `{ error, path: "main[0].items[2].fieldId" }`, so a retry can fix one thing
809
+ rather than guess. Unlike the workspace tools, this one needs a key whose
810
+ permission is `agent`: it writes a model, and that is the gate every other
811
+ model write asks for.
812
+
813
+ ## The workspace tools
814
+
815
+ A **workspace** is one saved arrangement of the Capa admin's left rail: which
816
+ models, entries and media folders a person or a team keeps in reach, in which
817
+ folders, in which order (`docs/design/ADMIN_UI_OVERHAUL.md` sections 0h and 0t).
818
+ It is navigation, not storage: a workspace node points at a record, and removing
819
+ it changes nothing about the record.
820
+
821
+ `capa_get_workspace` and `capa_set_workspace` speak the **same JSON document**,
822
+ so an agent can read one, edit it, and write it back:
823
+
824
+ ```json
825
+ {
826
+ "name": "Team",
827
+ "tree": [
828
+ { "folder": "Schema", "children": [{ "model": "blog_post" }] },
829
+ {
830
+ "folder": "Launch",
831
+ "children": [{ "instance": "0d1f…" }, { "media_folder": "9a2c…" }]
832
+ }
833
+ ]
834
+ }
835
+ ```
836
+
837
+ Models are addressed by **namespace or id**; entries and media folders by id.
838
+ A `Node` is `{folder, children?}`, `{model}`, `{instance}` or `{media_folder}`.
839
+ A folder is named by the customer and holds any mix of the three, so there are
840
+ no fixed sections to choose between: the folders in the example are words
841
+ somebody typed, not categories of ours.
842
+
843
+ `template` on a create starts a workspace with a handful of folders:
844
+ `"blank"` (the default) makes none, `"website"` makes Pages, Components,
845
+ Settings and Media, `"catalog"` makes Products, Collections and Assets. It is
846
+ ignored when applying to a workspace that already exists.
847
+
848
+ `mode` defaults to `"merge"`, which adds what is missing and removes nothing.
849
+ `"replace"` makes the workspace exactly the document. Applying the same document
850
+ twice is a no-op, and the `changes` summary says so, so a retry is safe and an
851
+ agent can tell whether it did anything.
852
+
853
+ **The old three-key shape still works, for one release.** A `tree` of
854
+ `{ model, content, media }` is accepted and lands in folders called Models,
855
+ Content and Media, keeping the rule it came with: a key the document does not
856
+ name is left alone even in `replace` mode, so a half document cannot empty a
857
+ section by omission. Send a `Node[]` instead.
858
+
859
+ ## Tests
860
+
861
+ `pnpm -F @capacms/mcp test` runs every case with no network beyond localhost. The
862
+ GraphQL tools run against `test/support/graphql-stub.mjs`, a local
863
+ `/api/graphql` that serves the seed schema from `packages/sdk/test/fixtures`
864
+ with a small content set of its own, not the seed's. It also serves the
865
+ stored values of `/api/entries/<model>` that `capa_explore_data` reads, and
866
+ with `{ graphql: false }` answers as `CAPA_API_GRAPHQL=off` does. Run it on
867
+ its own with `node test/support/graphql-stub.mjs 4461` to drive the stdio
868
+ server by hand. The worked examples above come from a real API, not the stub.
869
+ The tests also hold the context budget: each new description at most 700
870
+ characters, the four reading tools' descriptions and schemas at most 4,000,
871
+ the five output schemas at most 4,000, and `capa_graphql_schema` on the seed
872
+ at most 1,500 (4,000 for one model). The write tools are driven
873
+ through a fetch stub that records the METHOD and the body, because "it sent a
874
+ PUT to /tree with mode merge" is the only thing separating applying a document
875
+ from asking for one. The stub records the HEADERS for the same reason on the
876
+ other surface: that `/api/` sends `Capa-Version` and never sends `X-Tenant-Key`
877
+ is invisible in any response body. Already enforced in CI via `turbo run test`.
878
+
879
+ (Not `node --test packages/mcp/test/`: passing a DIRECTORY to `node --test` is
880
+ resolved as a module path on Node 22 and fails with MODULE_NOT_FOUND. The
881
+ package script names the file explicitly, which is the house pattern.)