@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,311 @@
1
+ /**
2
+ * media-tools.mjs — a key's files: list them, and upload one from disk.
3
+ *
4
+ * NAMES, DESCRIPTIONS AND SCHEMAS ARE PROVISIONAL. The tool-calls lane owns
5
+ * them and may rename or reword them; the `capa` CLI reads them from here.
6
+ *
7
+ * capa_list_media GET /v2/agent/file-uploads (media:read)
8
+ * capa_upload_media POST /v2/agent/file-uploads/ticket (media:create),
9
+ * then the file to the uploader's /upload with the
10
+ * ticket as its bearer token. The key never goes to the
11
+ * uploader.
12
+ *
13
+ * Both are `capOnly` agent tools, offered only where the deployment SAYS it
14
+ * takes API key uploads (`onlyWhen`): the startup probe (registry.mjs
15
+ * `probeUploads`) asks the list route, which answers 404
16
+ * `agent_uploads_disabled` while `CAPA_AGENT_UPLOADS` is off. Unlike pages and
17
+ * GraphQL, an unsure probe leaves them out: a deployment older than the list
18
+ * route, or with the switch off (the default), would refuse every call. The
19
+ * list is also left out for a key the route refuses by its view: a
20
+ * production key that may neither upload nor write entries in every model
21
+ * gets the published view there (per surface since #366), and the file list
22
+ * has none. The upload also needs the uploader's address (the host the admin
23
+ * uploads to): CAPA_UPLOAD_URL, which Capa's hosted API defaults (client.mjs
24
+ * HOSTED_UPLOAD_URL). Without one the tool is left out and a line on stderr
25
+ * says so, rather than registering a tool that always fails. A production key
26
+ * holding `media:create` uploads like any other, as the API takes it since
27
+ * #349, and lists the files too since #366.
28
+ *
29
+ * A file row is the value an image, video or file field takes, whole, so both
30
+ * tools hand rows back as the API stores them and say so in `next`.
31
+ *
32
+ * WHICH FILES MAY BE UPLOADED. The server reads a path from the agent, so a
33
+ * prompt the agent read somewhere could ask for ~/.ssh/id_rsa. A path must
34
+ * resolve, symlinks followed, inside the upload folder: CAPA_UPLOAD_ROOT when
35
+ * the person sets it, else CLAUDE_PROJECT_DIR (the project Claude Code
36
+ * names), else the folder the server was started in. A folder nobody chose
37
+ * that is the home folder or holds it (/, /Users) is refused outright: an
38
+ * agent client started from there would put every file the person has in
39
+ * reach. Folders are compared by the name the disk gives them, so on a disk
40
+ * that does not tell cases apart (macOS, Windows) /users/me is still the home
41
+ * folder. Within the folder, no part of the path may start with a dot (.env,
42
+ * .git, .npmrc), and no file may be named like a private key or certificate
43
+ * (id_rsa*, *.pem, *.key, *.p12, *.pfx, *.ppk, *.p8, *.jks, *.keystore,
44
+ * *.gpg, and their backups such as server.pem.bak or privkey.pem~), unless
45
+ * CAPA_UPLOAD_ALLOW, which only the person sets, names it.
46
+ */
47
+ import fs from "node:fs";
48
+ import os from "node:os";
49
+ import path from "node:path";
50
+ import { reads, writes } from "./annotations.mjs";
51
+ import { apiGet, apiWrite, CapaApiError } from "./client.mjs";
52
+
53
+ /** The uploader can take a while for a large file; the API's 25 seconds is for API calls. */
54
+ const UPLOAD_TIMEOUT_MS = 5 * 60_000;
55
+
56
+ const FIELD_NEXT =
57
+ "To use a file in an entry, put the whole file object in an image, video or file field: capa_update_entry { id, data: { <field>: <file> } }.";
58
+
59
+ const TYPES = ["image", "video", "file", "audio", "document", "pdf"];
60
+
61
+ const MIME = {
62
+ ".png": "image/png",
63
+ ".jpg": "image/jpeg",
64
+ ".jpeg": "image/jpeg",
65
+ ".gif": "image/gif",
66
+ ".webp": "image/webp",
67
+ ".avif": "image/avif",
68
+ ".svg": "image/svg+xml",
69
+ ".pdf": "application/pdf",
70
+ ".mp4": "video/mp4",
71
+ ".mov": "video/quicktime",
72
+ ".webm": "video/webm",
73
+ ".mp3": "audio/mpeg",
74
+ ".wav": "audio/wav",
75
+ };
76
+
77
+ /** The 403 a published-view key gets on an agent route with no published view (apps/api middleware/agentView.ts). */
78
+ export const AGENT_ROUTE_NEEDS_DRAFT_ACCESS = "agent_route_needs_draft_access";
79
+
80
+ /**
81
+ * Names a private key or certificate goes by, and their backups and editor
82
+ * copies (server.pem.bak, privkey.pem~, "key.pem " with a trailing space).
83
+ * Hidden ones (.npmrc, .env*) are refused as hidden.
84
+ */
85
+ const KEY_FILE = /^id[-_](rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|ppk|p8|jks|keystore|gpg)(\.|~|\s|$)/i;
86
+
87
+ function apiMessage(error) {
88
+ try {
89
+ const body = JSON.parse(error.body);
90
+ if (typeof body?.error === "string") return { error: body.error, code: body.code ?? null };
91
+ } catch {
92
+ // Not JSON: the status line is the message.
93
+ }
94
+ return { error: error.message, code: error.code ?? null };
95
+ }
96
+
97
+ /** Is `inner` the folder `outer`, or inside it? */
98
+ const within = (inner, outer) => {
99
+ const relative = path.relative(outer, inner);
100
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
101
+ };
102
+
103
+ /**
104
+ * The path as the disk names it, symlinks followed. `.native` gives the case
105
+ * the disk stores: on a disk that does not tell cases apart, the JS
106
+ * `realpathSync` keeps the case it was typed in, and /users/me would not be
107
+ * the home folder /Users/me.
108
+ */
109
+ const realOr = (file) => {
110
+ try {
111
+ return fs.realpathSync.native(path.resolve(file));
112
+ } catch {
113
+ return path.resolve(file);
114
+ }
115
+ };
116
+
117
+ /**
118
+ * The folder files are read from: `{ root }`, or `{ refusal }` when nobody
119
+ * chose it and it is the home folder or holds it. See WHICH FILES above.
120
+ */
121
+ export function uploadFolder(config) {
122
+ if (config.uploadRoot) return { root: config.uploadRoot };
123
+ const root = config.projectDir || config.cwd || process.cwd();
124
+ const real = realOr(root);
125
+ const home = [config.home, os.homedir()].filter(Boolean).map(realOr).find((dir) => within(dir, real));
126
+ if (home) {
127
+ return {
128
+ refusal: {
129
+ error: `Files are read from ${real}, which ${real === home ? "is" : "holds"} the home folder ${home}, so none is uploaded: any of your files could be named.`,
130
+ hint: "Start the agent from the project's folder, or set CAPA_UPLOAD_ROOT to the folder files may be uploaded from.",
131
+ },
132
+ };
133
+ }
134
+ return { root };
135
+ }
136
+
137
+ /**
138
+ * The file at `given`, if the server may read it: `{ file, name }`, or
139
+ * `{ refusal }`. `allow` lists files the person named in CAPA_UPLOAD_ALLOW,
140
+ * which may be hidden or named like a key; they must still be in the folder.
141
+ */
142
+ export function uploadable(given, root, allow = []) {
143
+ const base = fs.realpathSync.native(path.resolve(root));
144
+ let real;
145
+ try {
146
+ real = fs.realpathSync.native(path.resolve(base, given));
147
+ } catch {
148
+ return { refusal: { error: `No file at ${given}.`, hint: `Paths are read from ${base}.` } };
149
+ }
150
+ const relative = path.relative(base, real);
151
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
152
+ return {
153
+ refusal: {
154
+ error: `${given} is outside ${base}, the folder files are uploaded from.`,
155
+ hint: "Copy the file into the project first, or start the server with CAPA_UPLOAD_ROOT set to its folder.",
156
+ },
157
+ };
158
+ }
159
+ const named = allow.some((entry) => realOr(path.resolve(base, entry)) === real);
160
+ if (!named && relative.split(path.sep).some((part) => part.startsWith("."))) {
161
+ return {
162
+ refusal: {
163
+ error: `${given} is under a hidden name, which is never uploaded: hidden files and folders (.env, .git, .ssh) hold secrets.`,
164
+ hint: "To upload it anyway, the person running the agent names it in CAPA_UPLOAD_ALLOW.",
165
+ },
166
+ };
167
+ }
168
+ if (!named && KEY_FILE.test(path.basename(real))) {
169
+ return {
170
+ refusal: {
171
+ error: `${given} looks like a private key or certificate (id_rsa*, *.pem, *.key, *.p12, *.pfx, *.ppk, *.p8, *.jks, *.keystore, *.gpg, or a backup of one), so it is not uploaded.`,
172
+ hint: "To upload it anyway, the person running the agent names it in CAPA_UPLOAD_ALLOW.",
173
+ },
174
+ };
175
+ }
176
+ if (!fs.statSync(real).isFile()) return { refusal: { error: `${given} is not a file.` } };
177
+ return { file: real, name: path.basename(real) };
178
+ }
179
+
180
+ export const MEDIA_TOOLS = [
181
+ {
182
+ name: "capa_list_media",
183
+ ...reads("List media files"),
184
+ surface: "agent",
185
+ capOnly: true,
186
+ onlyWhen: "mediaList",
187
+ scope: "media:read",
188
+ cutHint: "Pass search, type or a smaller size, or the next page.",
189
+ description:
190
+ "The project's media files, newest first: images, videos, documents. Each file object is the value an image, " +
191
+ "video or file field takes, so pass one whole to capa_update_entry or capa_create_entry. search matches part " +
192
+ "of the file name; type narrows to image, video, file, audio, document (PDFs included) or pdf.",
193
+ inputSchema: {
194
+ type: "object",
195
+ properties: {
196
+ search: { type: "string", description: "Part of the file name, any case." },
197
+ type: { type: "string", enum: TYPES, description: "One kind of file." },
198
+ folderId: { type: "string", description: "One media folder's files." },
199
+ page: { type: "integer", minimum: 1, description: "From 1." },
200
+ size: { type: "integer", minimum: 1, maximum: 100, description: "Files per page, 1 to 100. Default 25." },
201
+ },
202
+ additionalProperties: false,
203
+ },
204
+ handler: async (config, args) => {
205
+ try {
206
+ const res = await apiGet(config, "/v2/agent/file-uploads", {
207
+ search: args.search,
208
+ type: args.type,
209
+ folderId: args.folderId,
210
+ page: args.page,
211
+ size: args.size,
212
+ });
213
+ const pagination = res?.pagination ?? {};
214
+ return {
215
+ files: res?.data ?? [],
216
+ total: pagination.total ?? null,
217
+ page: pagination.currentPage ?? null,
218
+ hasNextPage: Boolean(pagination.hasNextPage),
219
+ next: FIELD_NEXT,
220
+ };
221
+ } catch (error) {
222
+ if (error instanceof CapaApiError && (error.status === 400 || error.status === 403 || error.status === 404)) {
223
+ const { error: message, code } = apiMessage(error);
224
+ const hint =
225
+ code === AGENT_ROUTE_NEEDS_DRAFT_ACCESS
226
+ ? "This key reads published content only (a production key that may neither upload nor write entries), and the file list is for keys that can: use a key that also holds media:create or can write entries in every model, or a development key."
227
+ : error.status === 403
228
+ ? "Listing files needs a key holding media:read."
229
+ : null;
230
+ return { error: message, ...(code ? { code } : {}), ...(hint ? { hint } : {}) };
231
+ }
232
+ throw error;
233
+ }
234
+ },
235
+ },
236
+
237
+ {
238
+ name: "capa_upload_media",
239
+ // Adds a file and replaces nothing: a second call makes a second file.
240
+ ...writes("Upload a media file", { idempotent: false, destructive: false }),
241
+ surface: "agent",
242
+ capOnly: true,
243
+ onlyWhen: "uploads",
244
+ requires: "uploadUrl",
245
+ scope: "media:create",
246
+ cutHint: "The file is uploaded; capa_list_media finds it by name.",
247
+ description:
248
+ "Upload one file from this machine to the project's media, and answer its file object, which is the value an " +
249
+ "image, video or file field takes. path is relative to the project folder, and must be inside it; hidden " +
250
+ "files and folders, and files named like a private key or certificate, are not read. The file gets every check an upload in the admin gets (type, size, " +
251
+ "the storage limit). WRITES: it needs a key holding media:create.",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ path: { type: "string", description: "The file, relative to the project folder, e.g. public/hero.jpg." },
256
+ folderId: { type: "string", description: "A media folder to put it in." },
257
+ isPublic: { type: "boolean", description: "Mark the file public. Default false, as in the admin." },
258
+ },
259
+ required: ["path"],
260
+ additionalProperties: false,
261
+ },
262
+ handler: async (config, args) => {
263
+ const folder = uploadFolder(config);
264
+ if (folder.refusal) return { uploaded: false, ...folder.refusal };
265
+ const target = uploadable(args.path, folder.root, config.uploadAllow ?? []);
266
+ if (target.refusal) return { uploaded: false, ...target.refusal };
267
+ let ticket;
268
+ try {
269
+ ticket = await apiWrite(config, "POST", "/v2/agent/file-uploads/ticket", {});
270
+ } catch (error) {
271
+ if (error instanceof CapaApiError && (error.status === 403 || error.status === 404)) {
272
+ const { error: message, code } = apiMessage(error);
273
+ return { error: message, ...(code ? { code } : {}), uploaded: false };
274
+ }
275
+ throw error;
276
+ }
277
+ const form = new FormData();
278
+ const blob = await fs.openAsBlob(target.file, { type: MIME[path.extname(target.name).toLowerCase()] ?? "application/octet-stream" });
279
+ form.append("file", blob, target.name);
280
+ if (args.folderId) form.append("folderId", args.folderId);
281
+ if (args.isPublic !== undefined) form.append("isPublic", String(Boolean(args.isPublic)));
282
+ // `wait=true`: answer once the bytes are stored, so the URL works when the agent uses it.
283
+ const url = new URL(`${config.uploadUrl}${ticket?.uploadPath ?? "/upload"}`);
284
+ url.searchParams.set("wait", "true");
285
+ const res = await fetch(url, {
286
+ method: "POST",
287
+ headers: { authorization: `Bearer ${ticket.ticket}` },
288
+ body: form,
289
+ signal: AbortSignal.timeout(config.uploadTimeoutMs ?? UPLOAD_TIMEOUT_MS),
290
+ });
291
+ const text = await res.text();
292
+ let body = null;
293
+ try {
294
+ body = JSON.parse(text);
295
+ } catch {
296
+ body = null;
297
+ }
298
+ if (!res.ok) {
299
+ return {
300
+ uploaded: false,
301
+ error: typeof body?.error === "string" ? body.error : `The uploader answered ${res.status}.`,
302
+ ...(body?.code ? { code: body.code } : {}),
303
+ ...(body?.reason ? { reason: body.reason } : {}),
304
+ };
305
+ }
306
+ const file = Array.isArray(body?.data) ? body.data[0] : null;
307
+ if (!file) return { uploaded: false, error: "The uploader answered without a file." };
308
+ return { uploaded: true, file, next: FIELD_NEXT };
309
+ },
310
+ },
311
+ ];
package/lib/registry.mjs CHANGED
@@ -10,17 +10,29 @@
10
10
  *
