@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.
@@ -0,0 +1,1177 @@
1
+ /**
2
+ * graphql-tools.mjs — the tools that let an agent find, write, run and read
3
+ * queries against Capa's `/api/` read surface.
4
+ *
5
+ * The path an agent takes, and the tool for each step:
6
+ *
7
+ * what is here? capa_graphql_schema (the map, one model, or SDL)
8
+ * what does it look like? capa_explore_data (real values, measured)
9
+ * write me a query capa_graphql_build (GraphQL + REST + SDK, checked)
10
+ * run this query capa_graphql_query
11
+ * what went wrong? capa_explain_error (offline)
12
+ *
13
+ * The first three and capa_graphql_query are G plan 4.2's tools. The brief
14
+ * adds "explore data", which capa_explore_data does, and capa_explain_error
15
+ * answers the brief's "errors name the next tool" for errors an agent reads
16
+ * in a log rather than gets from a call.
17
+ *
18
+ * Every description says when to use the tool instead of its neighbours, every
19
+ * input schema refuses unknown keys (server.mjs checks arguments against it),
20
+ * every answer is bounded (lib/bound.mjs) and says what it cut, and every
21
+ * refusal names the next tool to call. The budgets are asserted in the tests.
22
+ *
23
+ * SCOPE. Every tool but capa_explain_error reads through `/api/graphql`, which
24
+ * serves any key holding `instance:read` for at least one model, so these
25
+ * tools use `scopePrefix` (a per-model `instance:read:<id>` counts). The
26
+ * schema they see is the key's own: a one-model key sees one model, and no
27
+ * output here names a model the key cannot read.
28
+ */
29
+ import { reads } from "./annotations.mjs";
30
+ import { answerSchema, BOUNDED, DRAFTS_NOTE, MAX_CHARS, READ_WITH_KEYS, readWith } from "./answers.mjs";
31
+ import { apiNextGet, apiNextPost, CapaApiError, DEFAULT_API_VERSION } from "./client.mjs";
32
+ import { boundAnswer, DEFAULT_MAX_CHARS, someNames } from "./bound.mjs";
33
+ import { BuildError, didYouMean, displayName, entriesPerEntry, findModel, firstWithin, isHop, planQuery, printGraphQL, printRest, RestOnlyModel } from "./graphql/build.mjs";
34
+ import { connectionsOf, withAfter } from "./graphql/document.mjs";
35
+ import { listedWithin, relationsWithMore } from "./graphql/more.mjs";
36
+ import { loadSchema, namedType } from "./graphql/schema.mjs";
37
+ import { whenNotServed } from "./graphql/served.mjs";
38
+ import { splitOutside } from "./graphql/names.mjs";
39
+ import { printSDL } from "./graphql/sdl.mjs";
40
+ import { modelSDLUri } from "./resources.mjs";
41
+ import { budgetOf, explainOne, hintedFix, nextSteps, nextTool, normalizeErrors } from "./error-guide.mjs";
42
+ import { COUNTS_LEGEND, exploreQuery, fieldStats, hiddenValueRequests, listRequests, measurableFields, nullListRequests, pageSizeFor, samples, statusCounts, storedRequest } from "./explore.mjs";
43
+
44
+ /** Every tool here but capa_explain_error reads through /api/graphql, which a deployment can switch off (registry.mjs). */
45
+ const SCOPE = { surface: "api", scopePrefix: "instance:read", feature: "graphql" };
46
+ /** What a cut schema answer tells the agent: one model at a time is always small. */
47
+ const SCHEMA_HINT = "Pass model for one model at a time.";
48
+ /** What a cut answer about one model tells the agent: it asked for one model already, and that model's SDL is a resource. */
49
+ const modelHint = (model) => `This model has more than one answer holds: read the resource ${modelSDLUri(model.namespace)} for its whole SDL.`;
50
+ /** What a schema map too big even to name every model tells the agent. */
51
+ const NAMES_HINT = "Pass model with any model's namespace, name or type: an unknown one comes back with the nearest.";
52
+ /** What a cut built query tells the agent. */
53
+ const BUILD_HINT = "Ask for fewer fields or a smaller first, or raise maxChars.";
54
+ /** How to read the rest of a clipped value: capa_graphql_query's slice, on the same document. */
55
+ const readOnWithSlice = (clip) =>
56
+ `Call capa_graphql_query again with the same query and slice: { path: "${clip.path}", offset: ${clip.kept} } for the rest.`;
57
+ /** The same from capa_graphql_build's run, whose data sits under result. */
58
+ const readOnFromBuild = (clip) => {
59
+ const path = clip.path.replace(/^result\./, "");
60
+ return `Run this query with capa_graphql_query and slice: { path: "${path}", offset: ${clip.kept} } for the rest.`;
61
+ };
62
+ const BUILD_FALLBACK_HINT =
63
+ "Raise maxChars, or run the query with capa_graphql_query, whose answer does not repeat the document.";
64
+ /**
65
+ * The parts of a built query's answer that are never cut: the document and its
66
+ * REST twin are the point of the tool, and what the key read decides what the
67
+ * result means. Only parts that overrun maxChars on their own are left out,
68
+ * whole (lib/bound.mjs).
69
+ */
70
+ const BUILT_PARTS = ["query", "variables", "operationName", "rest", "restFromApi", "environment", "drafts", "notFound"];
71
+ /**
72
+ * The parts of a query's answer that give way, whole and in this order,
73
+ * before its data is cut past one entry per list: the drafts note, since
74
+ * `environment` still says which key read it, then the REST twin, which a
75
+ * `nodes(ids:)` read's every id can make longer than the budget. The cost
76
+ * describes the data, so it stays beside it.
77
+ */
78
+ const QUERY_SPARE = ["drafts", "rest"];
79
+ /** What a cut explanation tells the agent. */
80
+ const EXPLAIN_HINT = "Pass the error object, or just its code, rather than the whole log.";
81
+ /** What a cut measurement tells the agent: this tool takes field and sample, not fields or first. */
82
+ const EXPLORE_HINT = "Pass field to measure one field, or sample: 0.";
83
+ /**
84
+ * The parts of a measurement left out whole before any field's numbers are
85
+ * cut, in order: the sample entries, then the legend of what each count means
86
+ * (the output schema's `counts` says where to find it).
87
+ */
88
+ const EXPLORE_EXTRAS = ["samples", "counts"];
89
+ const SYSTEM_NOTE =
90
+ "Every model type also has id, model, status, createdAt, updatedAt, publishedAt, _version, _tags, _folder. " +
91
+ "Filters and sorts take id, createdAt, updatedAt and publishedAt of those.";
92
+ /** The system fields every sort takes (N8), which every filter takes too. */
93
+ const SORTED_SYSTEM = new Set(["id", "createdAt", "updatedAt", "publishedAt"]);
94
+
95
+ /** SYSTEM_NOTE, and the system fields `models`' filters take beyond the sorted four, as the schema declares them (`_tags`, amendment 56). */
96
+ function systemNote(models) {
97
+ const more = [...new Set(models.flatMap((m) => m.systemFilters.map((f) => f.name)))].filter((name) => !SORTED_SYSTEM.has(name));
98
+ if (!more.length) return SYSTEM_NOTE;
99
+ const names = more.length === 1 ? more[0] : `${more.slice(0, -1).join(", ")} and ${more.at(-1)}`;
100
+ return `${SYSTEM_NOTE} Filters also take ${names}.`;
101
+ }
102
+
103
+ // ------------------------------------------------------------ small helpers ---
104
+
105
+ /**
106
+ * A `BuildError` as an in-band answer: the agent corrects itself from it. A
107
+ * wrong name carries the nearest names, every name, and the tool that lists
108
+ * them; a structural refusal states its fix in `error` itself.
109
+ */
110
+ function buildRefusal(error) {
111
+ // A model GraphQL leaves out is no misspelling: REST reads it, and capa_read_entries is registered for it.
112
+ if (error instanceof RestOnlyModel) return { error: error.message, next: "capa_read_entries" };
113
+ const out = { error: error.message };
114
+ if (error.didYouMean.length) out.didYouMean = error.didYouMean;
115
+ if (error.available.length) Object.assign(out, { available: error.available, next: "capa_graphql_schema" });
116
+ return out;
117
+ }
118
+
119
+ /** One field as a line of the map: `title: String`, `author -> authors`, `coauthors ->> authors`, marked when deprecated. */
120
+ function fieldLine(field) {
121
+ const line =
122
+ field.kind === "relation"
123
+ ? `${field.name} -> ${field.target}`
124
+ : field.kind === "relationList"
125
+ ? `${field.name} ->> ${field.target}`
126
+ : `${field.name}: ${field.graphqlType}`;
127
+ return field.deprecationReason ? `${line} (deprecated)` : line;
128
+ }
129
+
130
+ /** The API's GraphQL errors, trimmed to what an agent acts on. */
131
+ function trimErrors(errors) {
132
+ return (errors ?? []).map((e) => {
133
+ const out = { message: e.message };
134
+ if (e.extensions?.code) out.code = e.extensions.code;
135
+ if (e.extensions?.hint) out.hint = e.extensions.hint;
136
+ if (e.path) out.path = e.path;
137
+ return out;
138
+ });
139
+ }
140
+
141
+ /** `{ next }` naming the tool that helps with these errors, or nothing when none does. */
142
+ function nextField(errors) {
143
+ const tool = nextTool(errors);
144
+ return tool ? { next: tool } : {};
145
+ }
146
+
147
+ /**
148
+ * For a built list the API refused over the entries budget, the changes that
149
+ * fit: the refusal states the limit, and the plan what one entry can read
150
+ * (REST's pre-SQL bound), so the answer is a number, not "ask for less". The
151
+ * API's hint often names a relation list's first instead of the root's; both
152
+ * fit, so both are named, the API's first, each with its numbers as written.
153
+ */
154
+ function fittingFirst(plan, errors) {
155
+ if (plan.mode !== "list") return {};
156
+ const root = plan.model.listField;
157
+ const refusal = errors.find((e) => {
158
+ const budget = budgetOf(e.message);
159
+ return budget?.measure === "entries" && budget.root === root;
160
+ });
161
+ if (!refusal) return {};
162
+ const budget = budgetOf(refusal.message);
163
+ const limit = budget.written.limit;
164
+ const hinted = hintedFix(refusal.hint);
165
+ const apiChange = hinted?.field && hinted.field !== root ? `${hinted.field}(${hinted.argument}: ${hinted.value})` : null;
166
+ const first = firstWithin(plan, budget.limit);
167
+ if (first === null) return apiChange ? { fix: `${apiChange} fits the limit of ${limit}, as the API's hint says.` } : {};
168
+ const perEntry = entriesPerEntry(plan.fields);
169
+ if (first < 1) {
170
+ const lower = `One ${plan.model.typeName} entry can read ${perEntry} with its relations, over the limit of ${limit}`;
171
+ return { fix: apiChange ? `${lower}: pass ${apiChange}, as the API's hint says.` : `${lower}: lower first on its relation lists.` };
172
+ }
173
+ const each = `each ${plan.model.typeName} entry can read ${perEntry} with its relations`;
174
+ return {
175
+ fix: apiChange
176
+ ? `Either change fits the limit of ${limit}: ${apiChange}, as the API's hint says, or first: ${first} on ${root}, since ${each}.`
177
+ : `E${each.slice(1)}, so first: ${first} fits the limit of ${limit}.`,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The API refused the whole request: a status other than 200, with its
183
+ * errors, and for each code the fix and the tool that helps (error-guide.mjs).
184
+ */
185
+ class QueryRefused extends Error {
186
+ constructor(status, errors) {
187
+ const lines = errors.slice(0, 5).map((e) => `- ${e.code ? `${e.code}: ` : ""}${e.message}${e.hint ? ` Hint: ${e.hint}` : ""}`);
188
+ super(`Capa refused the query (HTTP ${status}).\n${lines.join("\n")}\n${nextSteps(errors)}`);
189
+ this.status = status;
190
+ this.errors = errors;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * POST a document and answer the API's body. It asks for `extensions.capa`,
196
+ * which the API sends only to a development key unless asked (spec 17,
197
+ * amendment 119): the detailed cost, the REST twin and the key's environment
198
+ * are part of every answer these tools give. A refused request throws
199
+ * `QueryRefused`, whose message lists every error with its hint and the next
200
+ * step, which the server turns into an `isError` result.
201
+ */
202
+ async function postQuery(config, query, variables, operationName) {
203
+ try {
204
+ return await apiNextPost(config, "/api/graphql", { query, variables, operationName, extensions: { capa: true } });
205
+ } catch (error) {
206
+ if (!(error instanceof CapaApiError)) throw error;
207
+ const notServed = whenNotServed(config, error);
208
+ if (notServed !== error) throw notServed;
209
+ let parsed = null;
210
+ try {
211
+ parsed = JSON.parse(error.body);
212
+ } catch {
213
+ parsed = null;
214
+ }
215
+ const errors = trimErrors(parsed?.errors ?? (parsed?.error ? [{ message: parsed.error.message, extensions: parsed.error }] : []));
216
+ throw new QueryRefused(error.status, errors.length ? errors : [{ message: error.message }]);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Run a document: a 200 answers `{ data, errors?, cost, budget, deprecations?, rest }`, and `environment`, the
222
+ * key's, which `readWith` reads. `budget` is `extensions.cost.budget`, `{ counted, limit }`: what the API
223
+ * counted against the 5,000-entry limit before it read (spec 17, amendment 138), so an agent sees how near a
224
+ * document is to a refusal. `deprecations` is each deprecated member the document used, `{ coordinate, reason }`
225
+ * (amendment 136), only when there is one.
226
+ */
227
+ async function runQuery(config, query, variables, operationName) {
228
+ const body = await postQuery(config, query, variables, operationName);
229
+ const out = { data: body.data ?? null };
230
+ const errors = trimErrors(body.errors);
231
+ if (errors.length) out.errors = errors;
232
+ if (body.extensions?.capa?.cost) out.cost = body.extensions.capa.cost;
233
+ if (body.extensions?.cost?.budget) out.budget = body.extensions.cost.budget;
234
+ if (body.extensions?.deprecations?.length) out.deprecations = body.extensions.deprecations;
235
+ if (body.extensions?.capa?.rest) out.rest = body.extensions.capa.rest;
236
+ if (body.extensions?.capa?.environment) out.environment = body.extensions.capa.environment;
237
+ return out;
238
+ }
239
+
240
+
241
+ // Split so the env-template scanner does not count text this tool writes for
242
+ // the user's code as a variable this package reads (as apps/admin dev-query.ts).
243
+ const ENV = "process" + ".env";
244
+
245
+ /**
246
+ * What the SDK code says for a legacy key: `pk_`, `sk_` or unprefixed, every
247
+ * key but `cap_`, which `@capacms/sdk/next` reads with and warns about once.
248
+ */
249
+ const LEGACY_KEY_NOTE =
250
+ "@capacms/sdk/next reads with this legacy key and warns once. Draft previews need a cap_ key: mint one in the Capa admin under Developers > Keys.";
251
+
252
+ const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
253
+
254
+ /**
255
+ * The code for a built query in the user's app, typed the way the SDK's own
256
+ * README types it:
257
+ *
258
+ * - `document`: the query as a `#graphql` literal, which `capa-codegen
259
+ * --graphql` reads, so `data` and the variables are typed from its text.
260
+ * - `next`: a Next.js server component's read, through `graphql()` from
261
+ * `@capacms/sdk/nextjs`: drafts and edit marks under preview, the Next
262
+ * cache tagged with every model the query reads (so `revalidateFromWebhook`
263
+ * refreshes the page on a publish), and never a cached error: graphql()
264
+ * keeps a read in Next's data cache itself, with no `unstable_cache` passed.
265
+ * The models are `<Name>Models`, which `capa-codegen --graphql` writes
266
+ * beside the document's types, so the tags follow the query when it is
267
+ * edited, rather than a list written once here that would go stale.
268
+ * - `node`: anywhere else, with `createClient` from `@capacms/sdk/next`.
269
+ * - `rest`: the REST twin, with the same `capa`.
270
+ *
271
+ * The SDK's consumer type test compiles each of them, as handed over
272
+ * (packages/sdk/test/types/mcp-snippets.ts). CAPA_API_URL and CAPA_KEY are
273
+ * the names /nextjs reads. `@capacms/sdk/next` reads with any key, and a
274
+ * legacy key's code notes the `cap_` key draft previews need.
275
+ */
276
+ export function sdkSnippets(config, summary, plan, built, rest) {
277
+ const version = summary.version || DEFAULT_API_VERSION;
278
+ const note = config.family === "cap" ? {} : { note: LEGACY_KEY_NOTE };
279
+ const name = built.operationName;
280
+ const document =
281
+ "// Run capa-codegen --graphql after saving this: it types data and the variables from this text.\n" +
282
+ `const ${name} = \`#graphql\n${built.query}\n\`;`;
283
+ const vars = JSON.stringify(built.variables);
284
+ const next =
285
+ 'import { draftMode, headers } from "next/headers";\n' +
286
+ 'import { graphql, tagsFor } from "@capacms/sdk/nextjs";\n' +
287
+ `import { ${name}Models } from "./capa-graphql"; // written by capa-codegen --graphql\n` +
288
+ "// In a server component:\n" +
289
+ `const { data, errors } = await graphql(${name}, ${vars}, {\n` +
290
+ " draftMode,\n" +
291
+ " headers,\n" +
292
+ ` tags: tagsFor({ namespace: ${name}Models }),\n` +
293
+ " revalidate: 60,\n" +
294
+ "});";
295
+ const node =
296
+ 'import { createClient } from "@capacms/sdk/next";\n' +
297
+ `const capa = createClient({ baseUrl: ${ENV}.CAPA_API_URL!, apiKey: ${ENV}.CAPA_KEY!, version: "${version}" });\n` +
298
+ `const { data, errors } = await capa.graphql(${name}${Object.keys(built.variables).length ? `, ${vars}` : ""});`;
299
+ const namespace = JSON.stringify(plan.model.namespace);
300
+ if (plan.mode === "single") {
301
+ return { ...note, document, next, node, rest: `const entry = await capa.entries.get(${namespace}, ${JSON.stringify(plan.id)}, { select: ${JSON.stringify(rest.select)} });` };
302
+ }
303
+ const options = [`select: ${JSON.stringify(rest.select)}`];
304
+ const param = (key) => rest.params.find(([k]) => k === key)?.[1];
305
+ if (param("where")) options.push(`where: ${param("where")}`);
306
+ // A quoted namespace may hold a comma (`"a,b"`), so the keys are split outside quotes.
307
+ if (param("sort")) options.push(`sort: ${JSON.stringify(splitOutside(param("sort"), ",", { nested: false }))}`);
308
+ if (param("limit")) options.push(`limit: ${param("limit")}`);
309
+ if (param("count")) options.push("count: true");
310
+ if (param("after")) options.push(`after: ${JSON.stringify(param("after"))}`);
311
+ if (param("before")) options.push(`before: ${JSON.stringify(param("before"))}`);
312
+ return { ...note, document, next, node, rest: `const page = await capa.entries.list(${namespace}, { ${options.join(", ")} });` };
313
+ }
314
+
315
+ /**
316
+ * The parts of `sdkSnippets` each `code` choice hands over. `document` goes
317
+ * with the code that runs it, which names it; the REST read stands alone.
318
+ */
319
+ const CODE_PARTS = {
320
+ next: ["document", "next"],
321
+ node: ["document", "node"],
322
+ rest: ["rest"],
323
+ all: ["document", "next", "node", "rest"],
324
+ };
325
+
326
+ /** The SDK code `code` asks for, with a legacy key's note, or null for `none`. */
327
+ function sdkCode(code, snippets) {
328
+ const parts = CODE_PARTS[code];
329
+ if (!parts) return null;
330
+ const { note, ...all } = snippets;
331
+ return { ...(note ? { note } : {}), ...Object.fromEntries(parts.map((part) => [part, all[part]])) };
332
+ }
333
+
334
+ /** A scope that names one model by id: `instance:read:<uuid>`. */
335
+ const MODEL_SCOPE = /^(.+):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
336
+
337
+ /**
338
+ * The key's scopes as an agent can use them: a scope on one model names the
339
+ * model by namespace (`instance:read:articles`), from the models `/api/me`
340
+ * lists with their ids, since a raw id is nothing a query or a tool takes. A
341
+ * scope on a model the key reads nothing of is not in that list; it is counted
342
+ * rather than printed as an id.
343
+ */
344
+ export function readableScopes(scopes, models) {
345
+ const namespaces = new Map((Array.isArray(models) ? models : []).map((m) => [m.id, m.namespace]));
346
+ const named = [];
347
+ let unnamed = 0;
348
+ for (const scope of scopes) {
349
+ const match = MODEL_SCOPE.exec(scope);
350
+ if (!match) named.push(scope);
351
+ else if (namespaces.has(match[2])) named.push(`${match[1]}:${namespaces.get(match[2])}`);
352
+ else unnamed++;
353
+ }
354
+ const shown = named.length > 12 ? [...named.slice(0, 12), `+${named.length - 12} more`] : named;
355
+ return unnamed ? [...shown, `+${unnamed} on models this key reads no entries of`] : shown;
356
+ }
357
+
358
+ async function keySummary(config) {
359
+ try {
360
+ const { data } = await apiNextGet(config, "/api/me");
361
+ const key = { environment: data?.environment, bundle: data?.bundle };
362
+ if (Array.isArray(data?.scopes)) key.scopes = readableScopes(data.scopes, data.models);
363
+ if (key.environment === "development") key.drafts = DRAFTS_NOTE;
364
+ return key;
365
+ } catch (error) {
366
+ return { unavailable: error instanceof CapaApiError ? `${error.status}${error.code ? ` ${error.code}` : ""}` : "unreachable" };
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Every model this key can read, one line per field. Over the budget, each
372
+ * model's field list is shortened and every model stays named, since a model
373
+ * the map leaves out is one the agent cannot know to ask for. Only when the
374
+ * names alone do not fit are models cut.
375
+ */
376
+ async function schemaMap(config, summary) {
377
+ const map = {
378
+ version: summary.version,
379
+ key: await keySummary(config),
380
+ models: [
381
+ ...summary.models.map((m) => ({
382
+ model: m.namespace,
383
+ name: displayName(m),
384
+ type: m.typeName,
385
+ graphql: `${m.listField}, ${m.singleField}(id)`,
386
+ rest: `/api/entries/${m.namespace}`,
387
+ fields: m.fields.map(fieldLine),
388
+ })),
389
+ // Listed, since a model the map leaves out is one the agent cannot know to ask for; REST reads them.
390
+ ...(summary.restOnly ?? []).map((namespace) => ({
391
+ model: namespace,
392
+ graphql: "none: readable over REST only; capa_read_entries reads it",
393
+ rest: `/api/entries/${namespace}`,
394
+ })),
395
+ ],
396
+ system: systemNote(summary.models),
397
+ next: "Pass model for filters, sorts and an example, or call capa_graphql_build.",
398
+ };
399
+ const bounded = boundAnswer(map, DEFAULT_MAX_CHARS, { hint: SCHEMA_HINT, whole: ["models"] });
400
+ if (!bounded.note) return bounded;
401
+ const names = {
402
+ ...map,
403
+ models: map.models.map(({ model, name, type, graphql, rest }) => (type ? { model, name, type } : { model, graphql, rest })),
404
+ note: `${map.models.length} models are too many to list with their fields in one answer. Pass model for one model's fields, filters and sorts.`,
405
+ };
406
+ return boundAnswer(names, DEFAULT_MAX_CHARS, { hint: NAMES_HINT });
407
+ }
408
+
409
+ /**
410
+ * What a media value holds, read from the schema's `Media` type: its fields in
411
+ * order, what `type` means, and the kinds of file it takes when its type is
412
+ * an enum (MediaKind, spec 17 amendment 114). Only for a model with a media
413
+ * field.
414
+ */
415
+ function mediaNote(introspection, model) {
416
+ if (!model.fields.some((f) => f.kind === "media")) return {};
417
+ const types = introspection.__schema.types;
418
+ const media = types.find((t) => t.name === "Media");
419
+ if (!media?.fields) return {};
420
+ const type = media.fields.find((f) => f.name === "type");
421
+ let named = type?.type;
422
+ while (named?.ofType) named = named.ofType;
423
+ const kinds = named?.kind === "ENUM" ? types.find((t) => t.name === named.name)?.enumValues?.map((v) => v.name) : null;
424
+ const meaning = type?.description ? ` type: ${type.description}` : "";
425
+ const values = kinds?.length ? ` ${named.name}: ${kinds.join(", ")}.` : "";
426
+ return { media: `Media fields: ${media.fields.map((f) => f.name).join(", ")}.${meaning}${values}` };
427
+ }
428
+
429
+ /** `{ name: reason }` for each deprecated one of `values` (arguments or input fields), or null when none is. */
430
+ function deprecatedOf(values) {
431
+ const out = Object.fromEntries((values ?? []).filter((v) => v.isDeprecated).map((v) => [v.name, v.deprecationReason ?? "No longer supported"]));
432
+ return Object.keys(out).length ? out : null;
433
+ }
434
+
435
+ /**
436
+ * What a later Capa-Version phases out of reading `model`, with each reason:
437
+ * the arguments of its root fields, as `articles(before:)`, and per filter
438
+ * field the operators its filter input deprecates. Each still works until
439
+ * then, so the builders take them.
440
+ */
441
+ function inputDeprecations(introspection, model) {
442
+ const types = new Map(introspection.__schema.types.map((t) => [t.name, t]));
443
+ const roots = types.get(introspection.__schema.queryType.name)?.fields ?? [];
444
+ const args = {};
445
+ for (const root of roots.filter((r) => r.name === model.listField || r.name === model.singleField)) {
446
+ for (const [name, reason] of Object.entries(deprecatedOf(root.args) ?? {})) args[`${root.name}(${name}:)`] = reason;
447
+ }
448
+ const filters = new Map((types.get(model.filterType)?.inputFields ?? []).map((input) => [input.name, input]));
449
+ const operators = (fieldName) => {
450
+ const input = filters.get(fieldName);
451
+ return input ? deprecatedOf(types.get(namedType(input.type))?.inputFields) : null;
452
+ };
453
+ return { args: Object.keys(args).length ? args : null, operators };
454
+ }
455
+
456
+ /** One model: every field's type, filter operators and sortability, the sort values, an example. */
457
+ function modelDetail(summary, model, introspection) {
458
+ const plan = planQuery(summary, { model: model.namespace, first: 5 });
459
+ const example = printGraphQL(plan);
460
+ const deprecations = inputDeprecations(introspection, model);
461
+ const graphql = { list: model.listField, single: model.singleField, filter: model.filterType, sort: model.sortType };
462
+ if (deprecations.args) graphql.deprecatedArgs = deprecations.args;
463
+ return {
464
+ model: model.namespace,
465
+ type: model.typeName,
466
+ name: displayName(model),
467
+ graphql,
468
+ rest: `/api/entries/${model.namespace}`,
469
+ fields: model.fields.map((f) => {
470
+ const out = { name: f.name, type: f.graphqlType, capa: f.arrayType ? `${f.capaType} of ${f.arrayType}` : f.capaType };
471
+ if (f.namespace !== f.name) out.namespace = f.namespace;
472
+ if (f.target) out.target = f.target;
473
+ // Operators take a value; hops are fields of the related model, each taking operators of its own.
474
+ const operators = f.filterOps.filter((op) => !isHop(f, op));
475
+ const hops = f.filterOps.filter((op) => isHop(f, op));
476
+ if (operators.length) out.filter = operators;
477
+ const deprecatedFilter = deprecations.operators(f.name);
478
+ if (deprecatedFilter) out.deprecatedFilter = deprecatedFilter;
479
+ if (hops.length) out.hops = hops;
480
+ if (f.sortable) out.sortable = true;
481
+ if (f.deprecationReason) out.deprecated = f.deprecationReason;
482
+ return out;
483
+ }),
484
+ sort: model.sortValues,
485
+ example: { query: example.query, variables: example.variables, rest: printRest(summary, plan).url },
486
+ system: systemNote([model]),
487
+ ...mediaNote(introspection, model),
488
+ };
489
+ }
490
+
491
+ /**
492
+ * One model's details with its SDL (compact, sdl.mjs), each whole: an SDL cut
493
+ * mid-type is text nobody can parse, and a field list cut to one field says
494
+ * nothing. When both do not fit, the SDL is left out and the note says where
495
+ * to read it.
496
+ */
497
+ export function detailWithSDL(detail, introspection, summary, model, maxChars = DEFAULT_MAX_CHARS) {
498
+ const { sdl, alsoReferenced } = printSDL(introspection, summary, [model.namespace], { compact: true });
499
+ const both = { ...detail, sdl, ...(alsoReferenced.length ? { alsoReferenced } : {}) };
500
+ if (JSON.stringify(both).length <= maxChars) return both;
501
+ const note =
502
+ `sdl is left out: ${model.namespace}'s SDL is ${sdl.length.toLocaleString("en-US")} characters, more than fits beside its details in ` +
503
+ `${maxChars.toLocaleString("en-US")}. Read it from the resource ${modelSDLUri(model.namespace)}.`;
504
+ const left = { path: "sdl", kept: 0, total: 1 };
505
+ const bounded = boundAnswer(detail, maxChars - JSON.stringify({ note, truncated: [left] }).length, { hint: modelHint(model) });
506
+ return { ...bounded, note: bounded.note ? `${bounded.note} ${note}` : note, truncated: [...(bounded.truncated ?? []), left] };
507
+ }
508
+
509
+ /**
510
+ * The whole schema's SDL (compact, sdl.mjs), cut between models so the
511
+ * answer, serialized and with its note, is at most `maxChars`. A cut keeps
512
+ * Query and the shared types first, then whole models in namespace order,
513
+ * each with every type it uses, and names the models it left out.
514
+ */
515
+ export function wholeSDL(introspection, summary, maxChars = DEFAULT_MAX_CHARS) {
516
+ const { sdl } = printSDL(introspection, summary, undefined, { compact: true });
517
+ const whole = { version: summary.version, sdl };
518
+ if (JSON.stringify(whole).length <= maxChars) return whole;
519
+ const all = summary.models.map((m) => m.namespace);
520
+ // Below one whole model nothing is worth printing: Query and the shared types only matter beside one.
521
+ const firstModels = (count) => ({
522
+ version: summary.version,
523
+ sdl: count ? printSDL(introspection, summary, all.slice(0, count), { compact: true }).sdl : "",
524
+ truncated: `Printed ${count} of ${all.length} models, each with the types it uses. Not printed: ${someNames(all.slice(count))}. Pass model for one.`,
525
+ });
526
+ // The serialized size grows with the count, so the largest count that fits is found by bisection.
527
+ let fits = 0;
528
+ let over = all.length;
529
+ while (over - fits > 1) {
530
+ const mid = (fits + over) >> 1;
531
+ if (JSON.stringify(firstModels(mid)).length <= maxChars) fits = mid;
532
+ else over = mid;
533
+ }
534
+ return firstModels(fits);
535
+ }
536
+
537
+ /**
538
+ * Run a built query with each entry's cursor selected beside it (`edges {
539
+ * cursor }`) in the root list and in every relation list, and each relation
540
+ * list's pageInfo, so that when the answer is cut, a list's endCursor can be
541
+ * the cursor of the last entry kept, at any depth. The edges then leave the
542
+ * result; the relation lists' pageInfo leaves it once read (the document
543
+ * handed over selects neither). The cursors are keyed by where each list's
544
+ * connection sits in the answer (`result.data.bins.nodes[0].items`).
545
+ */
546
+ async function runWithCursors(config, plan, built) {
547
+ const result = await runQuery(config, printGraphQL(plan, { cursors: true }).query, built.variables, built.operationName);
548
+ const cursors = {};
549
+ const collect = (value, path) => {
550
+ if (Array.isArray(value)) {
551
+ value.forEach((item, i) => collect(item, `${path}[${i}]`));
552
+ return;
553
+ }
554
+ if (!isPlainObject(value)) return;
555
+ if (Array.isArray(value.edges) && Array.isArray(value.nodes)) {
556
+ cursors[path] = value.edges.map((edge) => edge?.cursor);
557
+ delete value.edges;
558
+ }
559
+ for (const [key, inner] of Object.entries(value)) collect(inner, `${path}.${key}`);
560
+ };
561
+ collect(result.data, "result.data");
562
+ return { result, cursors };
563
+ }
564
+
565
+ /**
566
+ * One page's entries as the API stores them, by id: each one's `fields` from
567
+ * REST, relations as references (explore.mjs says why it is read twice).
568
+ */
569
+ async function readStored(config, model, fields, nodes) {
570
+ const request = storedRequest(model, fields, nodes.map((node) => node.id));
571
+ if (!request) return [];
572
+ const { data } = await apiNextGet(config, request.path, request.query);
573
+ return (Array.isArray(data) ? data : []).map((entry) => [entry.id, entry.fields ?? {}]);
574
+ }
575
+
576
+ /**
577
+ * The entries each of `requests` matches, by field namespace, added to
578
+ * `found`: the values REST shows as null although one is stored
579
+ * (`hiddenValueRequests`), or the relation lists it shows empty that are
580
+ * stored as null (`nullListRequests`) or as a list (`listRequests`),
581
+ * explore.mjs.
582
+ */
583
+ async function readMatches(config, requests, found) {
584
+ for (const request of requests) {
585
+ const { data } = await apiNextGet(config, request.path, request.query);
586
+ const ids = found.get(request.namespace) ?? new Set();
587
+ for (const entry of Array.isArray(data) ? data : []) ids.add(entry.id);
588
+ found.set(request.namespace, ids);
589
+ }
590
+ }
591
+
592
+ /**
593
+ * An answer bound to `maxChars` with the relation lists that hold more than
594
+ * they show named at its top (`more`, lib/graphql/more.mjs) and a `note`
595
+ * saying how to read on. The lists and the note are paid for first, so the
596
+ * whole still fits; a list's cursor is left out before the list is.
597
+ */
598
+ function boundWithMore(bound, listsOf, uncut, maxChars, noteOf) {
599
+ const size = (value) => JSON.stringify(value).length;
600
+ // A quarter of the budget for the lists, or what the first one takes whole, up to half.
601
+ const share = (lists) => Math.min(Math.floor(maxChars / 2), Math.max(Math.floor(maxChars / 4), size(lists.slice(0, 1))));
602
+ const extra = (lists, budget) => {
603
+ const more = listedWithin(lists, budget);
604
+ return { more, note: noteOf(lists, more) };
605
+ };
606
+ /** `out` with the lists it holds named in the room left, or null when they do not fit. */
607
+ const withLists = (out, lists) => {
608
+ const room = maxChars - size(out) - 8;
609
+ const { more, note } = extra(lists, room - size(noteOf(lists, lists.slice(0, 1))) - 16);
610
+ const joined = { ...out, more, note: out.note ? `${out.note} ${note}` : note };
611
+ return size(joined) <= maxChars ? joined : null;
612
+ };
613
+ // The lists are read from the answer as cut: a cut list's endCursor is then
614
+ // the cursor of the last entry kept (lib/bound.mjs), so reading on skips none.
615
+ const first = listsOf(uncut);
616
+ let reserve = first.length ? size(extra(first, share(first))) + 8 : 0;
617
+ for (let attempt = 0; attempt < 3; attempt++) {
618
+ const out = bound(maxChars - reserve);
619
+ const lists = listsOf(out);
620
+ if (!lists.length) break;
621
+ const joined = withLists(out, lists);
622
+ if (joined) return joined;
623
+ reserve = size(extra(lists, share(lists))) + 8;
624
+ }
625
+ // The room kept for the lists cost the answer its data, or they never fit:
626
+ // the data comes first, and the lists go beside it only if they still fit.
627
+ const whole = bound(maxChars);
628
+ const lists = listsOf(whole);
629
+ return (lists.length && withLists(whole, lists)) || whole;
630
+ }
631
+
632
+ /** "1 relation list holds more entries than it shows", with how many are listed when not all. */
633
+ function moreLead(lists, more) {
634
+ const maybe = lists.some((list) => list.pageInfo === false) ? ", or may hold," : "";
635
+ const lead = lists.length === 1 ? `1 relation list holds${maybe} more entries than it shows` : `${lists.length} relation lists hold${maybe} more entries than they show`;
636
+ return lists.length > more.length ? `${lead}; more names ${more.length}.` : `${lead}: see more.`;
637
+ }
638
+
639
+ /** capa_graphql_query's note: the lists with a next are read on by calling it; the others by hand. */
640
+ function queryMoreNote(lists, more) {
641
+ const next = more.some((list) => list.next) ? " Call capa_graphql_query with a list's next to read on from its endCursor." : "";
642
+ const byHand = more.some((list) => !list.next && list.pageInfo !== false && list.endCursor !== undefined)
643
+ ? " A list under a list pages on only in a read of one entry: read the entry more[].entry with its single root field " +
644
+ "(article(id: ...) for articles), follow the path to the list, and pass its endCursor as after on it."
645
+ : "";
646
+ const blind = more.some((list) => list.pageInfo === false)
647
+ ? " A list with pageInfo: false selected no pageInfo, so it fills its page and may hold more: select pageInfo { hasNextPage endCursor } on it for its cursor."
648
+ : "";
649
+ const cut = more.some((list) => list.pageInfo !== false && list.endCursor === undefined)
650
+ ? " A list with no endCursor was cut to fit this answer: truncated says how to read on without skipping any."
651
+ : "";
652
+ return `${moreLead(lists, more)}${next}${byHand}${blind}${cut}`;
653
+ }
654
+
655
+ /**
656
+ * The capa_graphql_query arguments that read on the relation list `list`
657
+ * from its endCursor: the same document with that list's `after` set. Only
658
+ * for a list every entry above which is one entry (the path holds no list
659
+ * position), since the API pages a relation list only there; a list under a
660
+ * list is read on by hand, from `entry`.
661
+ */
662
+ function queryNextRead(args, list) {
663
+ if (typeof list.endCursor !== "string" || /\[\d+\]/.test(list.path)) return null;
664
+ const read = withAfter(args.query, args.variables, args.operationName, list.path.replace(/^data\./, ""), list.endCursor);
665
+ if (!read) return null;
666
+ return {
667
+ query: read.query,
668
+ ...(read.variables && Object.keys(read.variables).length ? { variables: read.variables } : {}),
669
+ ...(args.operationName ? { operationName: args.operationName } : {}),
670
+ };
671
+ }
672
+
673
+ /** capa_graphql_build's note: the read that goes on is in each list's `next`. */
674
+ function buildMoreNote(lists, more) {
675
+ return (
676
+ `${moreLead(lists, more)} Call capa_graphql_query with a list's next to read on: it reads the one entry that ` +
677
+ "holds the list from the list's endCursor, and names what is still left the same way."
678
+ );
679
+ }
680
+
681
+ /**
682
+ * The capa_graphql_query arguments that read on the relation list at `path`
683
+ * from its endCursor: the entry that holds it (`entry`, its id) read alone,
684
+ * the single relations between them, and the list as the plan selects it,
685
+ * with its pageInfo.
686
+ */
687
+ function nextRead(plan, at, list) {
688
+ if (typeof list.entry !== "string" || typeof list.endCursor !== "string") return null;
689
+ const keys = list.path.replace(/\[\d+\]/g, "").slice(at.length + 1).split(".").slice(1);
690
+ let model = plan.model;
691
+ let entryModel = plan.model;
692
+ let planned = plan.fields;
693
+ let chain = [];
694
+ for (const key of keys) {
695
+ if (key === "nodes" || key === "edges" || key === "node") {
696
+ entryModel = model;
697
+ chain = [];
698
+ continue;
699
+ }
700
+ const field = planned.find((p) => p.name === key);
701
+ if (!field?.target) return null;
702
+ chain.push(field);
703
+ model = field.target;
704
+ planned = field.children;
705
+ }
706
+ if (!chain.length) return null;
707
+ let inner = { ...chain.pop(), after: list.endCursor };
708
+ while (chain.length) inner = { ...chain.pop(), children: [{ name: "id", field: null }, inner] };
709
+ const read = {
710
+ model: entryModel,
711
+ mode: "single",
712
+ id: list.entry,
713
+ fields: [{ name: "id", field: null }, inner],
714
+ totalCount: false,
715
+ operationName: `${entryModel.typeName}ById`,
716
+ };
717
+ return printGraphQL(read, { pages: true });
718
+ }
719
+
720
+ /**
721
+ * `data` without the relation lists' pageInfo, which the document handed over
722
+ * does not select. With `keepMore`, a pageInfo that says more comes stays: it
723
+ * is the only word of it when the answer had no room to name the list.
724
+ */
725
+ function withoutNestedPages(data, plan, { keepMore = false } = {}) {
726
+ const strip = (value) => {
727
+ if (Array.isArray(value)) return value.forEach(strip);
728
+ if (!isPlainObject(value)) return;
729
+ for (const inner of Object.values(value)) {
730
+ if (isPlainObject(inner) && Array.isArray(inner.nodes)) {
731
+ if (!(keepMore && inner.pageInfo?.hasNextPage === true)) delete inner.pageInfo;
732
+ strip(inner.nodes);
733
+ } else {
734
+ strip(inner);
735
+ }
736
+ }
737
+ };
738
+ const root = data?.[plan.mode === "single" ? plan.model.singleField : plan.model.listField];
739
+ strip(plan.mode === "single" ? root : root?.nodes);
740
+ }
741
+
742
+ // -------------------------------------------------------------------- tools ---
743
+
744
+ /** The steps of a `clipped` path: `data.scalars.nodes[3].body` is data, scalars, nodes, 3, body. */
745
+ function pathSteps(path) {
746
+ return [...String(path).matchAll(/\[(\d+)\]|([^.[\]]+)/g)].map(([, index, key]) => (index !== undefined ? Number(index) : key));
747
+ }
748
+
749
+ /**
750
+ * capa_graphql_query with `slice`: one text value of the answer, from
751
+ * `offset`, as `text`, as much of it as `maxChars` holds. The value is found
752
+ * by the path a `clipped` note names, in the same run's answer. Where more
753
+ * follows, `clipped` says so as a cut answer does, its `kept` the offset to
754
+ * read on from, and the hint gives the next call.
755
+ */
756
+ function sliceOf(answer, { path, offset = 0 }, maxChars) {
757
+ const value = pathSteps(path).reduce((node, step) => (node == null ? undefined : node[step]), answer);
758
+ if (typeof value !== "string") {
759
+ const found = value === undefined ? "nothing" : Array.isArray(value) ? "a list" : value === null ? "null" : `a ${typeof value}`;
760
+ return {
761
+ error: `slice.path ${path} names ${found} in this query's answer, not a text value.`,
762
+ hint: 'Pass a path from the answer\'s clipped notes, such as "data.article.body", with the same query and variables.',
763
+ };
764
+ }
765
+ const total = value.length;
766
+ const piece = (length) => {
767
+ const end = Math.min(total, offset + length);
768
+ const cut = end < total && /[\uD800-\uDBFF]/.test(value[end - 1]) ? end - 1 : end;
769
+ const text = value.slice(offset, cut);
770
+ const kept = offset + text.length;
771
+ return kept < total
772
+ ? { text, clipped: [{ path, kept, total }], hint: `Call again with slice: { path: "${path}", offset: ${kept} } for the rest.` }
773
+ : { text };
774
+ };
775
+ // The longest piece that fits, found by bisection: escapes make JSON longer than the text.
776
+ let low = 0;
777
+ let high = Math.max(0, total - offset);
778
+ while (low < high) {
779
+ const middle = Math.ceil((low + high) / 2);
780
+ if (JSON.stringify(piece(middle)).length <= maxChars) low = middle;
781
+ else high = middle - 1;
782
+ }
783
+ return piece(low);
784
+ }
785
+
786
+ export const GRAPHQL_TOOLS = [
787
+ {
788
+ name: "capa_graphql_schema",
789
+ ...reads("GraphQL schema"),
790
+ ...BOUNDED,
791
+ ...SCOPE,
792
+ description:
793
+ "Start here to query content. Without model: every model this key can read (namespace, name, type), its roots " +
794
+ "and REST path, fields as `name: Type` and relations as `field -> model` (->> for a list), and the key's " +
795
+ "scopes. With model: each field's filter operators, hops and sortability, the sort values and a runnable example. " +
796
+ "sdl adds the SDL. Next: capa_explore_data for real values, capa_graphql_build to write a query.",
797
+ inputSchema: {
798
+ type: "object",
799
+ properties: {
800
+ model: { type: "string", description: 'Namespace, type or name, e.g. "articles". Omit for every model.' },
801
+ sdl: { type: "boolean", description: "Also return the SDL." },
802
+ },
803
+ additionalProperties: false,
804
+ },
805
+ outputSchema: answerSchema({
806
+ version: { type: "string" },
807
+ key: { type: "object", description: "The key's environment, bundle and scopes." },
808
+ models: { type: "array" },
809
+ system: { type: "string" },
810
+ model: { type: "string" },
811
+ type: { type: "string" },
812
+ name: { type: "string" },
813
+ graphql: { type: ["object", "string"] },
814
+ rest: { type: "string" },
815
+ fields: { type: "array" },
816
+ sort: { type: "array", items: { type: "string" } },
817
+ example: { type: "object" },
818
+ media: { type: "string" },
819
+ sdl: { type: "string" },
820
+ alsoReferenced: { type: "array", items: { type: "string" } },
821
+ truncated: { type: ["array", "string"], description: "What was cut to fit; for the whole SDL, the models not printed." },
822
+ }),
823
+ handler: async (config, args) => {
824
+ const { introspection, summary } = await loadSchema(config);
825
+ if (args.model === undefined) return args.sdl ? wholeSDL(introspection, summary) : schemaMap(config, summary);
826
+ let model;
827
+ try {
828
+ model = findModel(summary, args.model);
829
+ } catch (error) {
830
+ if (error instanceof BuildError) return buildRefusal(error);
831
+ throw error;
832
+ }
833
+ const detail = modelDetail(summary, model, introspection);
834
+ if (args.sdl) return detailWithSDL(detail, introspection, summary, model);
835
+ return boundAnswer(detail, DEFAULT_MAX_CHARS, { hint: modelHint(model) });
836
+ },
837
+ },
838
+
839
+ {
840
+ name: "capa_graphql_build",
841
+ ...reads("Build a GraphQL query"),
842
+ ...BOUNDED,
843
+ ...SCOPE,
844
+ description:
845
+ "Write a query from an intent: model, fields (a relation as { field, fields }; omit for every non-relation " +
846
+ "field), filter, sort, first, and after a page's endCursor or before its startCursor or \"end\". Returns query, " +
847
+ "variables, operationName and rest (the REST twin). code adds @capacms/sdk code (next for a Next.js server " +
848
+ "component, node, rest, all): ask on the final build. It runs once: check says if it worked; run: true returns " +
849
+ 'the data. filter takes the operators capa_graphql_schema { model } lists, e.g. { "views": { "gte": 10 } }. ' +
850
+ "Unknown names come back with didYouMean.",
851
+ inputSchema: {
852
+ type: "object",
853
+ required: ["model"],
854
+ properties: {
855
+ model: { type: "string" },
856
+ mode: { type: "string", enum: ["list", "single"] },
857
+ id: { type: "string", description: "Reads one entry by UUID." },
858
+ fields: {
859
+ type: "array",
860
+ description: 'e.g. ["title", { "field": "author", "fields": ["name"] }]',
861
+ items: {
862
+ anyOf: [
863
+ { type: "string" },
864
+ {
865
+ type: "object",
866
+ required: ["field"],
867
+ properties: {
868
+ field: { type: "string" },
869
+ fields: { type: "array" },
870
+ first: { type: "integer", minimum: 1, maximum: 200 },
871
+ sort: { type: ["string", "array"] },
872
+ },
873
+ additionalProperties: false,
874
+ },
875
+ ],
876
+ },
877
+ },
878
+ first: { type: "integer", minimum: 1, maximum: 200 },
879
+ after: { type: "string" },
880
+ before: { type: "string" },
881
+ sort: { type: ["string", "array"], items: { type: "string" }, maxItems: 3, description: "e.g. [\"views_DESC\"]" },
882
+ filter: { type: "object" },
883
+ totalCount: { type: "boolean" },
884
+ run: { type: "boolean" },
885
+ code: { type: "string", enum: ["none", "next", "node", "rest", "all"] },
886
+ maxChars: MAX_CHARS,
887
+ },
888
+ additionalProperties: false,
889
+ },
890
+ outputSchema: answerSchema({
891
+ query: { type: "string" },
892
+ variables: { type: "object" },
893
+ operationName: { type: "string" },
894
+ rest: { type: "string", description: "The REST twin, as the API prints it." },
895
+ restFromApi: { type: "string" },
896
+ sdk: { type: "object", description: "code's parts: document, next, node, rest." },
897
+ check: { type: "object", description: "Without run: whether a run worked." },
898
+ result: { type: "object", description: "With run: data, errors, cost, budget." },
899
+ more: { type: "array", description: "Relation lists holding more; next reads on." },
900
+ ...READ_WITH_KEYS,
901
+ }),
902
+ handler: async (config, args) => {
903
+ const { summary } = await loadSchema(config);
904
+ const { run, maxChars, code = "none", ...spec } = args;
905
+ let plan;
906
+ try {
907
+ plan = planQuery(summary, spec);
908
+ } catch (error) {
909
+ if (error instanceof BuildError) return buildRefusal(error);
910
+ throw error;
911
+ }
912
+ const built = printGraphQL(plan);
913
+ const rest = printRest(summary, plan);
914
+ const sdk = sdkCode(code, sdkSnippets(config, summary, plan, built, rest));
915
+ const answer = { ...built, rest: rest.url, ...(sdk ? { sdk } : {}) };
916
+ // Only the result is ever cut; the SDK code asked for goes whole first, as it comes back from a call without run.
917
+ // The run selects each list's cursors and every relation list's pageInfo (runWithCursors), so a cut list
918
+ // pages on from its last entry kept, and a relation list that holds more than it shows can say so.
919
+ const read = printGraphQL(plan, { cursors: true });
920
+ const connections = connectionsOf(read.query, built.variables, built.operationName, { at: "result.data" });
921
+ const bounded = (cursors = {}, listsOf = () => []) =>
922
+ boundWithMore(
923
+ (budget) =>
924
+ boundAnswer(answer, budget, { keep: BUILT_PARTS, drop: ["sdk"], cursors, connections, hint: BUILD_HINT, readOn: readOnFromBuild, fallbackHint: BUILD_FALLBACK_HINT }),
925
+ listsOf,
926
+ answer,
927
+ maxChars ?? DEFAULT_MAX_CHARS,
928
+ buildMoreNote,
929
+ );
930
+ let ran;
931
+ try {
932
+ ran = await runWithCursors(config, plan, built);
933
+ } catch (error) {
934
+ // The document is still what the agent asked for; the refusal is the check.
935
+ if (!(error instanceof QueryRefused)) throw error;
936
+ answer.check = { ok: false, status: error.status, errors: error.errors, ...fittingFirst(plan, error.errors), ...nextField(error.errors) };
937
+ return bounded();
938
+ }
939
+ const { result, cursors } = ran;
940
+ Object.assign(answer, readWith(result.environment, result.data, result.errors));
941
+ delete result.environment;
942
+ if (run) {
943
+ // The API's REST twin is the answer's `rest`, or `restFromApi` when they differ: not repeated here.
944
+ const { rest: _twin, ...data } = result;
945
+ answer.result = data;
946
+ } else {
947
+ const root = result.data?.[plan.mode === "single" ? plan.model.singleField : plan.model.listField];
948
+ answer.check = result.errors
949
+ ? { ok: false, errors: result.errors, ...nextField(result.errors) }
950
+ : {
951
+ ok: true,
952
+ entries: plan.mode === "single" ? (root ? 1 : 0) : root?.nodes?.length ?? 0,
953
+ ...(result.cost ? { cost: result.cost } : {}),
954
+ ...(result.budget ? { budget: result.budget } : {}),
955
+ };
956
+ }
957
+ const serverRest = result.rest?.[0]?.url;
958
+ if (serverRest && decodeURIComponent(serverRest) !== decodeURIComponent(rest.url)) answer.restFromApi = serverRest;
959
+ // A relation list that holds more than it shows is named, with the read that goes on (bin-250: 100 of 250).
960
+ // With run, it is read from the answer as cut; without, from the run the check made.
961
+ const listsIn = (data) =>
962
+ relationsWithMore(data, connections, "result.data").map((list) => {
963
+ const next = nextRead(plan, "result.data", list);
964
+ const { endCursor: _cursor, entry: _entry, ...shown } = list;
965
+ return { ...shown, path: run ? list.path : list.path.slice("result.".length), ...(next ? { next } : {}) };
966
+ });
967
+ const unrun = run ? null : listsIn(result.data);
968
+ const out = bounded(cursors, (cut) => unrun ?? listsIn(cut.result?.data));
969
+ if (out.result) withoutNestedPages(out.result.data, plan, { keepMore: !out.more });
970
+ return out;
971
+ },
972
+ },
973
+
974
+ {
975
+ name: "capa_graphql_query",
976
+ ...reads("Run a GraphQL query"),
977
+ ...BOUNDED,
978
+ ...SCOPE,
979
+ description:
980
+ "Run a GraphQL read: data, errors (each with code and hint), cost, budget and each root field's REST " +
981
+ "twin. No document yet? capa_graphql_build writes one. A large answer is cut, lists from their end " +
982
+ "(truncated says where) and long text (clipped says where; slice reads on): ask for fewer fields or a smaller " +
983
+ "first, not a larger maxChars. A refused query's error gives each code's fix and the next tool.",
984
+ inputSchema: {
985
+ type: "object",
986
+ required: ["query"],
987
+ properties: {
988
+ query: { type: "string", minLength: 1, description: "e.g. { articles(first: 5) { nodes { id title } } }" },
989
+ variables: { type: "object" },
990
+ operationName: { type: "string" },
991
+ slice: {
992
+ type: "object",
993
+ required: ["path"],
994
+ properties: { path: { type: "string" }, offset: { type: "integer", minimum: 0 } },
995
+ additionalProperties: false,
996
+ },
997
+ maxChars: MAX_CHARS,
998
+ },
999
+ additionalProperties: false,
1000
+ },
1001
+ outputSchema: answerSchema({
1002
+ data: { type: ["object", "null"] },
1003
+ errors: { type: "array", description: "Each { message, code, hint, path }." },
1004
+ cost: { type: "object" },
1005
+ budget: { type: "object" },
1006
+ deprecations: { type: "array" },
1007
+ rest: { type: "array", description: "Each root field's REST twin, { field, url }." },
1008
+ more: { type: "array", description: "Relation lists holding more." },
1009
+ text: { type: "string" },
1010
+ ...READ_WITH_KEYS,
1011
+ }),
1012
+ // A cut list keeps a pageInfo true of what it kept (lib/bound.mjs), read from the document so an aliased
1013
+ // connection and a page read backward are repaired too, and paging on from its cursor skips nothing. A
1014
+ // relation list whose own page holds more than it shows is named in `more` (bin-250: 100 of 250).
1015
+ handler: async (config, args) => {
1016
+ const connections = connectionsOf(args.query, args.variables, args.operationName, { at: "data" });
1017
+ const { environment, ...ran } = await runQuery(config, args.query, args.variables, args.operationName);
1018
+ if (args.slice) return sliceOf(ran, args.slice, args.maxChars ?? DEFAULT_MAX_CHARS);
1019
+ const result = { ...ran, ...readWith(environment, ran.data, ran.errors) };
1020
+ return boundWithMore(
1021
+ (budget) => boundAnswer(result, budget, { connections, spare: QUERY_SPARE, readOn: readOnWithSlice }),
1022
+ (out) =>
1023
+ relationsWithMore(out.data, connections, "data").map((list) => {
1024
+ const next = queryNextRead(args, list);
1025
+ return next ? { ...list, next } : list;
1026
+ }),
1027
+ result,
1028
+ args.maxChars ?? DEFAULT_MAX_CHARS,
1029
+ queryMoreNote,
1030
+ );
1031
+ },
1032
+ },
1033
+
1034
+ {
1035
+ name: "capa_explore_data",
1036
+ ...reads("Explore a model's content"),
1037
+ ...BOUNDED,
1038
+ ...SCOPE,
1039
+ description:
1040
+ "Measure a model's real content before you filter, sort or change it: entries by status, and per field " +
1041
+ "how many store a value, an empty one or none (as filters count), dangling references, mistyped " +
1042
+ "values, ranges, enum-like values, list sizes and fan-out. field measures one field. Reads up to " +
1043
+ "maxEntries (default 200), says if that is not all, and returns samples. For types use capa_graphql_schema; " +
1044
+ "to fetch entries, capa_graphql_build.",
1045
+ inputSchema: {
1046
+ type: "object",
1047
+ required: ["model"],
1048
+ properties: {
1049
+ model: { type: "string" },
1050
+ field: { type: "string" },
1051
+ sample: { type: "integer", minimum: 0, maximum: 10, description: "Default 3." },
1052
+ maxEntries: { type: "integer", minimum: 1, maximum: 1000 },
1053
+ maxChars: MAX_CHARS,
1054
+ },
1055
+ additionalProperties: false,
1056
+ },
1057
+ outputSchema: answerSchema({
1058
+ model: { type: "string" },
1059
+ environment: { type: "string" },
1060
+ drafts: { type: "string" },
1061
+ total: { type: ["integer", "null"] },
1062
+ scanned: { type: "integer" },
1063
+ statuses: { type: "object" },
1064
+ counts: { type: "string", description: "What each count means; left out, after samples, of a cut answer." },
1065
+ fields: { type: "array" },
1066
+ samples: { type: "array" },
1067
+ skipped: { type: "object" },
1068
+ errors: { type: "array" },
1069
+ }),
1070
+ handler: async (config, args) => {
1071
+ const { summary } = await loadSchema(config);
1072
+ let model;
1073
+ try {
1074
+ model = findModel(summary, args.model);
1075
+ } catch (error) {
1076
+ if (error instanceof BuildError) return buildRefusal(error);
1077
+ throw error;
1078
+ }
1079
+ let wanted = model.fields;
1080
+ if (args.field !== undefined) {
1081
+ wanted = model.fields.filter((f) => f.name === args.field || f.namespace === args.field);
1082
+ if (!wanted.length) {
1083
+ const names = model.fields.map((f) => f.name);
1084
+ return { error: `unknown field ${args.field} on ${model.typeName}`, didYouMean: didYouMean(args.field, names), available: names };
1085
+ }
1086
+ }
1087
+ const { measured: fields, skipped } = measurableFields(wanted);
1088
+ const maxEntries = args.maxEntries ?? 200;
1089
+ const query = exploreQuery(model, fields, Math.min(pageSizeFor(fields), maxEntries));
1090
+ const nodes = [];
1091
+ const stored = new Map();
1092
+ const hidden = new Map();
1093
+ const nullLists = new Map();
1094
+ const lists = new Map();
1095
+ let total = null;
1096
+ let environment = null;
1097
+ let after;
1098
+ while (nodes.length < maxEntries) {
1099
+ const body = await postQuery(config, query, after ? { after } : {}, "CapaExplore");
1100
+ const errors = trimErrors(body.errors);
1101
+ if (errors.length) return { error: "The exploration query failed.", errors, ...nextField(errors) };
1102
+ // A development key reads drafts too, so the counts say which key made them.
1103
+ environment ??= body.extensions?.capa?.environment ?? null;
1104
+ const page = body.data?.[model.listField];
1105
+ total ??= page?.totalCount ?? null;
1106
+ const pageNodes = (page?.nodes ?? []).slice(0, maxEntries - nodes.length);
1107
+ nodes.push(...pageNodes);
1108
+ for (const [id, fieldsStored] of await readStored(config, model, fields, pageNodes)) stored.set(id, fieldsStored);
1109
+ const pageIds = pageNodes.map((node) => node.id);
1110
+ await readMatches(config, hiddenValueRequests(model, fields, stored, pageIds), hidden);
1111
+ await readMatches(config, nullListRequests(model, fields, stored, pageIds), nullLists);
1112
+ await readMatches(config, listRequests(model, fields, stored, pageIds), lists);
1113
+ if (!page?.pageInfo?.hasNextPage) break;
1114
+ after = page.pageInfo.endCursor;
1115
+ }
1116
+ const scanned = nodes;
1117
+ const answer = {
1118
+ model: model.namespace,
1119
+ ...readWith(environment),
1120
+ total,
1121
+ scanned: scanned.length,
1122
+ statuses: statusCounts(scanned),
1123
+ counts: COUNTS_LEGEND,
1124
+ fields: fields.map((f) => fieldStats(f, scanned, stored, hidden.get(f.namespace), nullLists.get(f.namespace), lists.get(f.namespace) ?? new Set())),
1125
+ samples: samples(scanned, fields, args.sample ?? 3),
1126
+ };
1127
+ if (skipped.length) {
1128
+ answer.skipped = {
1129
+ fields: skipped.map((f) => f.name),
1130
+ why: "One query expands at most 12 relations. Pass field to measure one of these.",
1131
+ };
1132
+ }
1133
+ if (total !== null && scanned.length < total) {
1134
+ answer.note = `Measured the first ${scanned.length} of ${total} entries in the API's default order. Raise maxEntries (up to 1000) for more.`;
1135
+ }
1136
+ // Every field's numbers are what was asked for: the samples, then the legend, go first when it does not fit.
1137
+ return boundAnswer(answer, args.maxChars, { drop: EXPLORE_EXTRAS, hint: EXPLORE_HINT });
1138
+ },
1139
+ },
1140
+
1141
+ {
1142
+ name: "capa_explain_error",
1143
+ ...reads("Explain a Capa API error"),
1144
+ ...BOUNDED,
1145
+ surface: "local",
1146
+ description:
1147
+ "Explain a Capa API error from anywhere (a site's logs, the SDK, a paste) and what to change. Pass it as you got " +
1148
+ "it (a GraphQL errors array, a REST error body, an SDK CapaError, or plain text), or just its code. Returns what " +
1149
+ "the code means, the fix, the API's own hint, and the tool to call next. Errors from capa_graphql_query already " +
1150
+ "carry their next step. Works offline, needs no scope, and never calls Capa.",
1151
+ inputSchema: {
1152
+ type: "object",
1153
+ properties: {
1154
+ error: {
1155
+ type: ["string", "object", "array"],
1156
+ description: 'The error as received, e.g. { "errors": [...] } or "invalid_cursor: ...".',
1157
+ },
1158
+ code: { type: "string", description: 'Just the code, e.g. "query_too_complex".' },
1159
+ },
1160
+ additionalProperties: false,
1161
+ },
1162
+ outputSchema: answerSchema({
1163
+ errors: { type: "array", description: "Each { code, meaning, fix, next, docs }, with the API's own message and hint." },
1164
+ more: { type: "integer" },
1165
+ example: { type: "object" },
1166
+ }),
1167
+ handler: async (_config, args) => {
1168
+ const items = [...normalizeErrors(args.error), ...(args.code ? [{ code: args.code }] : [])];
1169
+ if (!items.length) {
1170
+ return { error: "Pass error (the error as received) or code.", example: { code: "query_too_complex" } };
1171
+ }
1172
+ const explained = items.slice(0, 5).map(explainOne);
1173
+ // A pasted log can be any length, and `message` echoes it.
1174
+ return boundAnswer(items.length > 5 ? { errors: explained, more: items.length - 5 } : { errors: explained }, DEFAULT_MAX_CHARS, { hint: EXPLAIN_HINT });
1175
+ },
1176
+ },
1177
+ ];