@capacms/mcp 0.2.1 → 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.
- package/README.md +247 -39
- package/bin/capa-mcp.mjs +17 -59
- package/lib/annotations.mjs +4 -4
- package/lib/arguments.mjs +2 -1
- package/lib/client.mjs +102 -20
- package/lib/connect.mjs +67 -0
- package/lib/entry-tools.mjs +425 -0
- package/lib/error-guide.mjs +1 -1
- package/lib/explore.mjs +19 -4
- package/lib/graphql/schema.mjs +6 -2
- package/lib/graphql/served.mjs +10 -3
- package/lib/graphql-tools.mjs +37 -4
- package/lib/index.mjs +27 -0
- package/lib/instructions.mjs +2 -0
- package/lib/media-tools.mjs +311 -0
- package/lib/registry.mjs +201 -33
- package/lib/rest-tools.mjs +17 -3
- package/lib/server.mjs +39 -22
- package/lib/stdio.mjs +47 -0
- package/lib/tools.mjs +210 -49
- package/lib/uploader-retry.mjs +82 -0
- package/package.json +6 -2
|
@@ -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
|
+
];
|
package/lib/error-guide.mjs
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
/**
|
|
459
|
-
|
|
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;
|
package/lib/graphql/schema.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
239
|
+
store.set(key, { at: now, value });
|
|
236
240
|
return value;
|
|
237
241
|
}
|
package/lib/graphql/served.mjs
CHANGED
|
@@ -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,
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/lib/graphql-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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";
|
package/lib/instructions.mjs
CHANGED
|
@@ -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
|
);
|