11
11
  * THE RULE, in one sentence per family:
12
12
  *
13
- * cap_ `/api/` only. The legacy tools are not registered at all,
14
- * because the surface they call refuses the prefix.
15
- * pk_ / sk_ both. Every legacy tool registers as before, and the `/api/`
16
- * tools register too, because `/api/` accepts a legacy key and
17
- * derives its scope list from the key's bundle.
18
- *
19
- * Then the scope filter, which applies to `/api/` tools only: `GET /api/me`
20
- * reports the scopes a key holds, and a tool whose `scope` is not among them is
21
- * left out. A tool that is registered and always answers 403 teaches an agent
22
- * to stop trying, which is the same reasoning that keeps unbuilt write tools
23
- * absent rather than stubbed.
13
+ * cap_ `/api/`, plus `/v2/agent` where `GET /api/me` lists it among
14
+ * the key's `surfaces`. The tools that read `/v2/schema`,
15
+ * `/v2/api` or `/v2/schema/types` are not registered at all,
16
+ * because those mounts refuse the prefix on every deployment.
17
+ * pk_ / sk_ all. Every legacy and agent tool registers as before, and the
18
+ * `/api/` tools register too, because `/api/` accepts a legacy
19
+ * key and derives its scope list from the key's bundle. The
20
+ * exception is a `capOnly` agent tool (the entry writes, added
21
+ * after 0.2.1): a legacy key's list stays the one it had.
22
+ *
23
+ * `/v2/agent` accepts a `cap_` key only on a deployment that switched that on,
24
+ * and `/api/me` is how this server learns it: `surfaces` holds "/v2/agent"
25
+ * there and not elsewhere. A deployment that lists no `surfaces` (it hides
26
+ * them, or predates them) says nothing, and nothing is not a yes, so the agent
27
+ * tools stay out for a `cap_` key until a deployment says so.
28
+ *
29
+ * Then the scope filter, for the `/api/` tools and for a `cap_` key's agent
30
+ * tools: `GET /api/me` reports the scopes a key holds, and a tool whose `scope`
31
+ * is not among them is left out. A tool that is registered and always answers
32
+ * 403 teaches an agent to stop trying, which is the same reasoning that keeps
33
+ * unbuilt write tools absent rather than stubbed. A legacy key's agent tools
34
+ * are not filtered, which keeps its list what it was: its permission gates
35
+ * them, and each write answers a 403 with the fix.
24
36
  *
