@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/lib/tools.mjs ADDED
@@ -0,0 +1,1158 @@
1
+ /**
2
+ * tools.mjs — the tool set.
3
+ *
4
+ * #4583 is explicit that this must NOT be a thin wrapper exposing every
5
+ * endpoint: "a deliberately shaped tool set that gets an agent to the right
6
+ * context fast". So the shaping rules, stated because the next person to add a
7
+ * tool needs them:
8
+ *
9
+ * 1. A tool exists to answer a QUESTION an agent has, not to mirror a route.
10
+ * `capa_get_model` answers "what shape is this content?" — it does not
11
+ * correspond to any single endpoint.
12
+ * 2. Every tool trims. /v2/schema returns 20 models with every field, which
13
+ * is thousands of tokens an agent mostly does not need; `capa_list_models`
14
+ * returns names and counts, and `capa_get_model` returns the detail for
15
+ * one. Two calls beats one huge one.
16
+ * 3. Filtering happens server-side wherever the API supports it, and in this
17
+ * file where it does not — an agent should never have to fetch everything
18
+ * to find one thing.
19
+ *
20
+ * MOSTLY READ, AND NO LONGER READ-ONLY. The three `capa_*_workspace` tools at
21
+ * the bottom write, through `/v2/agent/workspaces` (ADMIN_UI_OVERHAUL 0h.4b).
22
+ * They need a key with `write` permission and surface the API's own 403 when
23
+ * the key has only `read` — see the corrected note in client.mjs, which used to
24
+ * claim no write verb existed behind a key at all.
25
+ *
26
+ * The rest of #4583's write half (edit content, validate, publish) still needs
27
+ * tools of its own against `/v2/agent/model-instances`. Absent rather than
28
+ * stubbed, because a tool that always fails teaches an agent to stop trying.
29
+ *
30
+ * WHY /v2/seo/ai-bundle IS NOT A TOOL, having been measured rather than assumed
31
+ * It is API-key reachable and the source calls it a "RAG-ready export across
32
+ * every model", so it looks like an obvious fit. It is not. On one ordinary
33
+ * tenant (20 models, 664 documents) it returns:
34
+ *
35
+ * 1,624,988 bytes — roughly 406,000 tokens
36
+ *
37
+ * That does not fit in any context window, and bounding it does not rescue it:
38
+ * at limit=1 the schemas block alone is 16.7 KB before a single document. The
39
+ * endpoint is built for a RAG INGESTION pipeline, which reads once and writes
40
+ * to a vector store — not for an agent's context, which is the resource rule 2
41
+ * above exists to protect.
42
+ *
43
+ * If we do want it, the right shape is a tool that WRITES THE BUNDLE TO A FILE
44
+ * and returns the path, so the bytes never enter the conversation. That is a
45
+ * different tool with a different contract, and it should be built deliberately
46
+ * rather than by pointing a `capa_get_*` at a large endpoint.
47
+ *
48
+ * TWO SURFACES, AND EVERY TOOL SAYS WHICH IT IS ON.
49
+ *
50
+ * `surface: "legacy"` means the tool calls `/v2/*` or `/v3/*`, which a `pk_` or
51
+ * `sk_` key reaches and a `cap_` key is refused on before any lookup.
52
+ * `surface: "api"` means it calls `/api/`, which both families reach.
53
+ * `lib/registry.mjs` turns those two words into the tool list one key actually
54
+ * gets, so an agent is never offered a tool its key cannot use.
55
+ *
56
+ * An `api` tool also declares the `scope` it needs. `/api/me` reports the
57
+ * scopes a key holds, and a tool whose scope is missing from that list is left
58
+ * out rather than registered and refused. The field is a string compared
59
+ * whole: the page routes ask for UNSCOPED `instance:read`, so a key holding
60
+ * only `instance:read:<modelId>` does not get them, which is the same answer
61
+ * the API would give.
62
+ */
63
+ import { reads, writes } from "./annotations.mjs";
64
+ import { DEFAULT_MAX_CHARS, someNames } from "./bound.mjs";
65
+ import { apiGet, apiGetText, apiNextGet, apiWrite, CapaApiError } from "./client.mjs";
66
+ import { GRAPHQL_TOOLS } from "./graphql-tools.mjs";
67
+ import { REST_TOOLS } from "./rest-tools.mjs";
68
+
69
+ /**
70
+ * The legacy tools name a model `namespace`, as `/v2` does; the GraphQL tools
71
+ * teach `model`, so either is read (server.mjs `withAliases`).
72
+ */
73
+ const MODEL_ALIAS = { aliases: { model: "namespace" } };
74
+
75
+ /** What a types answer cut to the budget tells the agent. */
76
+ const TYPES_HINT = "Pass namespace for one model's interface and the types it uses.";
77
+ const typeNameOf = (block) => (/^export (?:interface|type) (\w+)/m.exec(block) || [])[1];
78
+
79
+ /**
80
+ * The types answer for `blocks` of generated TypeScript, cut between whole
81
+ * declarations when it is over the answer budget: half a declaration does not
82
+ * compile, and a clipped string of source is worse than none. `bytes` is the
83
+ * size of everything asked for, and `notPrinted` names what was left out.
84
+ */
85
+ function typesAnswer(blocks, extra = {}) {
86
+ const source = blocks.join("\n");
87
+ const whole = { source, bytes: source.length, ...extra };
88
+ if (JSON.stringify(whole).length <= DEFAULT_MAX_CHARS) return whole;
89
+ const cut = (count) => ({
90
+ ...whole,
91
+ source: blocks.slice(0, count).join("\n"),
92
+ truncated: [{ path: "source", kept: count, total: blocks.length }],
93
+ notPrinted: someNames(blocks.slice(count).map(typeNameOf).filter(Boolean)),
94
+ hint: TYPES_HINT,
95
+ });
96
+ // The serialized size grows with the count, so the largest count that fits is found by bisection.
97
+ let fits = 0;
98
+ let over = blocks.length;
99
+ while (over - fits > 1) {
100
+ const middle = (fits + over) >> 1;
101
+ if (JSON.stringify(cut(middle)).length <= DEFAULT_MAX_CHARS) fits = middle;
102
+ else over = middle;
103
+ }
104
+ return cut(fits);
105
+ }
106
+
107
+ /** /v2/schema is the one call that describes everything; cache it per process. */
108
+ let schemaCache = null;
109
+ export function __resetSchemaCacheForTests() {
110
+ schemaCache = null;
111
+ }
112
+ async function schema(config) {
113
+ if (!schemaCache) schemaCache = await apiGet(config, "/v2/schema");
114
+ return schemaCache;
115
+ }
116
+
117
+ const lc = (s) => String(s ?? "").toLowerCase();
118
+
119
+ /**
120
+ * Capa stores most values as `{ type, value, sortOrder }` rather than bare
121
+ * scalars — including `title`, which reads like a plain column and is not.
122
+ *
123
+ * Handing an agent `{"type":"string","value":"Influencers and Ambassadors",
124
+ * "sortOrder":0}` where it expected a title is three times the tokens and
125
+ * invites it to reach for `.value` itself, which it will then do inconsistently.
126
+ *
127
+ * Found by driving the server against real data: the unit test's fake row had
128
+ * `title: "Hello"`, a plain string, so it passed. The fake was more uniform
129
+ * than production — the same failure this repo documented in
130
+ * docs/P2_DATA_IN.md §8 about the seed, reproduced in a test fixture within
131
+ * hours of writing that section down.
132
+ */
133
+ const unwrap = (v) =>
134
+ v && typeof v === "object" && !Array.isArray(v) && "value" in v ? v.value : v ?? null;
135
+
136
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
137
+
138
+ /**
139
+ * The instance id.
140
+ *
141
+ * `instanceId` FIRST, for the day a server sends it. No server does today:
142
+ * #4628 added it to every row of `/v2/api/:ns` and the public-surface freeze
143
+ * reverted that on 2026-09-20 (PARITY_EXCEPTIONS.md #22), because that surface
144
+ * answers the bytes the legacy API answers and a key like this ships on the
145
+ * next API version. So the fallback below is what actually runs.
146
+ *
147
+ * `id` is the fallback, and it is a fallback because it is not trustworthy. The
148
+ * route builds each row as `{...instance, ...formatInstanceData(instance)}`. The
149
+ * model's own fields are hoisted to the top level and spread SECOND, so a field
150
+ * named `id` OVERWRITES the instance's id and the real UUID then appears nowhere
151
+ * in the row. Same for `title` and `tags`. In one ordinary tenant, 20 of 20
152
+ * models have a field named `title` and 7 of 20 have one named `id` (every
153
+ * shopify_* model, plus judge_me_review), so this is the common case, and the
154
+ * UUID test is what separates an instance id from a Shopify numeric id sitting
155
+ * in a field called `id` on a server that predates the fix.
156
+ *
157
+ * null means neither was usable: a shadowing model on a server that sends no
158
+ * `instanceId`, which is every server today. The first
159
+ * version of these tools returned `row.id` raw, so capa_list_content handed back
160
+ * {"type":"string","value":"41269952589"} as an id and the capa_get_content call
161
+ * it invites 400ed. Returning null and saying why beats returning something that
162
+ * looks like an id and is not.
163
+ */
164
+ const instanceIdOf = (row) => {
165
+ if (typeof row?.instanceId === "string" && UUID_RE.test(row.instanceId)) return row.instanceId;
166
+ return typeof row?.id === "string" && UUID_RE.test(row.id) ? row.id : null;
167
+ };
168
+
169
+ export const TOOLS = [
170
+ {
171
+ name: "capa_list_models",
172
+ ...reads("List content models"),
173
+ cutHint: "Pass search to list only the models whose namespace or name matches.",
174
+ surface: "legacy",
175
+ description:
176
+ "List the content models in this Capa tenant: namespace, human name, field count, " +
177
+ "and what each one relates to, from the legacy /v2 API. Returns names only; use " +
178
+ "capa_get_model for one model's fields. To write or run a query against content, start " +
179
+ "from capa_graphql_schema instead.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ search: {
184
+ type: "string",
185
+ description: "Case-insensitive substring match on namespace or name. Omit to list all.",
186
+ },
187
+ },
188
+ additionalProperties: false,
189
+ },
190
+ handler: async (config, args) => {
191
+ const s = await schema(config);
192
+ const relByFrom = new Map();
193
+ for (const r of s.relations ?? []) {
194
+ if (!relByFrom.has(r.fromModel)) relByFrom.set(r.fromModel, []);
195
+ relByFrom.get(r.fromModel).push(`${r.fromField} -> ${r.toModel ?? "(dangling)"}`);
196
+ }
197
+ const q = lc(args.search);
198
+ const models = (s.models ?? [])
199
+ .filter((m) => !q || lc(m.namespace).includes(q) || lc(m.modelName).includes(q))
200
+ .map((m) => ({
201
+ namespace: m.namespace,
202
+ name: m.modelName,
203
+ singleInstance: m.isSingleInstance,
204
+ fieldCount: (m.fields ?? []).length,
205
+ relatesTo: relByFrom.get(m.namespace) ?? [],
206
+ }));
207
+ return { models, total: models.length, checksum: s.checksum };
208
+ },
209
+ },
210
+
211
+ {
212
+ name: "capa_get_model",
213
+ ...reads("Get a model and its editor layout"),
214
+ whole: true,
215
+ surface: "legacy",
216
+ ...MODEL_ALIAS,
217
+ description:
218
+ "Full field definitions for ONE model as the editor sees them: every field's namespace, " +
219
+ "type, whether it is required, what a relation points at, and the editor layout. Use it " +
220
+ "before capa_set_model_layout or to check required fields. To query the model's content, " +
221
+ "capa_graphql_schema { model } gives the names a query takes.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ namespace: { type: "string", description: "The model's namespace, e.g. \"blog_post\"." },
226
+ },
227
+ required: ["namespace"],
228
+ additionalProperties: false,
229
+ },
230
+ handler: async (config, args) => {
231
+ const s = await schema(config);
232
+ const model = (s.models ?? []).find((m) => m.namespace === args.namespace);
233
+ if (!model) {
234
+ const near = (s.models ?? [])
235
+ .map((m) => m.namespace)
236
+ .filter((n) => lc(n).includes(lc(args.namespace)) || lc(args.namespace).includes(lc(n)));
237
+ return {
238
+ error: `No model with namespace "${args.namespace}".`,
239
+ // A bare "not found" makes an agent guess again; naming the near
240
+ // misses and the full list lets it correct in one step.
241
+ didYouMean: near,
242
+ available: (s.models ?? []).map((m) => m.namespace),
243
+ };
244
+ }
245
+
246
+ // A SECOND request, for the two things /v2/schema does not carry: the
247
+ // field IDS and the entry-editor LAYOUT (ADMIN_UI_OVERHAUL 0m.4).
248
+ //
249
+ // They travel together because they are useless apart: a layout places
250
+ // fields by id, so an agent handed a layout and no ids can read the page
251
+ // but not change it. One extra GET for one model is the cost of
252
+ // capa_set_model_layout being usable at all, and it is not paid by
253
+ // capa_list_models, which stays one call for the whole tenant.
254
+ let detail = null;
255
+ let unavailable;
256
+ try {
257
+ detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(model.namespace)}`);
258
+ } catch (error) {
259
+ // Reported rather than swallowed: `layout: null` means LINEAR, and
260
+ // saying that when the truth is "this key could not read it" would send
261
+ // an agent off to build a layout that is about to collide with one.
262
+ unavailable =
263
+ error instanceof CapaApiError
264
+ ? `Field ids and layout are unavailable: ${error.message}`
265
+ : String(error?.message ?? error);
266
+ }
267
+ const idByNamespace = new Map(
268
+ (detail?.fields ?? []).map((f) => [f.namespace, f.id]),
269
+ );
270
+
271
+ return {
272
+ namespace: model.namespace,
273
+ name: model.modelName,
274
+ singleInstance: model.isSingleInstance,
275
+ fields: (model.fields ?? []).map((f) => ({
276
+ // The id a layout node's `fieldId` has to be. Absent only when the
277
+ // detail call failed, which `layoutUnavailable` then says.
278
+ id: idByNamespace.get(f.namespace),
279
+ namespace: f.namespace,
280
+ name: f.name,
281
+ type: f.arrayType ? `${f.type}<${f.arrayType}>` : f.type,
282
+ required: f.required,
283
+ relatesTo: f.relationRef ?? undefined,
284
+ enumValues: f.enumValues ?? undefined,
285
+ })),
286
+ relations: (s.relations ?? []).filter((r) => r.fromModel === model.namespace),
287
+ // null = the linear editor, which is what a model without a designed
288
+ // page gets. Write it with capa_set_model_layout.
289
+ //
290
+ // ABSENT rather than null when the detail call failed. null is a claim
291
+ // — "this model has no page" — and emitting it for "this key could not
292
+ // read it" is the exact mistake the catch above exists to avoid. An
293
+ // agent that finds no `layout` key cannot read the failure as an
294
+ // answer; `layoutUnavailable` then says which failure it was.
295
+ ...(detail ? { layout: detail.layout ?? null } : {}),
296
+ // 0m.8. TRUE means a relation field pointing at THIS model renders its
297
+ // fields inside the parent form rather than as a picker, wherever the
298
+ // layout node does not say otherwise — including in linear mode. An
299
+ // agent laying out a parent needs it to know what a relation node with
300
+ // no `display` will actually do. Absent for the same reason `layout`
301
+ // is when the detail call failed: `false` is a claim.
302
+ ...(detail ? { embedByDefault: detail.embedByDefault === true } : {}),
303
+ ...(unavailable ? { layoutUnavailable: unavailable } : {}),
304
+ };
305
+ },
306
+ },
307
+
308
+ {
309
+ name: "capa_set_model_layout",
310
+ ...writes("Set a model's editor layout", { idempotent: true }),
311
+ whole: true,
312
+ surface: "legacy",
313
+ aliases: { model: "modelId" },
314
+ description:
315
+ "Design a model's entry editor: cards in a main column and a side column, with each field " +
316
+ "placed in one of them. WRITES: it needs an API key whose permission is `agent`. Pass " +
317
+ "layout: null to reset the model to the plain linear editor. The document is " +
318
+ "{ v: 1, main: Card[], aside: Card[] } where a Card is { id, title, description?, " +
319
+ "collapsible, collapsed, items: Field[] } and a Field is { id, fieldId, width, display?, " +
320
+ "inline? }. `id` is any id you choose, unique in the document; `fieldId` is the field's id " +
321
+ "from capa_get_model. Widths are full, two_thirds, half or third. Relation fields may take " +
322
+ "display \"picker\", \"inline\" or \"embedded\" (embedded draws the related entry's own " +
323
+ "fields directly in the parent card), plus inline { allowCreate, allowRemove, " +
324
+ "allowReorder, summary } where summary is up to 3 field ids OF THE RELATED model. Leave " +
325
+ "display out and the related model's own embedByDefault decides, which capa_get_model " +
326
+ "reports. Read the current one with capa_get_model first: it returns the same document, " +
327
+ "so you can edit and write it back.",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: {
331
+ modelId: {
332
+ type: "string",
333
+ description: "The model's id, or its namespace; either works.",
334
+ },
335
+ layout: {
336
+ type: ["object", "null"],
337
+ description:
338
+ "The layout document, or null to reset the model to the linear editor.",
339
+ additionalProperties: true,
340
+ },
341
+ },
342
+ required: ["modelId", "layout"],
343
+ additionalProperties: false,
344
+ },
345
+ handler: async (config, args) => {
346
+ const path = `/v2/agent/models/${encodeURIComponent(args.modelId)}/layout`;
347
+ try {
348
+ const res = await apiWrite(config, "PUT", path, { layout: args.layout ?? null });
349
+ return { layout: res?.layout ?? null };
350
+ } catch (error) {
351
+ if (error instanceof CapaApiError && error.status === 400) {
352
+ // The API's rejection names the offending node —
353
+ // "main[0].items[2].fieldId" — and that path is the whole value of
354
+ // the message to an agent about to try again. Passed through as
355
+ // fields rather than buried in a thrown string.
356
+ let body = null;
357
+ try {
358
+ body = JSON.parse(error.body);
359
+ } catch {
360
+ body = null;
361
+ }
362
+ return {
363
+ error: body?.error ?? error.message,
364
+ path: body?.path,
365
+ hint:
366
+ "Fix the node named by `path` and send the document again. Field ids come from " +
367
+ "capa_get_model; a field can appear at most once in the whole layout.",
368
+ };
369
+ }
370
+ if (error instanceof CapaApiError && error.status === 403) {
371
+ return {
372
+ error: error.message,
373
+ hint:
374
+ "This tool writes a model, so it needs a Capa API key whose permission is `agent`. " +
375
+ "`read`, `write` and `delete` keys can call capa_get_model but not this one.",
376
+ };
377
+ }
378
+ throw error;
379
+ }
380
+ },
381
+ },
382
+
383
+ {
384
+ name: "capa_list_content",
385
+ ...reads("List entries (legacy API)"),
386
+ cutHint: "Ask for a smaller limit or fewer fields, and page on with page.",
387
+ surface: "legacy",
388
+ ...MODEL_ALIAS,
389
+ description:
390
+ "List published content instances for a model, with paging. Returns a trimmed summary " +
391
+ "per item (id, title, and the fields you name) rather than whole documents; ask for " +
392
+ "capa_get_content when you need one in full. To filter, sort or follow relations, use " +
393
+ "capa_graphql_build.",
394
+ inputSchema: {
395
+ type: "object",
396
+ properties: {
397
+ namespace: { type: "string", description: "Model namespace to list." },
398
+ limit: { type: "number", description: "Max items (default 20, server caps apply)." },
399
+ page: { type: "number", description: "1-based page number." },
400
+ fields: {
401
+ type: "array",
402
+ items: { type: "string" },
403
+ description: "Field namespaces to include in each summary. Omit for id + title only.",
404
+ },
405
+ },
406
+ required: ["namespace"],
407
+ additionalProperties: false,
408
+ },
409
+ handler: async (config, args) => {
410
+ const res = await apiGet(config, `/v2/api/${encodeURIComponent(args.namespace)}`, {
411
+ limit: args.limit ?? 20,
412
+ page: args.page,
413
+ depth: 1,
414
+ });
415
+ const want = Array.isArray(args.fields) ? args.fields : [];
416
+ let shadowed = 0;
417
+ const items = (res.data ?? []).map((row) => {
418
+ const id = instanceIdOf(row);
419
+ if (id === null) shadowed++;
420
+ const out = { id, title: unwrap(row.title) };
421
+ if (id === null) out.idUnavailable = unwrap(row.id);
422
+ for (const f of want) out[f] = unwrap(row?.data?.[f]);
423
+ return out;
424
+ });
425
+ const out = { items, meta: res.meta };
426
+ if (shadowed) {
427
+ // Said once per call rather than per row. It does NOT name
428
+ // capa_search_content as a way out: that tool sends extended=true, and
429
+ // the extended search path hoists the model's own fields exactly as
430
+ // this one does, so it is shadowed in precisely the same cases.
431
+ out.warning =
432
+ `${shadowed} of ${items.length} rows have no usable id: the model "${args.namespace}" ` +
433
+ `has a field named "id", which overwrites the instance id in this endpoint's response, ` +
434
+ `and the public read API sends no \`instanceId\` alongside it (#4628 was reverted ` +
435
+ `under the public-surface freeze). capa_get_content cannot be called for these rows. ` +
436
+ `The value shown as idUnavailable is the model's own id field, not a Capa id.`;
437
+ }
438
+ return out;
439
+ },
440
+ },
441
+
442
+ {
443
+ name: "capa_get_content",
444
+ ...reads("Get one entry (legacy API)"),
445
+ cutHint: "Ask for a smaller depth: each level of relations adds every related entry in full.",
446
+ surface: "legacy",
447
+ ...MODEL_ALIAS,
448
+ description:
449
+ "One content instance in full, including related content up to the depth you ask for. " +
450
+ "Use after capa_list_content has told you which id you want.",
451
+ inputSchema: {
452
+ type: "object",
453
+ properties: {
454
+ namespace: { type: "string", description: "Model namespace." },
455
+ id: { type: "string", description: "The instance id." },
456
+ depth: {
457
+ type: "number",
458
+ description: "How many relation hops to follow (default 1). Higher is much larger.",
459
+ },
460
+ },
461
+ required: ["namespace", "id"],
462
+ additionalProperties: false,
463
+ },
464
+ handler: async (config, args) => {
465
+ // Fail here rather than spending a request on a guaranteed 400. The ids
466
+ // capa_list_content can return for a shadowing model are the model's own
467
+ // id field, which is not a UUID and which the API rejects. See #4628.
468
+ if (!UUID_RE.test(String(args.id ?? ""))) {
469
+ return {
470
+ error: `"${args.id}" is not a Capa instance id.`,
471
+ hint:
472
+ `Capa instance ids are UUIDs. If capa_list_content returned this value, the model ` +
473
+ `has a field named "id" that overwrites the instance id in that endpoint's response ` +
474
+ `and the public read API sends no \`instanceId\` beside it (#4628 was reverted under ` +
475
+ `the public-surface freeze). capa_search_content is NOT a way around this: it sends ` +
476
+ `extended=true and the extended search path hoists the same fields the same way. ` +
477
+ `There is no way to recover the instance id from a public read response for a model ` +
478
+ `that shadows \`id\`; use the admin API, or wait for the next API version.`,
479
+ };
480
+ }
481
+ const res = await apiGet(config, `/v2/api/${encodeURIComponent(args.namespace)}`, {
482
+ ids: args.id,
483
+ depth: args.depth ?? 1,
484
+ limit: 1,
485
+ });
486
+ const item = (res.data ?? [])[0];
487
+ if (!item) return { error: `No instance ${args.id} in model "${args.namespace}".` };
488
+ return { item, relations: res.relations };
489
+ },
490
+ },
491
+
492
+ {
493
+ name: "capa_search_content",
494
+ ...reads("Search content"),
495
+ cutHint: "Ask for a smaller limit, or pass namespace to search one model.",
496
+ surface: "legacy",
497
+ ...MODEL_ALIAS,
498
+ description:
499
+ "Full-text search across this tenant's content, optionally within one model. Use when " +
500
+ "you know roughly WHAT you are looking for but not which model or id it lives in. " +
501
+ "When you know the model, capa_graphql_build filters it by field instead.",
502
+ inputSchema: {
503
+ type: "object",
504
+ properties: {
505
+ q: { type: "string", description: "The search text. Required and must be non-empty." },
506
+ namespace: {
507
+ type: "string",
508
+ description: "Restrict to one model namespace. Omit to search everything.",
509
+ },
510
+ limit: { type: "number", description: "Max results (default 20, server caps at 500)." },
511
+ page: { type: "number", description: "1-based page number." },
512
+ },
513
+ required: ["q"],
514
+ additionalProperties: false,
515
+ },
516
+ handler: async (config, args) => {
517
+ // A missing q answers 500 with an internal message on the server. #4627
518
+ // made it a 400 and the public-surface freeze put the 500 back on
519
+ // 2026-09-20. The check stays here because an agent should get a usable
520
+ // answer without spending a call.
521
+ const q = String(args.q ?? "").trim();
522
+ if (!q) {
523
+ return { error: "q is required and must be non-empty.", results: [], meta: null };
524
+ }
525
+ // extended=true, for two reasons. The default branch answers
526
+ // `{id, title}` and says nothing about which model a hit belongs to, so a
527
+ // result cannot be fed back into capa_get_content, which needs a
528
+ // namespace. (#4627 briefly added one; the public-surface freeze reverted
529
+ // it on 2026-09-20.) And the default branch reads the title out of the
530
+ // search DOCUMENT, which only ever holds `data.title.value`
531
+ // (search/indexes.ts buildInstanceDocument), so any model whose display
532
+ // field is not literally named `title` comes back with an EMPTY string.
533
+ // Observed against the seed's `authors` model, whose field is `name`:
534
+ // {"id":"...0011","title":""}, while the same instance under
535
+ // extended=true is "Ada Vale".
536
+ const res = await apiGet(config, "/v2/api/search", {
537
+ q,
538
+ modelNamespace: args.namespace,
539
+ size: args.limit ?? 20,
540
+ page: args.page,
541
+ extended: true,
542
+ depth: 0,
543
+ });
544
+ // Trimmed for the same reason as capa_list_content: a search result is a
545
+ // pointer, and the agent follows it with capa_get_content when it matters.
546
+ //
547
+ // instanceIdOf, NOT row.id. `extended=true` hoists the model's own fields
548
+ // over the row exactly as the content endpoints do, so for a model with a
549
+ // field named `id` this row's `id` is that field's value, not a UUID. No
550
+ // server sends `instanceId` on these rows today (#4628 was reverted under
551
+ // the public-surface freeze), so this is null for a shadowing model, the
552
+ // same as capa_list_content.
553
+ let shadowed = 0;
554
+ const results = (res.data ?? []).map((row) => {
555
+ const id = instanceIdOf(row);
556
+ if (id === null) shadowed++;
557
+ const out = {
558
+ id,
559
+ namespace: row.dataModel?.namespace ?? row.modelNamespace ?? null,
560
+ model: row.dataModel?.modelName ?? undefined,
561
+ title: unwrap(row.title),
562
+ };
563
+ if (id === null) out.idUnavailable = unwrap(row.id);
564
+ return out;
565
+ });
566
+ const out = { results, meta: res.meta };
567
+ if (shadowed) {
568
+ out.warning =
569
+ `${shadowed} of ${results.length} results have no usable id: the matched model ` +
570
+ `has a field named "id", which overwrites the instance id in this endpoint's ` +
571
+ `response, and this server is old enough not to send \`instanceId\` alongside it ` +
572
+ `(#4628). capa_get_content cannot be called for these results. ` +
573
+ `The value shown as idUnavailable is the model's own id field, not a Capa id.`;
574
+ }
575
+ return out;
576
+ },
577
+ },
578
+
579
+ {
580
+ name: "capa_get_types",
581
+ ...reads("Get TypeScript types (legacy API)"),
582
+ cutHint: TYPES_HINT,
583
+ surface: "legacy",
584
+ ...MODEL_ALIAS,
585
+ description:
586
+ "The generated TypeScript type definitions for this tenant's models, as source text: " +
587
+ "the exact interfaces the legacy /v2 API returns content as. Use when writing code " +
588
+ "against /v2. Code on @capacms/sdk/next reads a different shape: take the sdk code " +
589
+ "capa_graphql_build writes instead.",
590
+ inputSchema: {
591
+ type: "object",
592
+ properties: {
593
+ namespace: {
594
+ type: "string",
595
+ description:
596
+ "Return only the interface for this model (plus the shared Capa* helpers). " +
597
+ "Omit for the whole file.",
598
+ },
599
+ },
600
+ additionalProperties: false,
601
+ },
602
+ handler: async (config, args) => {
603
+ // text/plain, not JSON — see apiGetText.
604
+ const source = await apiGetText(config, "/v2/schema/types");
605
+ const blocks = source.split(/\n(?=export (?:interface|type) )/);
606
+ if (!args.namespace) return typesAnswer(blocks);
607
+
608
+ const nameOf = typeNameOf;
609
+ const byName = new Map(blocks.map((b) => [nameOf(b), b]).filter(([n]) => n));
610
+ const wanted = lc(args.namespace).replace(/[^a-z0-9]/g, "");
611
+ const shared = blocks.filter((b) => /^export (interface|type) Capa/m.test(b));
612
+ const match = blocks.filter((b) => {
613
+ const n = nameOf(b);
614
+ return n && lc(n).replace(/[^a-z0-9]/g, "") === wanted && !/^Capa/.test(n);
615
+ });
616
+ if (!match.length) {
617
+ const names = blocks
618
+ .map((b) => (/^export (?:interface|type) (\w+)/m.exec(b) || [])[1])
619
+ .filter(Boolean);
620
+ return {
621
+ error: `No type for "${args.namespace}".`,
622
+ didYouMean: names.filter((n) => lc(n).includes(wanted) || wanted.includes(lc(n))),
623
+ available: names,
624
+ };
625
+ }
626
+ // Follow referenced type names transitively. BlogsHomeSection refers to
627
+ // ShopifyArticle[] and Link; returning it alone hands an agent a fragment
628
+ // that cannot compile, which is worse than returning the whole file.
629
+ // Found by reading the output rather than the code — the first version of
630
+ // this tool shipped the claim "still typechecks on its own" in a comment
631
+ // directly above the code that made it false.
632
+ const closure = new Map();
633
+ const queue = match.map(nameOf);
634
+ while (queue.length) {
635
+ const n = queue.shift();
636
+ if (!n || closure.has(n)) continue;
637
+ const block = byName.get(n);
638
+ if (!block) continue;
639
+ closure.set(n, block);
640
+ for (const ref of block.matchAll(/\b([A-Z]\w*)\b/g)) {
641
+ if (byName.has(ref[1]) && !closure.has(ref[1])) queue.push(ref[1]);
642
+ }
643
+ }
644
+ const ordered = [...shared.filter((b) => !closure.has(nameOf(b))), ...closure.values()];
645
+ return typesAnswer(ordered, { types: [...closure.keys()] });
646
+ },
647
+ },
648
+
649
+ // --------------------------------------------- saved workspaces (0h, 0t) ---
650
+ //
651
+ // A workspace is one saved arrangement of the admin's left rail: which
652
+ // models, entries and media folders a person or a team keeps in reach, in
653
+ // which folders, in which order. ADMIN_UI_OVERHAUL 0h.4b asks for exactly
654
+ // three tools — list, get, set — and for get/set to speak the SAME JSON
655
+ // document, "so an agent can read, edit and write back".
656
+ //
657
+ // That is the shaping rule here: `capa_get_workspace` does NOT return the
658
+ // rail's render tree (counts, statuses, per-folder item caps) because an
659
+ // agent cannot write that back. It returns the document, which is the thing
660
+ // that round-trips.
661
+ //
662
+ // §0t made the document ONE list. It was `{ model, content, media }`, three
663
+ // trees that could not hold each other's nodes; a folder now holds any kind,
664
+ // so the document is `tree: Node[]` and a folder is named by the customer
665
+ // rather than by us. The three-key shape is still accepted for one release
666
+ // and maps onto folders called Models, Content and Media, so an agent that
667
+ // learned the old shape keeps working while it is updated.
668
+
669
+ {
670
+ name: "capa_list_workspaces",
671
+ ...reads("List workspaces"),
672
+ cutHint: "Name the workspace you want and read it with capa_get_workspace.",
673
+ surface: "legacy",
674
+ description:
675
+ "List this tenant's saved workspaces: the arrangements of the Capa admin's left rail. " +
676
+ "Each one is a named tree of models, entries and media folders. Start here before " +
677
+ "capa_get_workspace or capa_set_workspace: it is where the ids come from, and it says " +
678
+ "which one is the tenant default.",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ includePrivate: {
683
+ type: "boolean",
684
+ description:
685
+ "Also list workspaces individual people made for themselves. Off by default: " +
686
+ "those are personal, and an agent almost always wants the team ones.",
687
+ },
688
+ },
689
+ additionalProperties: false,
690
+ },
691
+ handler: async (config, args) => {
692
+ const res = await apiGet(config, "/v2/agent/workspaces", {
693
+ all: args.includePrivate ? "true" : undefined,
694
+ });
695
+ return {
696
+ workspaces: (res.workspaces ?? []).map((w) => ({
697
+ id: w.id,
698
+ name: w.name,
699
+ visibility: w.visibility,
700
+ isDefault: w.isDefault,
701
+ nodes: w.nodeCount,
702
+ })),
703
+ defaultId: res.defaultId ?? null,
704
+ };
705
+ },
706
+ },
707
+
708
+ {
709
+ name: "capa_get_workspace",
710
+ ...reads("Get a workspace document"),
711
+ whole: true,
712
+ surface: "legacy",
713
+ description:
714
+ "One workspace as a JSON document you can edit and write back with capa_set_workspace. " +
715
+ "The tree is `Node[]`, where a Node is `{folder, children?}`, `{model}`, `{instance}` or " +
716
+ "`{media_folder}`. A folder is named by the customer and holds any mix of the three, so " +
717
+ "there are no fixed sections. Models come back as NAMESPACES, so you can read and " +
718
+ "rearrange without resolving ids first.",
719
+ inputSchema: {
720
+ type: "object",
721
+ properties: {
722
+ id: { type: "string", description: "The workspace id, from capa_list_workspaces." },
723
+ },
724
+ required: ["id"],
725
+ additionalProperties: false,
726
+ },
727
+ handler: async (config, args) =>
728
+ apiGet(config, `/v2/agent/workspaces/${encodeURIComponent(args.id)}/document`),
729
+ },
730
+
731
+ {
732
+ name: "capa_set_workspace",
733
+ ...writes("Create or apply a workspace", { idempotent: false }),
734
+ cutHint: "The write is done; capa_get_workspace reads the whole document.",
735
+ surface: "legacy",
736
+ description:
737
+ "Create a workspace, or apply a document to one that exists. WRITES: it needs an API key " +
738
+ "with write permission. `mode: \"replace\"` makes the workspace exactly the document you " +
739
+ "send; `mode: \"merge\"` adds what is missing and removes nothing. Applying the same " +
740
+ "document twice changes nothing, so it is safe to retry. It never touches a record: a " +
741
+ "workspace is navigation, and removing something from it does not delete it.",
742
+ inputSchema: {
743
+ type: "object",
744
+ properties: {
745
+ id: {
746
+ type: "string",
747
+ description: "The workspace to apply to. Omit to CREATE one, in which case name is required.",
748
+ },
749
+ name: { type: "string", description: "Name for a new workspace, or a rename for an existing one." },
750
+ mode: {
751
+ type: "string",
752
+ enum: ["replace", "merge"],
753
+ description: 'Default "merge", the safe one. "replace" removes anything the document does not name.',
754
+ },
755
+ template: {
756
+ type: "string",
757
+ enum: ["blank", "website", "catalog"],
758
+ description:
759
+ 'Folders to start a NEW workspace with. "blank" (the default) makes none; ' +
760
+ '"website" makes Pages, Components, Settings, Media; "catalog" makes Products, ' +
761
+ "Collections, Assets. Ignored when applying to a workspace that already exists.",
762
+ },
763
+ tree: {
764
+ type: "array",
765
+ description:
766
+ 'Node[]. A Node is { folder: "<name>", children?: Node[] }, ' +
767
+ '{ model: "<namespace or id>" }, { instance: "<id>" } or ' +
768
+ '{ media_folder: "<folder id>" }. A folder holds any mix of the three. ' +
769
+ "The old three-key shape { model, content, media } is still accepted for one " +
770
+ "release and lands in folders called Models, Content and Media.",
771
+ items: { type: "object", additionalProperties: true },
772
+ },
773
+ },
774
+ additionalProperties: false,
775
+ },
776
+ handler: async (config, args) => {
777
+ const mode = args.mode ?? "merge";
778
+ const body = {};
779
+ if (args.name !== undefined) body.name = args.name;
780
+ if (args.tree !== undefined) body.tree = args.tree;
781
+
782
+ try {
783
+ if (!args.id) {
784
+ if (!args.name) {
785
+ return { error: "name is required when creating a workspace (omit id to create one)." };
786
+ }
787
+ // A key has no person behind it, so the API refuses a private
788
+ // workspace from this surface; saying so up front beats a 400.
789
+ if (args.template !== undefined) body.template = args.template;
790
+ const created = await apiWrite(config, "POST", "/v2/agent/workspaces", { ...body, visibility: "team" });
791
+ return { created: true, workspace: created.workspace, changes: created.changes ?? null, unresolved: created.unresolved ?? [] };
792
+ }
793
+ const applied = await apiWrite(
794
+ config,
795
+ "PUT",
796
+ `/v2/agent/workspaces/${encodeURIComponent(args.id)}/tree`,
797
+ { ...body, mode },
798
+ );
799
+ return {
800
+ created: false,
801
+ workspace: applied.workspace,
802
+ // The summary is the point of the call: an agent reads it to find out
803
+ // whether it actually changed anything, and a no-op says so.
804
+ changes: applied.changes,
805
+ unresolved: applied.unresolved ?? [],
806
+ };
807
+ } catch (error) {
808
+ // The API's OWN message, not a guess at it. A 403 here means the key
809
+ // may read and not write, which is a configuration fact somebody can
810
+ // act on — replacing it with "permission denied" would hide the fix.
811
+ if (error instanceof CapaApiError && error.status === 403) {
812
+ return {
813
+ error: error.message,
814
+ hint:
815
+ "This tool writes, so it needs a Capa API key whose permission is `write` " +
816
+ "(or `agent`). A `read` key can call capa_list_workspaces and capa_get_workspace " +
817
+ "but not this one.",
818
+ };
819
+ }
820
+ throw error;
821
+ }
822
+ },
823
+ },
824
+
825
+ // ------------------------------------------------------- pages (/api/) ---
826
+ //
827
+ // A page is a string starting with `/` that Capa learned about in one of two
828
+ // ways, and the union is the whole feature:
829
+ //
830
+ // DECLARED — a model carries a route (`/blog/[slug]`, `/pricing`). It
831
+ // exists with zero traffic, because a route somebody just set up
832
+ // should be visible immediately rather than after a visitor.
833
+ // OBSERVED — a read arrived carrying the page as the `Capa-Page` header. It
834
+ // exists whether or not any model declares it, because most
835
+ // sites have pages Capa knows nothing about.
836
+ //
837
+ // The two join on the string, so a page present in both is `kind: "both"`.
838
+ // `docs/api/pages.md` is the contract these three tools read.
839
+ //
840
+ // WHY AN AGENT WANTS THEM. Asked to change `/pricing`, an agent otherwise has
841
+ // to guess which models render it. `capa_get_page` answers that with the
842
+ // models, the entries and the exact queries the page issued, which is the
843
+ // difference between editing the right content and editing content that looks
844
+ // like it.
845
+ //
846
+ // NOTHING HERE COMPUTES A SUGGESTION. The rules that read a page's queries
847
+ // and say "this one fetches more than it renders" run on the API, so the Capa
848
+ // admin and an agent read one answer rather than two implementations that
849
+ // drift. This package has no dependencies and could not parse a select
850
+ // anyway. `capa_suggest_queries` passes those rows through verbatim and says
851
+ // so when there are none.
852
+
853
+ {
854
+ name: "capa_list_pages",
855
+ ...reads("List pages"),
856
+ cutHint: "Pass entry to list only the pages that read one entry.",
857
+ surface: "api",
858
+ scope: "instance:read",
859
+ feature: "pages",
860
+ description:
861
+ "Every page this Capa project knows about, busiest first: the page path, which content " +
862
+ "models it reads, and how many times it asked Capa for data in the last 30 days. Use it " +
863
+ "to find out what a site actually renders before changing content, and pass `entry` to " +
864
+ "answer \"what breaks if I unpublish this entry\". Reads the /api/ surface and needs a " +
865
+ "key holding the `instance:read` scope (a cap_ key with Read, or any legacy key). It " +
866
+ "returns no page bodies and no content, only the map.",
867
+ inputSchema: {
868
+ type: "object",
869
+ properties: {
870
+ entry: {
871
+ type: "string",
872
+ description:
873
+ "An entry id (UUID). Narrows the list to the pages that read that entry in the " +
874
+ "last 7 days, each carrying an extra entryReads count. Omit for every page.",
875
+ },
876
+ },
877
+ additionalProperties: false,
878
+ },
879
+ handler: async (config, args) => {
880
+ // Refused here rather than sent: the route reads an unrecognised id as
881
+ // "no pages" and answers an empty list, so a typo would come back looking
882
+ // exactly like a correct id nothing reads. Same reasoning as
883
+ // capa_get_content's UUID check, and it costs zero requests.
884
+ const entry = args.entry === undefined || args.entry === null ? undefined : String(args.entry);
885
+ if (entry !== undefined && !UUID_RE.test(entry)) {
886
+ return {
887
+ error: `"${args.entry}" is not a Capa entry id.`,
888
+ hint:
889
+ "Entry ids are UUIDs, the `id` capa_list_content returns. This route answers an " +
890
+ "empty list for an id it does not recognise rather than an error, so a malformed " +
891
+ "id would look like a real entry that no page reads. Omit `entry` to list every page.",
892
+ pages: [],
893
+ total: 0,
894
+ };
895
+ }
896
+
897
+ let data;
898
+ let meta;
899
+ try {
900
+ ({ data, meta } = await apiNextGet(config, "/api/pages", { entry }));
901
+ } catch (error) {
902
+ if (pagesOff(error)) return PAGES_OFF;
903
+ throw error;
904
+ }
905
+ const rows = Array.isArray(data) ? data : [];
906
+ const pages = rows.map((p) => {
907
+ // `pattern` is dropped: the docs are explicit that it equals `id`,
908
+ // because the string IS the identity. Two keys holding one value is
909
+ // tokens an agent has to read twice to learn nothing.
910
+ const row = {
911
+ id: p.id,
912
+ kind: p.kind,
913
+ models: (p.models ?? []).map((m) => ({
914
+ id: m.id,
915
+ namespace: m.namespace,
916
+ // `name` rather than `modelName`, matching capa_get_model's output,
917
+ // so the one word means the same thing in every tool here.
918
+ name: m.modelName ?? m.name ?? null,
919
+ })),
920
+ reads30d: p.reads30d,
921
+ lastReadAt: p.lastReadAt ?? null,
922
+ slugField: p.slugField ?? null,
923
+ };
924
+ // Present only on an entry-filtered list, and a SHORTER window than
925
+ // reads30d beside it (7 days, because entry ids live only on the raw
926
+ // read rows). Passed through under its own key for that reason.
927
+ if (p.entryReads !== undefined) row.entryReads = p.entryReads;
928
+ return row;
929
+ });
930
+
931
+ const out = {
932
+ pages,
933
+ total: pages.length,
934
+ // Tenant-wide rows the API computed, passed through untouched. An
935
+ // empty array is the honest answer both when the project has no unused
936
+ // entries and when this deployment does not compute them yet, which is
937
+ // why capa_suggest_queries says which of the two it saw.
938
+ insights: Array.isArray(meta?.insights) ? meta.insights : [],
939
+ };
940
+ // The list is capped rather than paged. Saying so only when it bit keeps
941
+ // the common answer small and stops `total` being read as "all of them"
942
+ // when it is not.
943
+ if (meta?.truncated === true) out.truncated = true;
944
+ if (pages.length === 0) {
945
+ out.hint =
946
+ entry === undefined
947
+ ? "A page exists once a model declares it as a route in the Capa admin, or once a " +
948
+ "read arrives carrying the page as the Capa-Page header, so a project with " +
949
+ "neither set up yet has no pages to list."
950
+ : "No page read that entry in the last 7 days. Entry ids are only on the raw read " +
951
+ "rows and those are kept for a week, so this window is shorter than reads30d. " +
952
+ "A page exists once a model declares it as a route or a read arrives carrying " +
953
+ "the Capa-Page header, and an entry read by nothing answers the same empty list " +
954
+ "as an id that never existed.";
955
+ }
956
+ return out;
957
+ },
958
+ },
959
+
960
+ {
961
+ name: "capa_get_page",
962
+ ...reads("Get one page"),
963
+ cutHint: "capa_suggest_queries lists the page's queries on their own.",
964
+ surface: "api",
965
+ scope: "instance:read",
966
+ feature: "pages",
967
+ description:
968
+ "One page in full: the models it reads, the entries it showed in the last 7 days, the " +
969
+ "exact queries it issued (each with the select text and, where the API parsed it, the " +
970
+ "parsed selection), its reads per day, and any suggestions the API computed about it. " +
971
+ "This is how you find out how a page fetches its data before you change the code that " +
972
+ "renders it. Reads the /api/ surface and needs a key holding the `instance:read` scope " +
973
+ "(a cap_ key with Read, or any legacy key). Pass the page exactly as capa_list_pages " +
974
+ "returned it. It returns the page's traffic and query shapes, never the rendered page " +
975
+ "and never the content itself: read an entry with capa_graphql_build (mode single).",
976
+ inputSchema: {
977
+ type: "object",
978
+ properties: {
979
+ page: {
980
+ type: "string",
981
+ description:
982
+ "The page path, e.g. \"/pricing\" or \"/blog/[slug]\". The concrete path a site " +
983
+ "served (\"/blog/hello\") is accepted too. From capa_list_pages.",
984
+ },
985
+ },
986
+ required: ["page"],
987
+ additionalProperties: false,
988
+ },
989
+ handler: async (config, args) => {
990
+ const page = String(args.page ?? "").trim();
991
+ if (!page) {
992
+ return {
993
+ error: "page is required and must be non-empty.",
994
+ hint:
995
+ "A page is a path starting with \"/\", e.g. \"/pricing\" or \"/blog/[slug]\". " +
996
+ "capa_list_pages returns the ones this project has, and its `id` is what goes here.",
997
+ };
998
+ }
999
+ try {
1000
+ // Encoded ONCE. The identity contains slashes and travels as a single
1001
+ // path segment; the route decodes it back before it looks anything up,
1002
+ // so encoding it twice would search for a page whose name contains
1003
+ // "%2F".
1004
+ const { data } = await apiNextGet(config, `/api/pages/${encodeURIComponent(page)}`);
1005
+ // Verbatim. Fields the API adds later (the parsed selection on each
1006
+ // query, the suggestions) reach an agent the day they ship without this
1007
+ // tool being edited, and a field this tool does not understand is not a
1008
+ // field it should be dropping.
1009
+ return data;
1010
+ } catch (error) {
1011
+ if (pagesOff(error)) return PAGES_OFF;
1012
+ if (error instanceof CapaApiError && error.status === 404) {
1013
+ // In band, with the way out, following capa_get_model's didYouMean:
1014
+ // a bare not-found makes an agent guess a second path, and the page
1015
+ // list is the answer it would have to go and fetch anyway.
1016
+ const available = await firstPageIds(config);
1017
+ return {
1018
+ error: "page_not_found",
1019
+ hint:
1020
+ error.hint ??
1021
+ "A page exists once a model declares it as a route in the Capa admin, or once a " +
1022
+ "read arrives carrying it as the Capa-Page header. capa_list_pages lists the " +
1023
+ "ones this project has.",
1024
+ // Omitted, not empty, when the list call failed too: `[]` would be
1025
+ // a claim that this project has no pages, which is a different
1026
+ // fact from "the second call did not answer either".
1027
+ ...(available === null ? {} : { available }),
1028
+ };
1029
+ }
1030
+ throw error;
1031
+ }
1032
+ },
1033
+ },
1034
+
1035
+ {
1036
+ name: "capa_suggest_queries",
1037
+ ...reads("Suggest query changes for a page"),
1038
+ cutHint: "capa_get_page has the page's models, entries and traffic.",
1039
+ surface: "api",
1040
+ scope: "instance:read",
1041
+ feature: "pages",
1042
+ description:
1043
+ "What Capa suggests changing about how one page fetches its data, plus the queries the " +
1044
+ "page actually issued. The suggestions are computed by the API from that page's own " +
1045
+ "reads, so this tool reports them rather than deriving anything itself, and the Capa " +
1046
+ "admin shows the same ones. Use it after capa_get_page when you are about to rewrite a " +
1047
+ "page's data fetching. Reads the /api/ surface and needs a key holding the " +
1048
+ "`instance:read` scope (a cap_ key with Read, or any legacy key). An empty list is an " +
1049
+ "answer, not a failure, and `note` says which kind of empty it is.",
1050
+ inputSchema: {
1051
+ type: "object",
1052
+ properties: {
1053
+ page: {
1054
+ type: "string",
1055
+ description: "The page path, exactly as capa_list_pages returned it.",
1056
+ },
1057
+ },
1058
+ required: ["page"],
1059
+ additionalProperties: false,
1060
+ },
1061
+ handler: async (config, args) => {
1062
+ const page = String(args.page ?? "").trim();
1063
+ if (!page) {
1064
+ return {
1065
+ error: "page is required and must be non-empty.",
1066
+ hint:
1067
+ "A page is a path starting with \"/\", e.g. \"/pricing\". capa_list_pages returns " +
1068
+ "the ones this project has.",
1069
+ };
1070
+ }
1071
+ let detail;
1072
+ try {
1073
+ const res = await apiNextGet(config, `/api/pages/${encodeURIComponent(page)}`);
1074
+ detail = res.data;
1075
+ } catch (error) {
1076
+ if (pagesOff(error)) return PAGES_OFF;
1077
+ if (error instanceof CapaApiError && error.status === 404) {
1078
+ return {
1079
+ error: "page_not_found",
1080
+ hint:
1081
+ error.hint ??
1082
+ "A page exists once a model declares it as a route in the Capa admin, or once a " +
1083
+ "read arrives carrying it as the Capa-Page header. capa_list_pages lists the " +
1084
+ "ones this project has.",
1085
+ };
1086
+ }
1087
+ throw error;
1088
+ }
1089
+
1090
+ const insights = Array.isArray(detail?.insights) ? detail.insights : [];
1091
+ // One row per distinct (model, select) the page asked for, which is how a
1092
+ // page fetching far more than it renders shows up. Trimmed to the three
1093
+ // fields that answer "what did it ask for and how often": the detail
1094
+ // carries the key ids and timestamps beside them, and capa_get_page is
1095
+ // where to read those.
1096
+ const queries = (detail?.queries ?? []).map((q) => ({
1097
+ modelNamespace: q.namespace ?? null,
1098
+ selectText: q.selectText ?? null,
1099
+ reads: q.reads,
1100
+ }));
1101
+
1102
+ const out = { page: detail?.page ?? page, insights, queries };
1103
+ if (insights.length === 0) {
1104
+ out.note =
1105
+ queries.length === 0
1106
+ ? "No suggestions: this page issued no reads in the window Capa measures, so there " +
1107
+ "is nothing to suggest about. A page's queries appear here once its reads arrive " +
1108
+ "carrying the page as the Capa-Page header."
1109
+ : "No suggestions: the API computes them from this page's own reads and returned " +
1110
+ "none, which means either every query is already narrow or this deployment does " +
1111
+ "not compute them yet. The queries listed are what the page actually issued, " +
1112
+ "verbatim, so you can judge them yourself.";
1113
+ }
1114
+ return out;
1115
+ },
1116
+ },
1117
+
1118
+ // ------------------------------------------- GraphQL and data (/api/) ---
1119
+ //
1120
+ // Finding, writing, running and reading queries: lib/graphql-tools.mjs.
1121
+ ...GRAPHQL_TOOLS,
1122
+
1123
+ // ------------------------------------------ REST, where GraphQL is off ---
1124
+ //
1125
+ // Reading entries when the deployment serves no GraphQL: lib/rest-tools.mjs.
1126
+ ...REST_TOOLS,
1127
+ ];
1128
+
1129
+ /**
1130
+ * A page route answering `route_not_found` is a deployment without pages
1131
+ * (`CAPA_SITE_PREVIEW` off), not a page that does not exist. The server
1132
+ * leaves these tools out when it can tell at startup; this is the answer when
1133
+ * it could not.
1134
+ */
1135
+ const pagesOff = (error) => error instanceof CapaApiError && error.status === 404 && error.code === "route_not_found";
1136
+ const PAGES_OFF = {
1137
+ error: "pages_not_enabled",
1138
+ hint:
1139
+ "This deployment does not serve pages (CAPA_SITE_PREVIEW is off), so no page can be listed or read here. " +
1140
+ "To see what content a model holds, use capa_graphql_schema and capa_explore_data.",
1141
+ };
1142
+
1143
+ /**
1144
+ * The first page ids this project has, for the `available` list on a 404.
1145
+ *
1146
+ * Null rather than `[]` when the call fails: an empty array is a claim that
1147
+ * the project has no pages, and the tool has not learned that.
1148
+ */
1149
+ async function firstPageIds(config) {
1150
+ try {
1151
+ const { data } = await apiNextGet(config, "/api/pages");
1152
+ return (Array.isArray(data) ? data : []).slice(0, 20).map((p) => p.id);
1153
+ } catch {
1154
+ return null;
1155
+ }
1156
+ }
1157
+
1158
+ export const TOOLS_BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));