@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 +881 -0
- package/bin/capa-mcp.mjs +96 -0
- package/lib/annotations.mjs +27 -0
- package/lib/answers.mjs +69 -0
- package/lib/arguments.mjs +140 -0
- package/lib/bound.mjs +546 -0
- package/lib/client.mjs +512 -0
- package/lib/error-guide.mjs +750 -0
- package/lib/explore.mjs +471 -0
- package/lib/graphql/build.mjs +725 -0
- package/lib/graphql/document.mjs +388 -0
- package/lib/graphql/filter-values.mjs +92 -0
- package/lib/graphql/more.mjs +97 -0
- package/lib/graphql/names.mjs +131 -0
- package/lib/graphql/schema.mjs +237 -0
- package/lib/graphql/sdl.mjs +144 -0
- package/lib/graphql/served.mjs +82 -0
- package/lib/graphql-tools.mjs +1177 -0
- package/lib/guide.mjs +55 -0
- package/lib/instructions.mjs +32 -0
- package/lib/prompts.mjs +68 -0
- package/lib/registry.mjs +235 -0
- package/lib/resources.mjs +134 -0
- package/lib/rest-tools.mjs +176 -0
- package/lib/server.mjs +194 -0
- package/lib/session.mjs +90 -0
- package/lib/suggest.mjs +32 -0
- package/lib/tools.mjs +1158 -0
- package/package.json +24 -0
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.)
|