25
37
  * Then the deployment filter, for the two features a deployment can switch
26
38
  * off. `/api/pages` and its siblings sit behind `CAPA_SITE_PREVIEW`, which is
@@ -30,11 +42,14 @@
30
42
  * ask once at startup, and a tool whose `feature` the deployment does not
31
43
  * serve is left out, for the same reason a tool that always answers 403 is.
32
44
  * A tool that stands in for a feature (`whenOff`, capa_read_entries for
33
- * GraphQL) is registered only where the probe found the feature off, or,
34
- * with a description of its own (`besideGraphQL`), where GraphQL is served
35
- * and leaves some of the key's models out: REST is the only way to read them.
45
+ * GraphQL) is registered for a legacy key only where the probe found the
46
+ * feature off, or, with a description of its own (`besideGraphQL`), where
47
+ * GraphQL is served and leaves some of the key's models out: REST is the only
48
+ * way to read them. A `cap_` key gets capa_read_entries wherever its scopes
49
+ * allow, described for reading beside GraphQL (`capDescription`), since it
50
+ * has no legacy content tool to read with instead.
36
51
  *
37
- * WHEN `/api/me` DOES NOT ANSWER, THE TOOLS REGISTER ANYWAY. Two real cases
52
+ * WHEN `/api/me` DOES NOT ANSWER, THE `/api/` TOOLS REGISTER ANYWAY. Two real cases
38
53
  * produce that: a deployment with the `/api/` surface switched off answers 404
