@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,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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
34
|
-
* with a description of its own (`besideGraphQL`), where
|
|
35
|
-
* and leaves some of the key's models out: REST is the only
|
|
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 =
|
|
188
|
-
|
|
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 &&
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* out
|
|
217
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|