@capacms/mcp 0.2.0 → 0.3.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,425 @@
1
+ /**
2
+ * entry-tools.mjs — writing entries, through `/v2/agent/model-instances`.
3
+ *
4
+ * The content half of the agent surface. `DESIGN.md` kept these out until
5
+ * they had a shape of their own, and the shape is this:
6
+ *
7
+ * - A write is a DRAFT. `capa_create_entry` and `capa_update_entry` never
8
+ * publish, whatever the key may do: what a site shows changes only when
9
+ * something publishes, and that is a separate act with its own scope.
10
+ * - An entry is named by its id and a model by its namespace, the words the
11
+ * read tools answer with, so a read's answer feeds a write unchanged.
12
+ * - `data` is flat, `{ "<field namespace>": <value> }`, as the API takes it.
13
+ * The API stores each value as `{ type, value, sortOrder }`; the answer
14
+ * unwraps them, so an agent reads back what it wrote.
15
+ * - A key of `data` the model has no field for is refused before anything
16
+ * is written. The API builds what it stores from the model's own fields,
17
+ * so it drops such a key without a word and answers 200: a misspelt
18
+ * `titel` saved an identical version and read as a success. The check
19
+ * needs the model's fields, so a key without model:read (or, for an
20
+ * update, without instance:read) writes unchecked, and the answer says so.
21
+ * - An update MERGES: a field `data` leaves out keeps its value (the API's
22
+ * PUT merges). There is no way to clear a field by leaving it out; send
23
+ * it with null.
24
+ * - A refusal is the API's own words plus the next step, in band, so the
25
+ * model fixes the field the API named instead of guessing.
26
+ * - Publishing is its own tool, `capa_publish_entry`, with its own scope
27
+ * (`instance:publish`), and so is `capa_unpublish_entry`. Both change what
28
+ * every visitor reads, so both are marked destructive: a client asks its
29
+ * person before each call unless told not to, which is where "what may an
30
+ * agent do unattended" is answered, per client and per person.
31
+ *
32
+ * All four are `surface: "agent"` and `capOnly`: registered for a `cap_` key
33
+ * where `/api/me` lists `/v2/agent` and the key holds the scope, and never
34
+ * for a legacy key, whose tool list stays the one it had before these
35
+ * existed (registry.mjs).
36
+ * Any key holding the scope writes, whatever its environment. What the key
37
+ * READS back differs: a development or draft key reads drafts, so the entry
38
+ * it just made; a production key reads published entries only, so a draft it
39
+ * made shows up in its reads once something publishes it. The answer says so.
40
+ *
41
+ * The names, descriptions and input schemas here are provisional: the tool
42
+ * calls lane owns them. `@capacms/cli` reads them from this module, so a
43
+ * rename moves its commands with it.
44
+ */
45
+ import { writes } from "./annotations.mjs";
46
+ import { apiGet, apiWrite, CapaApiError } from "./client.mjs";
47
+ import { didYouMean } from "./suggest.mjs";
48
+
49
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+
51
+ /** A stored value `{ type, value, sortOrder }` as its value; anything else as it is. */
52
+ const unwrap = (v) => (v && typeof v === "object" && !Array.isArray(v) && "value" in v ? v.value : v ?? null);
53
+
54
+ /** The model's fields, by namespace, which is how `data` names them. */
55
+ const FIELD_HINT = "capa_get_model { namespace } lists the model's fields: their namespaces, types and which are required.";
56
+
57
+ /** What a write answers when the model's fields could not be read, so `data`'s names went unchecked. */
58
+ const UNCHECKED =
59
+ "The field names in data were not checked: reading the model's fields takes model:read (and instance:read for an " +
60
+ "update), which this key lacks. The API drops a name the model has no field for without saying so: entry.data is " +
61
+ "what was stored.";
62
+
63
+ /** A model detail's field namespaces, or null when it lists none to check against. */
64
+ function fieldNamespaces(detail) {
65
+ if (!Array.isArray(detail?.fields)) return null;
66
+ return detail.fields.map((f) => f?.namespace).filter((n) => typeof n === "string");
67
+ }
68
+
69
+ const quoted = (names) => (names.length === 1 ? `"${names[0]}"` : `${names.slice(0, -1).map((n) => `"${n}"`).join(", ")} and "${names.at(-1)}"`);
70
+
71
+ /**
72
+ * The refusal for keys of `data` the model has no field for, or null when
73
+ * every key is a field. Nothing is written: the API would drop them and
74
+ * answer success.
75
+ */
76
+ function unknownFields(data, namespaces, model) {
77
+ const unknown = Object.keys(data ?? {}).filter((key) => !namespaces.includes(key));
78
+ if (!unknown.length) return null;
79
+ const near = [...new Set(unknown.flatMap((key) => didYouMean(key, namespaces)))];
80
+ return {
81
+ error: `${model ? `The model "${model}"` : "This entry's model"} has no field${unknown.length === 1 ? "" : "s"} ${quoted(unknown)}.`,
82
+ ...(near.length ? { didYouMean: near } : {}),
83
+ available: namespaces,
84
+ next: `Nothing was written. Name each field by a namespace from available and send it again. ${FIELD_HINT}`,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * One entry as a write answers it: the instance row the API sends back,
90
+ * reduced to what an agent reads next. `status` is the CURRENT version's,
91
+ * `draft` after every write here; `live` says whether some version is
92
+ * published, which a draft saved over a published entry leaves in place.
93
+ */
94
+ /**
95
+ * A model's namespace from its id, or null. The create and update answers
96
+ * carry `modelId` only, and an agent reads the entry back by namespace. A key
97
+ * without model:read, or a model gone since, answers null rather than failing
98
+ * a write that already succeeded.
99
+ */
100
+ async function namespaceOf(config, modelId) {
101
+ if (!modelId) return null;
102
+ try {
103
+ const detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(modelId)}`);
104
+ return typeof detail?.namespace === "string" ? detail.namespace : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ export function entryOf(row, namespace) {
111
+ const version = row?.currentVersion ?? null;
112
+ const stored = version?.data ?? row?.data ?? {};
113
+ const data = {};
114
+ for (const [key, value] of Object.entries(stored)) data[key] = unwrap(value);
115
+ return {
116
+ id: row?.id ?? null,
117
+ model: namespace ?? row?.dataModel?.namespace ?? null,
118
+ title: row?.title ?? version?.title ?? null,
119
+ status: version?.status ?? null,
120
+ live: Boolean(row?.publishedVersionId),
121
+ versionId: version?.id ?? row?.currentVersionId ?? null,
122
+ versionNumber: version?.versionNumber ?? null,
123
+ data,
124
+ };
125
+ }
126
+
127
+ /** The API's own message from a refusal body, `{ error }` or text. */
128
+ function apiMessage(error) {
129
+ try {
130
+ const body = JSON.parse(error.body);
131
+ if (typeof body?.error === "string") return body.error;
132
+ } catch {
133
+ // Not JSON: the status line is the message.
134
+ }
135
+ return error.message;
136
+ }
137
+
138
+ /** What a key that lacks the write is told: the API's words and the scope it needs. */
139
+ function forbidden(error, scope, act = "writes entries") {
140
+ return {
141
+ error: apiMessage(error),
142
+ hint: `This tool ${act}, so it needs a key holding ${scope}: the "Content writer" preset under Developers > Keys has it.`,
143
+ };
144
+ }
145
+
146
+ /** A missing entry, in band, with where ids come from. */
147
+ const noEntry = (id) => ({ error: `No entry ${id} here.`, next: "Read entries with capa_read_entries or capa_graphql_query to find the id." });
148
+
149
+ /**
150
+ * What a publish tool's description says before anything else: it changes
151
+ * what a site serves. Both are marked destructive, so a client asks its
152
+ * person before each call unless told otherwise; the description asks the
153
+ * agent to do the same.
154
+ */
155
+ const PUBLIC =
156
+ "It changes what the public API serves, and sites, to every visitor, so publish only what the person asked to " +
157
+ "publish, or after they agreed.";
158
+
159
+ /** What a write's answer says about reading the draft back, whichever key made it. */
160
+ const READ_BACK =
161
+ "Saved as a draft. A development or draft key reads it now with capa_read_entries { model, id }; a production key reads published entries only, so it sees this once the entry is published (capa_publish_entry).";
162
+
163
+ /**
164
+ * The model's id, namespace and field namespaces, from a namespace or an id.
165
+ * A miss answers in band with the near names and the list, as
166
+ * `capa_get_model` does. An answer without a string id is a miss too: "." or
167
+ * ".." reaches the model LIST route, which answers 200 with no model in it.
168
+ *
169
+ * Reading a model needs model:read. Without it an id is taken as it is, with
170
+ * `fields` null (the write goes unchecked); a namespace cannot be turned into
171
+ * an id, so it is refused with the two ways forward.
172
+ */
173
+ async function resolveModel(config, model) {
174
+ let detail = null;
175
+ try {
176
+ detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(model)}`);
177
+ } catch (error) {
178
+ if (error instanceof CapaApiError && error.status === 403) {
179
+ if (UUID_RE.test(model)) return { id: model, namespace: null, fields: null };
180
+ return {
181
+ refusal: {
182
+ error: apiMessage(error),
183
+ hint: `Finding the model "${model}" by its namespace needs a key holding model:read. Pass the model's id instead, or use a key with model:read: the "Content writer" preset has it.`,
184
+ },
185
+ };
186
+ }
187
+ if (!(error instanceof CapaApiError && error.status === 404)) throw error;
188
+ }
189
+ if (typeof detail?.id === "string") {
190
+ return { id: detail.id, namespace: typeof detail.namespace === "string" ? detail.namespace : null, fields: fieldNamespaces(detail) };
191
+ }
192
+ let available = null;
193
+ try {
194
+ const list = await apiGet(config, "/v2/agent/models", { limit: 250, includeFields: "false" });
195
+ available = (list.data ?? []).map((m) => m.namespace);
196
+ } catch {
197
+ available = null;
198
+ }
199
+ return {
200
+ refusal: {
201
+ error: `No model "${model}" here.`,
202
+ // Omitted, not empty, when the list call failed too: `[]` would claim there are no models.
203
+ ...(available === null ? {} : { didYouMean: didYouMean(model, available), available }),
204
+ next: "Pass model with a namespace from available, or read the models with capa_graphql_schema.",
205
+ },
206
+ };
207
+ }
208
+
209
+ /**
210
+ * An entry's model, for checking an update's `data` before it is written:
211
+ * `{ namespace, fields }`, or null when it cannot be read (no instance:read or
212
+ * model:read, or any other refusal), and the update then goes ahead
213
+ * unchecked. A missing entry is left to the write, which answers 404.
214
+ */
215
+ async function modelOfEntry(config, id) {
216
+ try {
217
+ const row = await apiGet(config, `/v2/agent/model-instances/${encodeURIComponent(id)}`);
218
+ if (typeof row?.modelId !== "string") return null;
219
+ const detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(row.modelId)}`);
220
+ return { namespace: typeof detail?.namespace === "string" ? detail.namespace : null, fields: fieldNamespaces(detail) };
221
+ } catch (error) {
222
+ // Any refusal leaves the check undone, never the write: the PUT answers for itself.
223
+ if (error instanceof CapaApiError) return null;
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ /** A 400 from a write: the API names the field and the rule. */
229
+ function invalid(error) {
230
+ return { error: apiMessage(error), hint: `Fix the value the error names and send it again. ${FIELD_HINT}` };
231
+ }
232
+
233
+ const DATA = {
234
+ type: "object",
235
+ description:
236
+ 'Field values by field namespace, e.g. { "title": "Hello", "views": 3 }. A relation is the related entry\'s id ' +
237
+ "(a list of ids for a list); an image, video or file field takes the whole file object an upload answered.",
238
+ additionalProperties: true,
239
+ };
240
+
241
+ export const ENTRY_TOOLS = [
242
+ {
243
+ name: "capa_create_entry",
244
+ ...writes("Create a draft entry", { idempotent: false }),
245
+ cutHint: "The entry is created; capa_read_entries reads it whole by id.",
246
+ surface: "agent",
247
+ capOnly: true,
248
+ scope: "instance:create",
249
+ aliases: { namespace: "model" },
250
+ description:
251
+ "Create one entry in a model, as a DRAFT: nothing a site reads changes until it is published. data takes " +
252
+ `field values by namespace; required fields must be in it. ${FIELD_HINT} Answers the new entry's id and ` +
253
+ "what was stored. WRITES: it needs a key holding instance:create.",
254
+ inputSchema: {
255
+ type: "object",
256
+ properties: {
257
+ model: { type: "string", description: 'The model\'s namespace (or id), e.g. "articles".' },
258
+ data: DATA,
259
+ },
260
+ required: ["model", "data"],
261
+ additionalProperties: false,
262
+ },
263
+ handler: async (config, args) => {
264
+ const model = await resolveModel(config, args.model);
265
+ if (model.refusal) return model.refusal;
266
+ if (model.fields) {
267
+ const unknown = unknownFields(args.data, model.fields, model.namespace);
268
+ if (unknown) return unknown;
269
+ }
270
+ try {
271
+ const row = await apiWrite(config, "POST", "/v2/agent/model-instances", { modelId: model.id, data: args.data, publish: false });
272
+ return {
273
+ created: true,
274
+ entry: entryOf(row, model.namespace ?? (await namespaceOf(config, row?.modelId ?? model.id))),
275
+ next: READ_BACK,
276
+ ...(model.fields ? {} : { unchecked: UNCHECKED }),
277
+ };
278
+ } catch (error) {
279
+ if (error instanceof CapaApiError && error.status === 400) return invalid(error);
280
+ if (error instanceof CapaApiError && error.status === 403) return forbidden(error, "instance:create");
281
+ if (error instanceof CapaApiError && error.status === 404) return (await resolveModel(config, args.model)).refusal ?? { error: apiMessage(error) };
282
+ throw error;
283
+ }
284
+ },
285
+ },
286
+
287
+ {
288
+ name: "capa_update_entry",
289
+ // Not idempotent: every call saves a new draft version, the same values included.
290
+ ...writes("Update an entry as a draft", { idempotent: false }),
291
+ cutHint: "The update is saved; capa_read_entries reads the entry whole by id.",
292
+ surface: "agent",
293
+ capOnly: true,
294
+ scope: "instance:update",
295
+ description:
296
+ "Change fields of one entry, saved as a new DRAFT version: a published entry keeps serving its published " +
297
+ "version until it is published again. data MERGES: a field it leaves out keeps its value, and null clears " +
298
+ `one. ${FIELD_HINT} WRITES: it needs a key holding instance:update.`,
299
+ inputSchema: {
300
+ type: "object",
301
+ properties: {
302
+ id: { type: "string", description: "The entry's id." },
303
+ data: DATA,
304
+ },
305
+ required: ["id", "data"],
306
+ additionalProperties: false,
307
+ },
308
+ handler: async (config, args) => {
309
+ const model = await modelOfEntry(config, args.id);
310
+ if (model?.fields) {
311
+ const unknown = unknownFields(args.data, model.fields, model.namespace);
312
+ if (unknown) return unknown;
313
+ }
314
+ try {
315
+ const row = await apiWrite(config, "PUT", `/v2/agent/model-instances/${encodeURIComponent(args.id)}`, { data: args.data, publish: false });
316
+ return {
317
+ updated: true,
318
+ entry: entryOf(row, model?.namespace ?? (await namespaceOf(config, row?.modelId))),
319
+ next: READ_BACK,
320
+ ...(model?.fields ? {} : { unchecked: UNCHECKED }),
321
+ };
322
+ } catch (error) {
323
+ if (error instanceof CapaApiError && error.status === 400) return invalid(error);
324
+ if (error instanceof CapaApiError && error.status === 403) return forbidden(error, "instance:update");
325
+ if (error instanceof CapaApiError && error.status === 404) return noEntry(args.id);
326
+ throw error;
327
+ }
328
+ },
329
+ },
330
+
331
+ {
332
+ name: "capa_publish_entry",
333
+ ...writes("Publish an entry", { idempotent: true }),
334
+ cutHint: "The publish is done; capa_read_entries reads the entry.",
335
+ surface: "agent",
336
+ capOnly: true,
337
+ scope: "instance:publish",
338
+ description:
339
+ `Publish one entry: its newest draft becomes the version sites read. ${PUBLIC} An entry already published ` +
340
+ "with nothing newer answers skipped. versionId publishes an older version instead. WRITES: it needs a key " +
341
+ "holding instance:publish.",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: {
345
+ id: { type: "string", description: "The entry's id." },
346
+ versionId: { type: "string", description: "A version to publish instead of the newest; from the entry's history." },
347
+ },
348
+ required: ["id"],
349
+ additionalProperties: false,
350
+ },
351
+ handler: async (config, args) => {
352
+ try {
353
+ const res = await apiWrite(
354
+ config,
355
+ "POST",
356
+ `/v2/agent/model-instances/${encodeURIComponent(args.id)}/publish`,
357
+ args.versionId === undefined ? {} : { versionId: args.versionId },
358
+ );
359
+ const outcome = res?.outcome ?? {};
360
+ return {
361
+ published: !outcome.skipped,
362
+ id: outcome.instanceId ?? args.id,
363
+ versionId: outcome.versionId ?? null,
364
+ versionNumber: outcome.versionNumber ?? null,
365
+ ...(outcome.skipped ? { skipped: outcome.skipped, note: "Nothing changed: that version is already the one sites read." } : {}),
366
+ };
367
+ } catch (error) {
368
+ if (error instanceof CapaApiError && error.status === 403) return forbidden(error, "instance:publish", "publishes");
369
+ if (error instanceof CapaApiError && error.status === 404) return noEntry(args.id);
370
+ if (error instanceof CapaApiError && error.status === 400) return { error: apiMessage(error), hint: "The entry was not published. Fix what the error names and publish again." };
371
+ throw error;
372
+ }
373
+ },
374
+ },
375
+
376
+ {
377
+ name: "capa_unpublish_entry",
378
+ ...writes("Unpublish an entry", { idempotent: true }),
379
+ cutHint: "The unpublish is done; capa_read_entries reads the entry.",
380
+ surface: "agent",
381
+ capOnly: true,
382
+ scope: "instance:publish",
383
+ description:
384
+ `Unpublish one entry: sites stop reading it, and its content stays as a draft. ${PUBLIC} An entry with ` +
385
+ "nothing published answers skipped. WRITES: it needs a key holding instance:publish.",
386
+ inputSchema: {
387
+ type: "object",
388
+ properties: { id: { type: "string", description: "The entry's id." } },
389
+ required: ["id"],
390
+ additionalProperties: false,
391
+ },
392
+ handler: async (config, args) => {
393
+ let res;
394
+ try {
395
+ // The agent mount's one-entry unpublish is the bulk route with one id:
396
+ // POST /:id/unpublish is mounted for people only.
397
+ res = await apiWrite(config, "POST", "/v2/agent/model-instances/bulk-unpublish", { instanceIds: [args.id] });
398
+ } catch (error) {
399
+ if (error instanceof CapaApiError && error.status === 403) return forbidden(error, "instance:publish", "unpublishes");
400
+ throw error;
401
+ }
402
+ const result = (res?.results ?? []).find((r) => r.id === args.id);
403
+ if (!result) {
404
+ // 202: more than the inline limit, which one id never is; said rather than guessed at.
405
+ return { unpublished: false, id: args.id, note: "The API queued the unpublish instead of answering it.", poll: res?.poll ?? null };
406
+ }
407
+ if (!result.ok) {
408
+ if (result.code === "not_found" || result.code === "deleted") return noEntry(args.id);
409
+ return {
410
+ unpublished: false,
411
+ id: args.id,
412
+ error: result.message ?? "The entry was not unpublished.",
413
+ code: result.code ?? null,
414
+ note: "Nothing changed: the entry is still published, and sites keep reading it.",
415
+ next:
416
+ result.code === "forbidden"
417
+ ? "This key may not unpublish this model's entries: it needs instance:publish for the model."
418
+ : "Try capa_unpublish_entry again. If it fails the same way, tell the person: the entry can be unpublished in the admin.",
419
+ };
420
+ }
421
+ if (result.skipped) return { unpublished: false, id: args.id, skipped: result.skipped, note: "Nothing changed: no version of this entry is published." };
422
+ return { unpublished: true, id: args.id, versionId: result.versionId ?? null, versionNumber: result.versionNumber ?? null };
423
+ },
424
+ },
425
+ ];
@@ -144,7 +144,7 @@ const GUIDE = {
144
144
  // answer of a path the API does not serve has none. Only without one can
145
145
  // the request have been a query sent where GraphQL is off.
146
146
  fix: (error) =>
147
- "Send a query instead; nothing here can write. Content is edited in the Capa admin." +
147
+ "Send a query instead; GraphQL here cannot write. To save an entry, capa_create_entry and capa_update_entry write drafts where this key is offered them; otherwise content is edited in the Capa admin." +
148
148
  (error.hint
149
149
  ? ""
150
150
  : " If it was already a query, GraphQL is off on this deployment: read over REST instead, GET /api/entries/<model>."),
package/lib/explore.mjs CHANGED
@@ -214,8 +214,9 @@ export function listRequests(model, fields, storedById, ids) {
214
214
  }
215
215
 
216
216
  /** The exploration query for `fields` of `model`, paged by `$after`. `status` is always the system field (N5 renames a field called status). */
217
- export function exploreQuery(model, fields, first) {
218
- const selections = ["id", "status", ...fields.map(selectionFor)].join(" ");
217
+ export function exploreQuery(model, fields, first, label = null) {
218
+ const named = label && !fields.some((field) => field.name === label.name) ? [label.name] : [];
219
+ const selections = ["id", "status", ...named, ...fields.map(selectionFor)].join(" ");
219
220
  return (
220
221
  `query CapaExplore($after: String) { ${model.listField}(first: ${first}, after: $after) ` +
221
222
  `{ nodes { ${selections} } pageInfo { hasNextPage endCursor } totalCount } }`
@@ -455,10 +456,24 @@ export function statusCounts(nodes) {
455
456
  return counts;
456
457
  }
457
458
 
458
- /** The first `count` entries, long strings clipped, relations as ids. */
459
- export function samples(nodes, fields, count) {
459
+ /**
460
+ * The text field a person knows an entry by, `title` else `name`, or null.
461
+ * Samples carry it beside the id: an agent that explored one field and found
462
+ * an empty one in the samples answered with the UUID (scripts/tool-evals, M10).
463
+ */
464
+ export function labelField(model) {
465
+ for (const name of ["title", "name"]) {
466
+ const field = model.fields?.find((f) => f.name === name && f.kind === "scalar" && !f.arrayType && scalarKind(f) === "string");
467
+ if (field) return field;
468
+ }
469
+ return null;
470
+ }
471
+
472
+ /** The first `count` entries, each named by its label field, long strings clipped, relations as ids. */
473
+ export function samples(nodes, fields, count, label = null) {
460
474
  return nodes.slice(0, count).map((node) => {
461
475
  const out = { id: node.id };
476
+ if (label && !fields.some((field) => field.name === label.name)) out[label.name] = clip(node[label.name], SAMPLE_CHARS);
462
477
  for (const field of fields) {
463
478
  const value = node[field.name];
464
479
  if (field.kind === "relation") out[field.name] = value?.id ?? null;
@@ -216,7 +216,11 @@ export function summarizeIntrospection(introspection) {
216
216
  */
217
217
  export async function loadSchema(config, { now = Date.now() } = {}) {
218
218
  const key = `${config.baseUrl}|${config.apiKey}|${config.apiVersion}`;
219
- const hit = cache.get(key);
219
+ // A config may carry its own cache (`config.schemaCache`, a Map). The hosted
220
+ // server gives each request one: its callers share a base URL and send no
221
+ // key, so this process-wide key would hand one person's schema to another.
222
+ const store = config.schemaCache ?? cache;
223
+ const hit = store.get(key);
220
224
  if (hit && now - hit.at < SCHEMA_TTL_MS) return hit.value;
221
225
  let body;
222
226
  try {
@@ -232,6 +236,6 @@ export async function loadSchema(config, { now = Date.now() } = {}) {
232
236
  );
233
237
  }
234
238
  const value = { introspection: body.data, summary: summarizeIntrospection(body.data) };
235
- cache.set(key, { at: now, value });
239
+ store.set(key, { at: now, value });
236
240
  return value;
237
241
  }
@@ -27,8 +27,9 @@ export function graphqlNotServed(error) {
27
27
 
28
28
  /**
29
29
  * The error every GraphQL tool answers on such a deployment. It names the
30
- * switch and what can still read content: over REST with the same key, and
31
- * for a legacy `pk_`/`sk_` key the legacy content tools it is also offered.
30
+ * switch and what can still read content: over REST with the same key, the
31
+ * legacy content tools a legacy `pk_`/`sk_` key is also offered, and
32
+ * capa_read_entries, which a `cap_` key is always offered.
32
33
  *
33
34
  * Where `GET /api/me` found no `/api/` surface at all at startup
34
35
  * (`config.apiMissing`), GraphQL being off is not the likely cause: the
@@ -46,7 +47,13 @@ function graphqlOff(config, error) {
46
47
  `This deployment does not serve GraphQL: POST /api/graphql answered ${error.status} ${error.code} as a path it does not serve, ` +
47
48
  "which is what CAPA_API_GRAPHQL=off does. No GraphQL tool can answer until it is switched back on.\n" +
48
49
  "Next: tell the person running Capa that GraphQL is off here. " +
49
- (config.family === "legacy" ? "To read entries meanwhile, capa_list_content and capa_get_content use the legacy API. " : "") +
50
+ // A cap_ key is offered capa_read_entries beside every GraphQL tool it has (registry.mjs); a legacy key
51
+ // is offered it only where the startup probe saw GraphQL off, which is not this case.
52
+ (config.family === "cap"
53
+ ? "To read entries meanwhile, capa_read_entries reads them over REST with this key. "
54
+ : config.family === "legacy"
55
+ ? "To read entries meanwhile, capa_list_content and capa_get_content use the legacy API. "
56
+ : "") +
50
57
  "Code can read the same content over REST with this key: GET /api/entries/<model>."
51
58
  );
52
59
  }
@@ -56,7 +63,7 @@ function noApiAt(config, error) {
56
63
  `${printable(config.baseUrl)} serves no /api/ route: GET /api/me answered 404 when this server started, and POST /api/graphql answered ${error.status}${error.code ? ` ${error.code}` : ""}. ` +
57
64
  "Either CAPA_API_URL is not the Capa API's address, or this deployment does not serve /api/.\n" +
58
65
  "Next: tell the person running this server to check that CAPA_API_URL is the API's address with no route after it, " +
59
- "e.g. https://api.capacms.com, and restart it; if it is, /api/ is off on this deployment."
66
+ "e.g. https://cdn.capacms.com, and restart it; if it is, /api/ is off on this deployment."
60
67
  );
61
68
  }
62
69
 
@@ -39,7 +39,7 @@ import { splitOutside } from "./graphql/names.mjs";
39
39
  import { printSDL } from "./graphql/sdl.mjs";
40
40
  import { modelSDLUri } from "./resources.mjs";
41
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";
42
+ import { COUNTS_LEGEND, exploreQuery, fieldStats, hiddenValueRequests, labelField, listRequests, measurableFields, nullListRequests, pageSizeFor, samples, statusCounts, storedRequest } from "./explore.mjs";
43
43
 
44
44
  /** Every tool here but capa_explain_error reads through /api/graphql, which a deployment can switch off (registry.mjs). */
45
45
  const SCOPE = { surface: "api", scopePrefix: "instance:read", feature: "graphql" };
@@ -473,6 +473,13 @@ function modelDetail(summary, model, introspection) {
473
473
  // Operators take a value; hops are fields of the related model, each taking operators of its own.
474
474
  const operators = f.filterOps.filter((op) => !isHop(f, op));
475
475
  const hops = f.filterOps.filter((op) => isHop(f, op));
476
+ // How to read a relation: a list one is a connection, read through nodes.
477
+ // Agents wrote `faqs { title }` and got a validation error back.
478
+ if (f.kind === "relation" || f.kind === "relationList") {
479
+ const readable = hops.includes("title") ? "title" : hops.find((hop) => hop !== "id");
480
+ const inner = ["id", ...(readable ? [readable] : [])].join(" ");
481
+ out.select = f.kind === "relationList" ? `${f.name} { nodes { ${inner} } }` : `${f.name} { ${inner} }`;
482
+ }
476
483
  if (operators.length) out.filter = operators;
477
484
  const deprecatedFilter = deprecations.operators(f.name);
478
485
  if (deprecatedFilter) out.deprecatedFilter = deprecatedFilter;
@@ -783,6 +790,27 @@ function sliceOf(answer, { path, offset = 0 }, maxChars) {
783
790
  return piece(low);
784
791
  }
785
792
 
793
+ /**
794
+ * The sentence for a filter that asked text fields to have no value and
795
+ * matched nothing, or null. Blank text ("") is a value, so `{ null: true }`
796
+ * and `{ exists: false }` skip it: asked which services had no description,
797
+ * an agent filtered `{ description: { null: true } }`, got no entries, and
798
+ * said every service had one (scripts/tool-evals, M11).
799
+ */
800
+ function blankIsNotNull(model, filter) {
801
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) return null;
802
+ const asked = Object.entries(filter).filter(([, cond]) => cond && typeof cond === "object" && (cond.null === true || cond.exists === false));
803
+ const text = asked
804
+ .map(([name, cond]) => ({ field: model.fields.find((f) => f.name === name), op: cond.null === true ? "{ null: true }" : "{ exists: false }" }))
805
+ .filter(({ field }) => field && field.kind === "scalar" && !field.arrayType && field.graphqlType.replace(/!/g, "") === "String");
806
+ if (!text.length) return null;
807
+ const names = text.map(({ field }) => field.name).join(" or ");
808
+ return (
809
+ `No entry matched. ${text[0].op} matches ${names} only where it has no value at all; blank text ("") is a value, ` +
810
+ `so a blank ${names} is not counted here. capa_explore_data with field ${text[0].field.name} counts empty and missing apart.`
811
+ );
812
+ }
813
+
786
814
  export const GRAPHQL_TOOLS = [
787
815
  {
788
816
  name: "capa_graphql_schema",
@@ -912,7 +940,7 @@ export const GRAPHQL_TOOLS = [
912
940
  const built = printGraphQL(plan);
913
941
  const rest = printRest(summary, plan);
914
942
  const sdk = sdkCode(code, sdkSnippets(config, summary, plan, built, rest));
915
- const answer = { ...built, rest: rest.url, ...(sdk ? { sdk } : {}) };
943
+ let answer = { ...built, rest: rest.url, ...(sdk ? { sdk } : {}) };
916
944
  // Only the result is ever cut; the SDK code asked for goes whole first, as it comes back from a call without run.
917
945
  // The run selects each list's cursors and every relation list's pageInfo (runWithCursors), so a cut list
918
946
  // pages on from its last entry kept, and a relation list that holds more than it shows can say so.
@@ -956,6 +984,10 @@ export const GRAPHQL_TOOLS = [
956
984
  }
957
985
  const serverRest = result.rest?.[0]?.url;
958
986
  if (serverRest && decodeURIComponent(serverRest) !== decodeURIComponent(rest.url)) answer.restFromApi = serverRest;
987
+ const listed = plan.mode === "single" || result.errors ? null : (result.data?.[plan.model.listField]?.nodes?.length ?? null);
988
+ // First, in `note`: an answer with no entries is never cut, so nothing else writes that key here.
989
+ const note = listed === 0 ? blankIsNotNull(plan.model, spec.filter) : null;
990
+ if (note) answer = { note, ...answer };
959
991
  // A relation list that holds more than it shows is named, with the read that goes on (bin-250: 100 of 250).
960
992
  // With run, it is read from the answer as cut; without, from the run the check made.
961
993
  const listsIn = (data) =>
@@ -1086,7 +1118,8 @@ export const GRAPHQL_TOOLS = [
1086
1118
  }
1087
1119
  const { measured: fields, skipped } = measurableFields(wanted);
1088
1120
  const maxEntries = args.maxEntries ?? 200;
1089
- const query = exploreQuery(model, fields, Math.min(pageSizeFor(fields), maxEntries));
1121
+ const label = labelField(model);
1122
+ const query = exploreQuery(model, fields, Math.min(pageSizeFor(fields), maxEntries), label);
1090
1123
  const nodes = [];
1091
1124
  const stored = new Map();
1092
1125
  const hidden = new Map();
@@ -1122,7 +1155,7 @@ export const GRAPHQL_TOOLS = [
1122
1155
  statuses: statusCounts(scanned),
1123
1156
  counts: COUNTS_LEGEND,
1124
1157
  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),
1158
+ samples: samples(scanned, fields, args.sample ?? 3, label),
1126
1159
  };
1127
1160
  if (skipped.length) {
1128
1161
  answer.skipped = {
package/lib/index.mjs ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * index.mjs — @capacms/mcp as a module, for a front end that runs these tools
3
+ * without being an MCP client: the `capa` CLI (@capacms/cli), whose content
4
+ * commands are these tools by name, with the same arguments and the same
5
+ * errors, and whose `capa mcp` serves them over stdio.
6
+ *
7
+ * The bin (`capa-mcp`) is a dozen lines over the same three calls:
8
+ *
9
+ * const config = loadConfig(env); // CAPA_API_URL, CAPA_KEY, ...
10
+ * const { ctx, notes } = await connect(config); // /api/me and the probes
11
+ * await serveStdio(ctx); // or runTool(ctx, name, args)
12
+ *
13
+ * `ctx.tools` is the list this key is offered (registry.mjs), and `runTool`
14
+ * answers only from it: a front end never runs a tool the MCP server would
15
+ * not have offered the same key.
16
+ */
17
+ export { loadConfig, keyFamily, apiNextGet, CapaApiError, CapaTimeout, CapaUnreachable, DEFAULT_API_VERSION } from "./client.mjs";
18
+ export { connect } from "./connect.mjs";
19
+ export { runTool, callTool, dispatch, SERVER_INFO } from "./server.mjs";
20
+ export { serveStdio } from "./stdio.mjs";
21
+ export { createSession } from "./session.mjs";
22
+ export { selectTools, scopeNote, probeUploads } from "./registry.mjs";
23
+ export { checkArguments } from "./arguments.mjs";
24
+ export { TOOLS, TOOLS_BY_NAME } from "./tools.mjs";
25
+ // The hosted server (apps/api/src/routes/mcp.ts) builds its own MCP server
26
+ // over these tools and gives it the same instructions the stdio one sends.
27
+ export { instructionsFor } from "./instructions.mjs";
@@ -15,6 +15,8 @@ export function instructionsFor(tools) {
15
15
  "Capa is a headless CMS, and these tools read its content with one API key.",
16
16
  "Start with capa_graphql_schema: it names every model and field this key can read. Never guess a model, field or sort name it has not shown; a wrong one comes back with the nearest names.",
17
17
  "capa_explore_data measures real values before you filter, capa_graphql_build writes and checks a query, and capa_graphql_query runs one.",
18
+ "Run a query before you hand it to anyone; capa_graphql_build with run: true writes and runs one.",
19
+ "A list relation is read through nodes: faqs { nodes { id title } }.",
18
20
  "A production key reads published entries only; a development key also reads drafts, and every answer says which key read it.",
19
21
  "The resource capa://guide/querying has the call order, the filter and sort grammar, and the limits.",
20
22
  );