@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/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]));
|