39
54
  * on every path under it, and a machine with no route to the host answers
40
55
  * nothing at all. Neither is evidence about what the key may do. Dropping the
@@ -42,11 +57,15 @@
42
57
  * which is the wrong thing for an agent to conclude and an impossible one for a
43
58
  * person to debug from the tool list. So they register, the first call carries
44
59
  * the real error, and the reason goes to stderr where the person running the
45
- * server can read it.
60
+ * server can read it. A `cap_` key's agent tools are the exception: without
61
+ * an answer nothing says the deployment accepts the key on `/v2/agent`, and
62
+ * every legacy mount refuses it by default, so they stay out and the stderr
63
+ * line says why.
46
64
  */
47
- import { apiNextGet, apiNextPost, CapaApiError, CapaUnreachable } from "./client.mjs";
65
+ import { apiGet, apiNextGet, apiNextPost, CapaApiError, CapaUnreachable } from "./client.mjs";
48
66
  import { loadSchema } from "./graphql/schema.mjs";
49
67
  import { graphqlNotServed } from "./graphql/served.mjs";
68
+ import { AGENT_ROUTE_NEEDS_DRAFT_ACCESS } from "./media-tools.mjs";
50
69
  import { TOOLS } from "./tools.mjs";
51
70
 
52
71
  /**
@@ -61,6 +80,25 @@ import { TOOLS } from "./tools.mjs";
61
80
  */
