@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.
@@ -6,8 +6,11 @@
6
6
  * registered there (registry.mjs), and without this tool an assistant would
7
7
  * lose every read for as long as GraphQL is off. It is registered then, and
8
8
  * where GraphQL leaves some of the key's models out (N1: their type names
9
- * collide), for those. Elsewhere capa_graphql_build writes the same REST read
10
- * and checks it, and a second way to read would only cost context.
9
+ * collide), for those. For a legacy key that is all: elsewhere it has
10
+ * capa_graphql_build, which writes the same REST read and checks it, and the
11
+ * legacy content tools besides, so a second way to read would only cost
12
+ * context. A `cap_` key gets it always (0.3.0): it has no legacy content tool,
13
+ * and REST reads every model it holds whatever else the deployment serves.
11
14
  *
12
15
  * It takes REST's parameters as docs/api/entries.md writes them and passes
13
16
  * them through, so the API's own grammar, limits and refusals apply. The
@@ -113,13 +116,24 @@ export const REST_TOOLS = [
113
116
  ...BOUNDED,
114
117
  surface: "api",
115
118
  scopePrefix: "instance:read",
116
- // Registered where the deployment serves no GraphQL, or where GraphQL leaves models out (registry.mjs).
119
+ // For a legacy key, registered where the deployment serves no GraphQL, or
120
+ // where GraphQL leaves models out. For a cap_ key, always (registry.mjs).
117
121
  whenOff: "graphql",
118
122
  description: `Read entries over REST (GET /api/entries/<model>): this deployment serves no GraphQL. Without model: the models this key reads. ${USAGE}`,
119
123
  /** The description where GraphQL is served and leaves models out, which this tool is then registered to read. */
120
124
  besideGraphQL:
121
125
  "Read entries over REST (GET /api/entries/<model>) of the models GraphQL leaves out, which capa_graphql_schema " +
122
126
  `lists as readable over REST only; read every other model with capa_graphql_build. ${USAGE}`,
127
+ /**
128
+ * The description a `cap_` key reads where GraphQL is not known to be off
129
+ * and leaves no model out: the tool is registered for every `cap_` key,
130
+ * since REST is the read a `cap_` key always has, and the GraphQL tools
131
+ * stay the way to shape one.
132
+ */
133
+ capDescription:
134
+ "Read entries over REST (GET /api/entries/<model>) of any model this key reads. Without model: the " +
135
+ "models this key reads. To shape a read (fields, filters, relations) as one checked query, start from " +
136
+ `capa_graphql_build. ${USAGE}`,
123
137
  inputSchema: {
124
138
  type: "object",
125
139
  properties: {
package/lib/server.mjs CHANGED
@@ -28,7 +28,7 @@ import { instructionsFor } from "./instructions.mjs";
28
28
  const PROTOCOL_VERSION = "2025-06-18";
29
29
  const SUPPORTED = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
30
30
 
31
- export const SERVER_INFO = { name: "capa-mcp", version: "0.2.1" };
31
+ export const SERVER_INFO = { name: "capa-mcp", version: "0.3.0" };
32
32
 
33
33
  const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
34
34
  const fail = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
@@ -58,8 +58,26 @@ function withAliases(tool, args) {
58
58
  */
59
59
  const configFor = (ctx, signal) => (signal ? { ...ctx.config, signal } : ctx.config);
60
60
 
61
- async function callTool(ctx, params, signal) {
62
- const name = params?.name;
61
+ /**
62
+ * One tool call, for every front end: the `tools/call` answer below, and the
63
+ * `capa` CLI, which runs the same tools by name and needs to tell a refusal
64
+ * from an answer without reading the words. Either way the caller gets the
65
+ * same checks and the same sentences, so a CLI command and an MCP tool can
66
+ * never disagree about an argument or an error.
67
+ *
68
+ * { ok: true, tool, answer } the tool's answer, bounded
69
+ * { ok: false, reason, text, tool?, error? }
70
+ *
71
+ * `reason` is "unknown_tool", "invalid_arguments" or "failed" (the handler
72
+ * threw: an API refusal, a host that never answered, a timeout). `text` is
73
+ * what the model reads. `error` is what the handler threw, for a caller that
74
+ * classifies it (`CapaApiError`, `CapaUnreachable`, `CapaTimeout`).
75
+ *
76
+ * An answer the API gave and the tool shaped into a refusal (`{ error,
77
+ * didYouMean }` for a model that does not exist) is `ok: true`: the model is
78
+ * meant to read it and correct in one step, and MCP sends it as a result.
79
+ */
80
+ export async function runTool(ctx, name, rawArgs, { signal } = {}) {
63
81
  const tools = ctx.tools;
64
82
  const tool = name ? tools.find((t) => t.name === name) : undefined;
65
83
  if (!tool) {
@@ -71,24 +89,16 @@ async function callTool(ctx, params, signal) {
71
89
  // cannot reach would answer "that one does not exist" with a suggestion
72
90
  // that is equally unusable.
73
91
  return {
74
- content: [
75
- {
76
- type: "text",
77
- text:
78
- `Unknown tool: ${String(name)}\n` +
79
- `Available: ${tools.map((t) => t.name).join(", ")}`,
80
- },
81
- ],
82
- isError: true,
92
+ ok: false,
93
+ reason: "unknown_tool",
94
+ text: `Unknown tool: ${String(name)}\nAvailable: ${tools.map((t) => t.name).join(", ")}`,
83
95
  };
84
96
  }
85
97
  // Checked against the schema the tool advertised, so a misspelled or
86
98
  // mistyped argument is refused by name instead of silently ignored.
87
- const args = withAliases(tool, params.arguments);
99
+ const args = withAliases(tool, rawArgs);
88
100
  const problem = checkArguments(tool.inputSchema, args);
89
- if (problem) {
90
- return { content: [{ type: "text", text: `Invalid arguments for ${name}: ${problem}` }], isError: true };
91
- }
101
+ if (problem) return { ok: false, reason: "invalid_arguments", tool, text: `Invalid arguments for ${name}: ${problem}` };
92
102
  try {
93
103
  const answer = await tool.handler(configFor(ctx, signal), args ?? {});
94
104
  // Every answer fits its budget and says how to ask for less (lib/bound.mjs),
@@ -97,11 +107,7 @@ async function callTool(ctx, params, signal) {
97
107
  // not cut again: only it knows which parts are never cut (a built query's
98
108
  // document and REST twin) and how a connection pages on once cut.
99
109
  const data = tool.whole || tool.bounded ? answer : boundAnswer(answer, args?.maxChars ?? DEFAULT_MAX_CHARS, { hint: tool.cutHint });
100
- // Compact: indentation is a third of the characters and tells a model nothing.
101
- const content = [{ type: "text", text: JSON.stringify(data) }];
102
- // A tool with an output schema also answers the object itself, for a
103
- // client that reads results as data; the text stays for one that does not.
104
- return tool.outputSchema ? { content, structuredContent: data } : { content };
110
+ return { ok: true, tool, answer: data };
105
111
  } catch (error) {
106
112
  // Same reasoning: an API failure is reported to the MODEL so it can adapt
107
113
  // (retry narrower, pick another tool), rather than surfaced as a transport
@@ -118,10 +124,21 @@ async function callTool(ctx, params, signal) {
118
124
  "(the Capa API rejected this call; a 401 means the key or tenant is wrong, a 404 means the model or id does not exist)"
119
125
  }`
120
126
  : String(error?.message ?? error);
121
- return { content: [{ type: "text", text }], isError: true };
127
+ return { ok: false, reason: "failed", tool, text, error };
122
128
  }
123
129
  }
124
130
 
131
+ /** `runTool` as MCP's `tools/call` result. */
132
+ export async function callTool(ctx, params, signal) {
133
+ const run = await runTool(ctx, params?.name, params?.arguments, { signal });
134
+ if (!run.ok) return { content: [{ type: "text", text: run.text }], isError: true };
135
+ // Compact: indentation is a third of the characters and tells a model nothing.
136
+ const content = [{ type: "text", text: JSON.stringify(run.answer) }];
137
+ // A tool with an output schema also answers the object itself, for a
138
+ // client that reads results as data; the text stays for one that does not.
139
+ return run.tool.outputSchema ? { content, structuredContent: run.answer } : { content };
140
+ }
141
+
125
142
  /**
126
143
  * One message's answer. `signal` is the call's own, aborted when the client
127
144
  * cancels it (session.mjs); a message that never reaches Capa has none.
package/lib/stdio.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * stdio.mjs — the server on a pair of streams: one JSON-RPC message per line
3
+ * in, one per line out, and every log line on the error stream.
4
+ *
5
+ * The bin runs it on the process's own streams, and so does `capa mcp`, which
6
+ * starts the same server with the credentials `capa login` stored. A test can
7
+ * run it on streams of its own.
8
+ */
9
+ import { createInterface } from "node:readline";
10
+ import { createSession } from "./session.mjs";
11
+
12
+ /**
13
+ * Serve `ctx` (connect.mjs) until the input ends. Resolves once every message
14
+ * read has been answered and the output has flushed, then calls `exit(0)`,
15
+ * which is `process.exit` unless the caller passes its own.
16
+ */
17
+ export function serveStdio(ctx, { input = process.stdin, output = process.stdout, error = process.stderr, exit = (code) => process.exit(code) } = {}) {
18
+ const rl = createInterface({ input, crlfDelay: Infinity });
19
+ const session = createSession(ctx, {
20
+ write: (message) => output.write(JSON.stringify(message) + "\n"),
21
+ log: (text) => error.write(`${text}\n`),
22
+ });
23
+ rl.on("line", session.receive);
24
+
25
+ return new Promise((resolve) => {
26
+ // Drain before exiting. `close` fires as soon as the input ends, which for
27
+ // a piped or scripted client is right after the last line, while a call may
28
+ // still be mid-fetch: exiting there would leave it unanswered, with nothing
29
+ // on stderr. A long-lived client holds stdin open, so only a pipe meets this.
30
+ rl.on("close", async () => {
31
+ try {
32
+ await session.drain();
33
+ } catch (e) {
34
+ error.write(`capa-mcp: drain failed: ${String(e?.stack ?? e)}\n`);
35
+ }
36
+ // Exit once the output has flushed, not before. process.exit() drops what
37
+ // stdout still buffers, and on macOS a write to a pipe is asynchronous: the
38
+ // answer to one tools/list came back cut at 8,192 bytes ("Unterminated
39
+ // string in JSON at position 8192", graphql-tools.test.mjs on Node 22.13).
40
+ // The empty write's callback runs after every earlier write has gone out.
41
+ output.write("", () => {
42
+ resolve();
43
+ exit(0);
44
+ });
45
+ });
46
+ });
47
+ }
package/lib/tools.mjs CHANGED
@@ -23,9 +23,8 @@
23
23
  * the key has only `read` — see the corrected note in client.mjs, which used to
24
24
  * claim no write verb existed behind a key at all.
25
25
  *
26
- * The rest of #4583's write half (edit content, validate, publish) still needs
27
- * tools of its own against `/v2/agent/model-instances`. Absent rather than
28
- * stubbed, because a tool that always fails teaches an agent to stop trying.
26
+ * Entries are written as drafts by lib/entry-tools.mjs, against
27
+ * `/v2/agent/model-instances`, for `cap_` keys only.
29
28
  *
30
29
  * WHY /v2/seo/ai-bundle IS NOT A TOOL, having been measured rather than assumed
31
30
  * It is API-key reachable and the source calls it a "RAG-ready export across
@@ -45,25 +44,37 @@
45
44
  * different tool with a different contract, and it should be built deliberately
46
45
  * rather than by pointing a `capa_get_*` at a large endpoint.
47
46
  *
48
- * TWO SURFACES, AND EVERY TOOL SAYS WHICH IT IS ON.
47
+ * THREE SURFACES, AND EVERY TOOL SAYS WHICH IT IS ON.
49
48
  *
50
- * `surface: "legacy"` means the tool calls `/v2/*` or `/v3/*`, which a `pk_` or
51
- * `sk_` key reaches and a `cap_` key is refused on before any lookup.
49
+ * `surface: "legacy"` means the tool calls `/v2/schema`, `/v2/api` or
50
+ * `/v2/schema/types`, which a `pk_` or `sk_` key reaches and a `cap_` key is
51
+ * refused on before any lookup.
52
+ * `surface: "agent"` means every call it makes goes to `/v2/agent/*`, which a
53
+ * legacy key reaches and a `cap_` key reaches where the deployment accepts one
54
+ * there (`GET /api/me` lists `/v2/agent` among the key's `surfaces`).
52
55
  * `surface: "api"` means it calls `/api/`, which both families reach.
53
- * `lib/registry.mjs` turns those two words into the tool list one key actually
56
+ * `lib/registry.mjs` turns those words into the tool list one key actually
54
57
  * gets, so an agent is never offered a tool its key cannot use.
55
58
  *
56
- * An `api` tool also declares the `scope` it needs. `/api/me` reports the
57
- * scopes a key holds, and a tool whose scope is missing from that list is left
58
- * out rather than registered and refused. The field is a string compared
59
- * whole: the page routes ask for UNSCOPED `instance:read`, so a key holding
60
- * only `instance:read:<modelId>` does not get them, which is the same answer
61
- * the API would give.
59
+ * An `api` or `agent` tool also declares the `scope` it needs. `/api/me`
60
+ * reports the scopes a key holds, and a tool whose scope is missing from that
61
+ * list is left out rather than registered and refused. The field is a string
62
+ * compared whole, or a list of which any one will do: the page routes ask for
63
+ * UNSCOPED `instance:read`, so a key holding only `instance:read:<modelId>`
64
+ * does not get them, which is the same answer the API would give. An `agent`
65
+ * tool's scope filters a `cap_` key only; a legacy key gets every agent tool,
66
+ * as it did before a `cap_` key could reach them.
67
+ *
68
+ * `capDescription` is what a `cap_` key's agent reads instead of
69
+ * `description`, where the two keys need different words: a legacy key's
70
+ * write is gated by its permission, a `cap_` key's by a scope.
62
71
  */
63
72
  import { reads, writes } from "./annotations.mjs";
64
73
  import { DEFAULT_MAX_CHARS, someNames } from "./bound.mjs";
65
74
  import { apiGet, apiGetText, apiNextGet, apiWrite, CapaApiError } from "./client.mjs";
66
75
  import { GRAPHQL_TOOLS } from "./graphql-tools.mjs";
76
+ import { ENTRY_TOOLS } from "./entry-tools.mjs";
77
+ import { MEDIA_TOOLS } from "./media-tools.mjs";
67
78
  import { REST_TOOLS } from "./rest-tools.mjs";
68
79
 
69
80
  /**
@@ -104,18 +115,98 @@ function typesAnswer(blocks, extra = {}) {
104
115
  return cut(fits);
105
116
  }
106
117
 
107
- /** /v2/schema is the one call that describes everything; cache it per process. */
118
+ /**
119
+ * /v2/schema is the one call that describes everything; cache it per process,
120
+ * or in `config.schemaCache` when the config carries one (graphql/schema.mjs
121
+ * says why the hosted server's does).
122
+ */
108
123
  let schemaCache = null;
109
124
  export function __resetSchemaCacheForTests() {
110
125
  schemaCache = null;
111
126
  }
112
127
  async function schema(config) {
128
+ if (config.schemaCache) {
129
+ if (!config.schemaCache.has("/v2/schema")) config.schemaCache.set("/v2/schema", await apiGet(config, "/v2/schema"));
130
+ return config.schemaCache.get("/v2/schema");
131
+ }
113
132
  if (!schemaCache) schemaCache = await apiGet(config, "/v2/schema");
114
133
  return schemaCache;
115
134
  }
116
135
 
117
136
  const lc = (s) => String(s ?? "").toLowerCase();
118
137
 
138
+ /**
139
+ * capa_get_model for a `cap_` key: ONE request, to `/v2/agent/models/<namespace>`.
140
+ *
141
+ * The legacy path reads `/v2/schema` first, and that mount refuses a `cap_`
142
+ * key whatever the deployment does on `/v2/agent`. The agent route resolves a
143
+ * namespace (or an id) itself and answers the fields, their ids, the layout
144
+ * and `embedByDefault` in one body, so everything the legacy answer holds
145
+ * comes from it, in the same shape:
146
+ *
147
+ * - `enumValues` is `[]` there where `/v2/schema` says null, so an empty
148
+ * list reads as absent, as it does on the legacy path.
149
+ * - `relationRef` is `""` or null on a field that relates to nothing.
150
+ * - `relations` is built from the fields as `/v2/schema` builds it. Its
151
+ * `toModel` is the field's `relationRef` as stored, which is the target's
152
+ * namespace on every real tenant (apps/api schema.ts); `/v2/schema`
153
+ * resolves the rare id-form one against every model, which one model's
154
+ * detail cannot, so an edge stored by id names the id.
155
+ *
156
+ * A miss is answered as the legacy path answers one, with the near names, read
157
+ * from the model list on the same mount (`model:read`, the scope this tool is
158
+ * registered by). A refusal is not a miss and throws as the API's error.
159
+ */
160
+ async function agentModel(config, namespace) {
161
+ let detail;
162
+ try {
163
+ detail = await apiGet(config, `/v2/agent/models/${encodeURIComponent(namespace)}`);
164
+ } catch (error) {
165
+ if (!(error instanceof CapaApiError && error.status === 404 && /model not found/i.test(error.body))) throw error;
166
+ let available = null;
167
+ try {
168
+ const list = await apiGet(config, "/v2/agent/models", { limit: 250 });
169
+ available = (list.data ?? []).map((m) => m.namespace);
170
+ } catch {
171
+ available = null;
172
+ }
173
+ return {
174
+ error: `No model with namespace "${namespace}".`,
175
+ // Omitted, not empty, when the list call failed too: `[]` would claim this tenant has no models.
176
+ ...(available === null
177
+ ? {}
178
+ : { didYouMean: available.filter((n) => lc(n).includes(lc(namespace)) || lc(namespace).includes(lc(n))), available }),
179
+ };
180
+ }
181
+ const fields = detail.fields ?? [];
182
+ const relations = [];
183
+ for (const f of fields) {
184
+ if (!f.relationRef) continue;
185
+ const isOne = f.type === "relation";
186
+ const isMany = f.type === "array" && f.arrayType === "relation";
187
+ if (!isOne && !isMany) continue;
188
+ relations.push({ fromModel: detail.namespace, fromField: f.namespace, toModel: f.relationRef, type: isOne ? "one" : "many" });
189
+ }
190
+ // Key for key what the legacy path answers, which the tests hold byte-equal.
191
+ return {
192
+ namespace: detail.namespace,
193
+ name: detail.modelName,
194
+ singleInstance: detail.isSingleInstance,
195
+ fields: fields.map((f) => ({
196
+ id: f.id,
197
+ namespace: f.namespace,
198
+ name: f.name,
199
+ type: f.arrayType ? `${f.type}<${f.arrayType}>` : f.type,
200
+ required: f.required,
201
+ relatesTo: f.relationRef || undefined,
202
+ enumValues: f.enumValues?.length > 0 ? f.enumValues : undefined,
203
+ })),
204
+ relations,
205
+ layout: detail.layout ?? null,
206
+ embedByDefault: detail.embedByDefault === true,
207
+ };
208
+ }
209
+
119
210
  /**
120
211
  * Capa stores most values as `{ type, value, sortOrder }` rather than bare
121
212
  * scalars — including `title`, which reads like a plain column and is not.
@@ -166,6 +257,57 @@ const instanceIdOf = (row) => {
166
257
  return typeof row?.id === "string" && UUID_RE.test(row.id) ? row.id : null;
167
258
  };
168
259
 
260
+ /** capa_set_model_layout's description, around the sentence that says what its write needs, which differs by key family. */
261
+ const LAYOUT_LEAD =
262
+ "Design a model's entry editor: cards in a main column and a side column, with each field " +
263
+ "placed in one of them. ";
264
+ const LAYOUT_DOCUMENT =
265
+ "Pass " +
266
+ "layout: null to reset the model to the plain linear editor. The document is " +
267
+ "{ v: 1, main: Card[], aside: Card[] } where a Card is { id, title, description?, " +
268
+ "collapsible, collapsed, items: Field[] } and a Field is { id, fieldId, width, display?, " +
269
+ "inline? }. `id` is any id you choose, unique in the document; `fieldId` is the field's id " +
270
+ "from capa_get_model. Widths are full, two_thirds, half or third. Relation fields may take " +
271
+ "display \"picker\", \"inline\" or \"embedded\" (embedded draws the related entry's own " +
272
+ "fields directly in the parent card), plus inline { allowCreate, allowRemove, " +
273
+ "allowReorder, summary } where summary is up to 3 field ids OF THE RELATED model. Leave " +
274
+ "display out and the related model's own embedByDefault decides, which capa_get_model " +
275
+ "reports. Read the current one with capa_get_model first: it returns the same document, " +
276
+ "so you can edit and write it back.";
277
+
278
+ /** capa_set_workspace's description, around the sentence that says what its write needs. */
279
+ const WORKSPACE_LEAD = "Create a workspace, or apply a document to one that exists. ";
280
+ const WORKSPACE_REST =
281
+ "`mode: \"replace\"` makes the workspace exactly the document you " +
282
+ "send; `mode: \"merge\"` adds what is missing and removes nothing. Applying the same " +
283
+ "document twice changes nothing, so it is safe to retry. It never touches a record: a " +
284
+ "workspace is navigation, and removing something from it does not delete it. It creates no " +
285
+ "models or entries: a reference to one that does not exist is not placed, and the answer says so first.";
286
+
287
+ /** The API's unresolved references, `kind:ref`, as a person would name them. */
288
+ const UNRESOLVED_KIND = { model: "model", instance: "entry", media_folder: "media folder" };
289
+
290
+ /**
291
+ * A workspace answer led by a sentence for what it could not place. The API
292
+ * applies the rest of the document and lists the misses as `unresolved`; an
293
+ * agent that asked for a model which does not exist read that list past and
294
+ * told the person the model had been created (scripts/tool-evals, M20).
295
+ */
296
+ function withNotPlaced(answer) {
297
+ const missed = Array.isArray(answer.unresolved) ? answer.unresolved : [];
298
+ if (missed.length === 0) return answer;
299
+ const named = missed.map((ref) => {
300
+ const at = String(ref).indexOf(":");
301
+ const kind = at > 0 ? String(ref).slice(0, at) : "";
302
+ return UNRESOLVED_KIND[kind] ? `${UNRESOLVED_KIND[kind]} ${String(ref).slice(at + 1)}` : String(ref);
303
+ });
304
+ const notPlaced =
305
+ `${missed.length} of the document's ${missed.length === 1 ? "references was" : "references were"} not placed, because nothing in this ` +
306
+ `project has that id or namespace: ${named.join(", ")}. This tool arranges what exists; it creates no models or entries. ` +
307
+ "Everything else was applied.";
308
+ return { notPlaced, ...answer };
309
+ }
310
+
169
311
  export const TOOLS = [
170
312
  {
171
313
  name: "capa_list_models",
@@ -212,7 +354,10 @@ export const TOOLS = [
212
354
  name: "capa_get_model",
213
355
  ...reads("Get a model and its editor layout"),
214
356
  whole: true,
215
- surface: "legacy",
357
+ // Every call goes to /v2/agent for a cap_ key (agentModel). A legacy key
358
+ // reads /v2/schema first, which its key reaches, exactly as it always did.
359
+ surface: "agent",
360
+ scope: "model:read",
216
361
  ...MODEL_ALIAS,
217
362
  description:
218
363
  "Full field definitions for ONE model as the editor sees them: every field's namespace, " +
@@ -228,6 +373,7 @@ export const TOOLS = [
228
373
  additionalProperties: false,
229
374
  },
230
375
  handler: async (config, args) => {
376
+ if (config.family === "cap") return agentModel(config, args.namespace);
231
377
  const s = await schema(config);
232
378
  const model = (s.models ?? []).find((m) => m.namespace === args.namespace);
233
379
  if (!model) {
@@ -309,22 +455,14 @@ export const TOOLS = [
309
455
  name: "capa_set_model_layout",
310
456
  ...writes("Set a model's editor layout", { idempotent: true }),
311
457
  whole: true,
312
- surface: "legacy",
458
+ surface: "agent",
459
+ scope: "model:update",
313
460
  aliases: { model: "modelId" },
314
461
  description:
315
- "Design a model's entry editor: cards in a main column and a side column, with each field " +
316
- "placed in one of them. WRITES: it needs an API key whose permission is `agent`. Pass " +
317
- "layout: null to reset the model to the plain linear editor. The document is " +
318
- "{ v: 1, main: Card[], aside: Card[] } where a Card is { id, title, description?, " +
319
- "collapsible, collapsed, items: Field[] } and a Field is { id, fieldId, width, display?, " +
320
- "inline? }. `id` is any id you choose, unique in the document; `fieldId` is the field's id " +
321
- "from capa_get_model. Widths are full, two_thirds, half or third. Relation fields may take " +
322
- "display \"picker\", \"inline\" or \"embedded\" (embedded draws the related entry's own " +
323
- "fields directly in the parent card), plus inline { allowCreate, allowRemove, " +
324
- "allowReorder, summary } where summary is up to 3 field ids OF THE RELATED model. Leave " +
325
- "display out and the related model's own embedByDefault decides, which capa_get_model " +
326
- "reports. Read the current one with capa_get_model first: it returns the same document, " +
327
- "so you can edit and write it back.",
462
+ LAYOUT_LEAD +
463
+ "WRITES: it needs an API key whose permission is `agent`. " +
464
+ LAYOUT_DOCUMENT,
465
+ capDescription: `${LAYOUT_LEAD}WRITES: it needs a key holding model:update. ${LAYOUT_DOCUMENT}`,
328
466
  inputSchema: {
329
467
  type: "object",
330
468
  properties: {
@@ -371,8 +509,11 @@ export const TOOLS = [
371
509
  return {
372
510
  error: error.message,
373
511
  hint:
374
- "This tool writes a model, so it needs a Capa API key whose permission is `agent`. " +
375
- "`read`, `write` and `delete` keys can call capa_get_model but not this one.",
512
+ config.family === "cap"
513
+ ? "This tool writes a model, so it needs a key holding the model:update scope. A key " +
514
+ "holding model:read can call capa_get_model but not this one."
515
+ : "This tool writes a model, so it needs a Capa API key whose permission is `agent`. " +
516
+ "`read`, `write` and `delete` keys can call capa_get_model but not this one.",
376
517
  };
377
518
  }
378
519
  throw error;
@@ -670,7 +811,8 @@ export const TOOLS = [
670
811
  name: "capa_list_workspaces",
671
812
  ...reads("List workspaces"),
672
813
  cutHint: "Name the workspace you want and read it with capa_get_workspace.",
673
- surface: "legacy",
814
+ surface: "agent",
815
+ scope: "workspace:read",
674
816
  description:
675
817
  "List this tenant's saved workspaces: the arrangements of the Capa admin's left rail. " +
676
818
  "Each one is a named tree of models, entries and media folders. Start here before " +
@@ -682,8 +824,8 @@ export const TOOLS = [
682
824
  includePrivate: {
683
825
  type: "boolean",
684
826
  description:
685
- "Also list workspaces individual people made for themselves. Off by default: " +
686
- "those are personal, and an agent almost always wants the team ones.",
827
+ "Has no effect: workspaces people made for themselves are personal, and the API " +
828
+ "never lists them to an API key. Accepted so existing calls keep working.",
687
829
  },
688
830
  },
689
831
  additionalProperties: false,
@@ -709,13 +851,16 @@ export const TOOLS = [
709
851
  name: "capa_get_workspace",
710
852
  ...reads("Get a workspace document"),
711
853
  whole: true,
712
- surface: "legacy",
854
+ surface: "agent",
855
+ scope: "workspace:read",
713
856
  description:
714
857
  "One workspace as a JSON document you can edit and write back with capa_set_workspace. " +
715
858
  "The tree is `Node[]`, where a Node is `{folder, children?}`, `{model}`, `{instance}` or " +
716
859
  "`{media_folder}`. A folder is named by the customer and holds any mix of the three, so " +
717
860
  "there are no fixed sections. Models come back as NAMESPACES, so you can read and " +
718
- "rearrange without resolving ids first.",
861
+ "rearrange without resolving ids first. A key that may update workspaces is shown every pin, " +
862
+ "so the document can name an entry that /v2/agent/model-instances does not serve that key: " +
863
+ "one never published, in a model it does not write.",
719
864
  inputSchema: {
720
865
  type: "object",
721
866
  properties: {
@@ -732,13 +877,15 @@ export const TOOLS = [
732
877
  name: "capa_set_workspace",
733
878
  ...writes("Create or apply a workspace", { idempotent: false }),
734
879
  cutHint: "The write is done; capa_get_workspace reads the whole document.",
735
- surface: "legacy",
736
- description:
737
- "Create a workspace, or apply a document to one that exists. WRITES: it needs an API key " +
738
- "with write permission. `mode: \"replace\"` makes the workspace exactly the document you " +
739
- "send; `mode: \"merge\"` adds what is missing and removes nothing. Applying the same " +
740
- "document twice changes nothing, so it is safe to retry. It never touches a record: a " +
741
- "workspace is navigation, and removing something from it does not delete it.",
880
+ surface: "agent",
881
+ // Either will do: without an id it creates (POST, workspace:create), with
882
+ // one it applies (PUT, workspace:update), and a key holding one of the two
883
+ // can do that half. The other half answers the API's 403 and the fix.
884
+ scope: ["workspace:create", "workspace:update"],
885
+ description: `${WORKSPACE_LEAD}WRITES: it needs an API key with write permission. ${WORKSPACE_REST}`,
886
+ capDescription:
887
+ `${WORKSPACE_LEAD}WRITES: it needs a key holding workspace:create to create one and ` +
888
+ `workspace:update to apply to one. ${WORKSPACE_REST}`,
742
889
  inputSchema: {
743
890
  type: "object",
744
891
  properties: {
@@ -788,7 +935,7 @@ export const TOOLS = [
788
935
  // workspace from this surface; saying so up front beats a 400.
789
936
  if (args.template !== undefined) body.template = args.template;
790
937
  const created = await apiWrite(config, "POST", "/v2/agent/workspaces", { ...body, visibility: "team" });
791
- return { created: true, workspace: created.workspace, changes: created.changes ?? null, unresolved: created.unresolved ?? [] };
938
+ return withNotPlaced({ created: true, workspace: created.workspace, changes: created.changes ?? null, unresolved: created.unresolved ?? [] });
792
939
  }
793
940
  const applied = await apiWrite(
794
941
  config,
@@ -796,14 +943,14 @@ export const TOOLS = [
796
943
  `/v2/agent/workspaces/${encodeURIComponent(args.id)}/tree`,
797
944
  { ...body, mode },
798
945
  );
799
- return {
946
+ return withNotPlaced({
800
947
  created: false,
801
948
  workspace: applied.workspace,
802
949
  // The summary is the point of the call: an agent reads it to find out
803
950
  // whether it actually changed anything, and a no-op says so.
804
951
  changes: applied.changes,
805
952
  unresolved: applied.unresolved ?? [],
806
- };
953
+ });
807
954
  } catch (error) {
808
955
  // The API's OWN message, not a guess at it. A 403 here means the key
809
956
  // may read and not write, which is a configuration fact somebody can
@@ -812,9 +959,13 @@ export const TOOLS = [
812
959
  return {
813
960
  error: error.message,
814
961
  hint:
815
- "This tool writes, so it needs a Capa API key whose permission is `write` " +
816
- "(or `agent`). A `read` key can call capa_list_workspaces and capa_get_workspace " +
817
- "but not this one.",
962
+ config.family === "cap"
963
+ ? "This tool writes, so it needs a key holding workspace:create to create a " +
964
+ "workspace and workspace:update to apply a document to one. A key holding " +
965
+ "workspace:read can call capa_list_workspaces and capa_get_workspace but not this one."
966
+ : "This tool writes, so it needs a Capa API key whose permission is `write` " +
967
+ "(or `agent`). A `read` key can call capa_list_workspaces and capa_get_workspace " +
968
+ "but not this one.",
818
969
  };
819
970
  }
820
971
  throw error;
@@ -1124,6 +1275,16 @@ export const TOOLS = [
1124
1275
  //
1125
1276
  // Reading entries when the deployment serves no GraphQL: lib/rest-tools.mjs.
1126
1277
  ...REST_TOOLS,
1278
+
1279
+ // ----------------------------------------- entries (/v2/agent, drafts) ---
1280
+ //
1281
+ // Creating and changing entries as drafts: lib/entry-tools.mjs.
1282
+ ...ENTRY_TOOLS,
1283
+
1284
+ // ------------------------------------ media (/v2/agent/file-uploads) ---
1285
+ //
1286
+ // Listing files and uploading one: lib/media-tools.mjs.
1287
+ ...MEDIA_TOOLS,
1127
1288
  ];
1128
1289
 
1129
1290
  /**