62
81
  const ME_TIMEOUT_MS = 5000;
63
82
 
83
+ /** What `GET /api/me` lists among a `cap_` key's `surfaces` where the deployment accepts it on the agent surface. */
84
+ export const AGENT_SURFACE = "/v2/agent";
85
+
86
+ /** The tools whose every call goes to `/v2/agent`. */
87
+ // Not the media tools (`onlyWhen`): they wait on a probe that says yes, so no `/api/me` answer could bring them.
88
+ const AGENT_TOOL_NAMES = TOOLS.filter((tool) => tool.surface === "agent" && !tool.onlyWhen).map((tool) => tool.name);
89
+
90
+ /** The scopes `/api/me` reports, or null where the deployment does not report them. */
91
+ const scopesOf = (me) => (Array.isArray(me?.scopes) ? me.scopes : null);
92
+
93
+ /**
94
+ * Whether `/api/me` says this key reaches `/v2/agent`: true, false (it lists
95
+ * surfaces without it), or null (it lists none, so it does not say).
96
+ */
97
+ export const agentSurface = (me) => (Array.isArray(me?.surfaces) ? me.surfaces.includes(AGENT_SURFACE) : null);
98
+
99
+ /** The scopes a tool asks for, any one of which will do. */
100
+ const scopesFor = (tool) => (Array.isArray(tool.scope) ? tool.scope : [tool.scope]);
101
+
64
102
  /**
65
103
  * Ask `/api/me` what this key is and what it holds.
66
104
  *
@@ -99,7 +137,11 @@ export async function loadMe(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
99
137
  apiMissing: error instanceof CapaApiError && error.status === 404,
100
138
  reason:
101
139
  `${why}. The /api/ tools are registered anyway, so a call to one reports the real ` +
102
- `error instead of the tool silently not existing.`,
140
+ `error instead of the tool silently not existing.` +
141
+ (config?.family === "cap"
142
+ ? ` The model, workspace and entry tools (${inWords(AGENT_TOOL_NAMES)}) are not: nothing says this ` +
143
+ `deployment accepts a cap_ key on ${AGENT_SURFACE}.`
144
+ : ""),
103
145
  };
104
146
  }
105
147
  }
@@ -166,6 +208,46 @@ export async function probeRestOnly(config, { timeoutMs = ME_TIMEOUT_MS } = {})
166
208
  }
167
209
  }
168
210
 
211
+ /**
212
+ * What the file list told this key: `{ uploads, mediaList }`.
213
+ *
214
+ * `uploads` is whether this deployment takes uploads with an API key: `true`,
215
+ * `false`, or `null` when the probe could not tell, and `null` without asking
216
+ * for a legacy key, which is offered no media tool whatever the answer.
217
+ * `mediaList` is whether the list is this key's to call, as far as the probe
218
+ * shows: `false` where it is off, and where it answered 403
219
+ * `agent_route_needs_draft_access`, which a key in the published view gets
220
+ * (a production key that may neither upload nor write entries, #294 and
221
+ * #354, per surface since #366).
222
+ *
223
+ * Asked of the file list, `GET /v2/agent/file-uploads?size=1`, whose first
224
+ * check is the switch: with `CAPA_AGENT_UPLOADS` off it answers 404
225
+ * `agent_uploads_disabled` before it looks at the key. Any other answer, a 403
226
+ * included, means the switch is on. A 403 for a missing scope leaves the list
227
+ * to the scope check, which names the scope in its note. A deployment older
228
+ * than the route answers a plain 404, which says nothing, so neither tool is
229
+ * offered (`onlyWhen` wants a yes).
230
+ */
231
+ export async function probeUploads(config, { timeoutMs = ME_TIMEOUT_MS } = {}) {
232
+ const unsure = { uploads: null, mediaList: null };
233
+ if (config?.family !== "cap") return unsure;
234
+ try {
235
+ await apiGet({ ...config, requestTimeoutMs: timeoutMs }, "/v2/agent/file-uploads", { size: 1 });
236
+ return { uploads: true, mediaList: true };
237
+ } catch (error) {
238
+ if (!(error instanceof CapaApiError)) return unsure;
239
+ let code = null;
240
+ try {
241
+ code = JSON.parse(error.body)?.code ?? null;
242
+ } catch {
243
+ code = null;
244
+ }
245
+ if (error.status === 404) return code === "agent_uploads_disabled" ? { uploads: false, mediaList: false } : unsure;
246
+ if (error.status === 403 && code === AGENT_ROUTE_NEEDS_DRAFT_ACCESS) return { uploads: true, mediaList: false };
247
+ return { uploads: true, mediaList: true };
248
+ }
249
+ }
250
+
169
251
  /**
170
252
  * The tools this key gets.
171
253
  *
@@ -184,18 +266,36 @@ export function selectTools(config, me, deployment = {}) {
184
266
  // "this deployment is not telling", not "this key holds none". Filtering on
185
267
  // an empty list there would drop every `/api/` tool on a stack where they all
186
268
  // work.
187
- const scopes = Array.isArray(me?.scopes) ? me.scopes : null;
188
- const besideGraphQL = (tool) => Boolean(tool.besideGraphQL) && deployment[tool.whenOff] !== false && deployment.restOnly?.length > 0;
269
+ const scopes = scopesOf(me);
270
+ // Compared WHOLE, not by prefix: the page routes ask for unscoped
271
+ // `instance:read`, and a key holding only `instance:read:<modelId>` is
272
+ // refused there because a page list spans every model a page touches. The
273
+ // agent routes ask the same way ("model:read" anywhere), so a one-model
274
+ // grant does not answer them either.
275
+ const holds = (tool) => scopesFor(tool).some((scope) => scopes.includes(scope));
276
+ const off = (tool) => deployment[tool.whenOff] === false;
277
+ const besideGraphQL = (tool) => Boolean(tool.besideGraphQL) && !off(tool) && deployment.restOnly?.length > 0;
189
278
  const registered = TOOLS.filter((tool) => {
190
279
  // Answers from this package alone, so every key gets it.
191
280
  if (tool.surface === "local") return true;
192
281
  if (tool.surface === "legacy") return family === "legacy";
282
+ if (tool.surface === "agent") {
283
+ // A tool added after 0.2.1 for the scoped family only (`capOnly`, the
284
+ // entry writes): a legacy key's list stays the one it was pinned at, so
285
+ // nobody running a pk_ or sk_ key gets new tools by upgrading.
286
+ if (family === "legacy") return !tool.capOnly;
287
+ if (agentSurface(me) !== true) return false;
288
+ // The media tools: only where the deployment said it takes API key
289
+ // uploads (probeUploads; unsure is no), the list only where it did not
290
+ // refuse this key's view, and the upload only with an uploader address
291
+ // to send the file to.
292
+ if (tool.onlyWhen && deployment[tool.onlyWhen] !== true) return false;
293
+ if (tool.requires && !config?.[tool.requires]) return false;
294
+ return !scopes || holds(tool);
295
+ }
193
296
  if (tool.feature && deployment[tool.feature] === false) return false;
194
- if (tool.whenOff && deployment[tool.whenOff] !== false && !besideGraphQL(tool)) return false;
195
- // Compared WHOLE, not by prefix: the page routes ask for unscoped
196
- // `instance:read`, and a key holding only `instance:read:<modelId>` is
197
- // refused there because a page list spans every model a page touches.
198
- if (tool.scope && scopes) return scopes.includes(tool.scope);
297
+ if (tool.whenOff && family === "legacy" && !off(tool) && !besideGraphQL(tool)) return false;
298
+ if (tool.scope && scopes) return holds(tool);
199
299
  // By prefix, for the GraphQL tools: `/api/graphql` serves any key that can
200
300
  // read at least one model, and shows it only those models, so a one-model
201
301
  // `instance:read:<modelId>` key gets them and sees one model.
@@ -204,32 +304,100 @@ export function selectTools(config, me, deployment = {}) {
204
304
  }
205
305
  return true;
206
306
  });
207
- return registered.map((tool) => (besideGraphQL(tool) ? { ...tool, description: tool.besideGraphQL } : tool));
307
+ const describe = (tool) => {
308
+ if (besideGraphQL(tool)) return tool.besideGraphQL;
309
+ // A stand-in where its feature is off keeps the words for that, whichever the family.
310
+ if (family === "cap" && tool.capDescription && !(tool.whenOff && off(tool))) return tool.capDescription;
311
+ return tool.description;
312
+ };
313
+ return registered.map((tool) => {
314
+ const description = describe(tool);
315
+ return description === tool.description ? tool : { ...tool, description };
316
+ });
317
+ }
318
+
319
+ /**
320
+ * The upload tool, where everything but the uploader's address would offer it:
321
+ * the person running the server is told which variable brings it.
322
+ */
323
+ function uploadLine(config, me, deployment = {}) {
324
+ if (config?.family !== "cap" || !me || config.uploadUrl) return null;
325
+ const would = selectTools({ ...config, uploadUrl: "set" }, me, deployment).filter((tool) => tool.requires === "uploadUrl");
326
+ if (!would.length) return null;
327
+ return (
328
+ `capa-mcp: CAPA_UPLOAD_URL is not set, so ${inWords(would.map((tool) => tool.name))} is not offered: ` +
329
+ "it is the address of the uploader, the host the Capa admin sends files to. Only Capa's hosted API has a default."
330
+ );
208
331
  }
209
332
 
210
333
  /** `a`, `a and b`, `a, b and c`. */
211
334
  const inWords = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
212
335
 
336
+ /** `a`, `a or b`, `a, b or c`. */
337
+ const orWords = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} or ${names.at(-1)}`);
338
+
339
+ /** What a tool needs, in words: its scope, its prefix, or `either a or b` for a tool any of two scopes serves. */
340
+ const neededBy = (tool) => {
341
+ if (!tool.scope) return tool.scopePrefix;
342
+ const any = scopesFor(tool);
343
+ return any.length === 1 ? any[0] : `either ${orWords(any)}`;
344
+ };
345
+
213
346
  /**
214
- * One line for the person running the server when the key's scopes leave
215
- * tools out, naming the scope those tools need, or null when they leave none
216
- * out. A key holding only `media:read` would otherwise start with
217
- * capa_explain_error alone and nothing to say why.
347
+ * What the person running the server is told about tools this key is not
348
+ * offered, as one `capa-mcp:` line per reason, or null when nothing is left
349
+ * out that the key could have. Two reasons have lines:
350
+ *
351
+ * SCOPES. The key's scopes leave tools out: the line names the scope those
352
+ * tools need. A key holding only `media:read` would otherwise start with
353
+ * capa_explain_error alone and nothing to say why.
354
+ *
355
+ * /v2/agent. A `cap_` key on a deployment that does not say it accepts one
356
+ * there gets no model or workspace tool: the line names the ones its scopes
357
+ * would get, and what they need, which is a deployment and not a scope. It
358
+ * names no server switch: the person reading it may not run the server, and
359
+ * a switch name is the deployment's business. When `/api/me` did not answer
360
+ * at all, `loadMe`'s line says this instead.
218
361
  */
219
362
  export function scopeNote(config, me, deployment = {}) {
220
- const scopes = Array.isArray(me?.scopes) ? me.scopes : null;
363
+ const lines = [scopeLine(config, me, deployment), agentLine(config, me, deployment), uploadLine(config, me, deployment)].filter(Boolean);
364
+ return lines.length ? lines.join("\n") : null;
365
+ }
366
+
367
+ function scopeLine(config, me, deployment) {
368
+ const scopes = scopesOf(me);
221
369
  if (!scopes) return null;
222
370
  const offered = selectTools(config, me, deployment);
223
371
  const offeredNames = new Set(offered.map((tool) => tool.name));
224
372
  const left = selectTools(config, { ...me, scopes: null }, deployment).filter((tool) => !offeredNames.has(tool.name));
225
373
  if (!left.length) return null;
226
- const needed = inWords([...new Set(left.map((tool) => tool.scope ?? tool.scopePrefix))]);
227
374
  const shown = scopes.length > 4 ? [...scopes.slice(0, 4), `${scopes.length - 4} more`] : scopes;
228
375
  const held = scopes.length ? `holds ${inWords(shown)}` : "holds no scope";
229
376
  if (offered.every((tool) => tool.surface === "local")) {
230
377
  const local = inWords(offered.map((tool) => tool.name));
231
- return `capa-mcp: this key ${held}, and every tool that reads Capa needs ${needed}, so only ${local} ${offered.length === 1 ? "is" : "are"} offered.`;
378
+ // Any one of these would bring a tool, so they are named with "or".
379
+ const any = orWords([...new Set(left.flatMap((tool) => (tool.scope ? scopesFor(tool) : [tool.scopePrefix])))]);
380
+ return `capa-mcp: this key ${held}, and every tool that reads Capa needs ${any}, so only ${local} ${offered.length === 1 ? "is" : "are"} offered.`;
232
381
  }
382
+ const needed = inWords([...new Set(left.map(neededBy))]);
233
383
  const names = inWords(left.map((tool) => tool.name));
234
384
  return `capa-mcp: this key ${held}, so ${names} ${left.length === 1 ? "is" : "are"} not offered: ${left.length === 1 ? "it needs" : "they need"} ${needed}.`;
235
385
  }
386
+
387
+ function agentLine(config, me, found = {}) {
388
+ if (config?.family !== "cap" || !me) return null;
389
+ const accepts = agentSurface(me);
390
+ if (accepts === true) return null;
391
+ // The agent tools this key's scopes would get where the deployment accepts it there.
392
+ const would = selectTools(config, { ...me, surfaces: [...(me.surfaces ?? []), AGENT_SURFACE] }, found).filter((tool) => tool.surface === "agent");
393
+ if (!would.length) return null;
394
+ const deployment =
395
+ accepts === false
396
+ ? `this deployment does not accept a cap_ key on ${AGENT_SURFACE}`
397
+ : `this deployment does not say it accepts a cap_ key on ${AGENT_SURFACE}`;
398
+ const one = would.length === 1;
399
+ return (
400
+ `capa-mcp: ${deployment}, so ${inWords(would.map((tool) => tool.name))} ${one ? "is" : "are"} not offered: ` +
401
+ `${one ? "it needs" : "they need"} a deployment that accepts cap_ keys there.`
402
+ );
403
+ }