@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.
- package/README.md +250 -42
- package/bin/capa-mcp.mjs +18 -60
- package/lib/annotations.mjs +4 -4
- package/lib/arguments.mjs +2 -1
- package/lib/client.mjs +104 -22
- 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 +11 -4
- 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 +7 -3
package/lib/client.mjs
CHANGED
|
@@ -87,11 +87,37 @@ export class CapaApiError extends Error {
|
|
|
87
87
|
*/
|
|
88
88
|
export const DEFAULT_API_VERSION = "2026-10-01";
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The uploader Capa's hosted API pairs with: what the production admin uploads
|
|
92
|
+
* to (NEXT_PUBLIC_FILE_UPLOAD_SERVER_URL, infra/fly/fly.admin.toml and the
|
|
93
|
+
* live admin's bundle, 2026-10-03). It is the Fly app's own public address,
|
|
94
|
+
* as no capacms.com name points at the uploader yet.
|
|
95
|
+
*
|
|
96
|
+
* Used only when CAPA_API_URL is one of HOSTED_API_HOSTS over https and no
|
|
97
|
+
* CAPA_UPLOAD_URL is given. Any other API (local, self-hosted, a preview) gets
|
|
98
|
+
* no default, so a file is never sent to Capa's uploader with a ticket from
|
|
99
|
+
* somewhere else.
|
|
100
|
+
*/
|
|
101
|
+
export const HOSTED_UPLOAD_URL = "https://uploads.capacms.com";
|
|
102
|
+
|
|
103
|
+
/** The hosts Capa's hosted API answers on (docs/LAUNCH.md), whose uploader is HOSTED_UPLOAD_URL. */
|
|
104
|
+
export const HOSTED_API_HOSTS = ["api.capacms.com", "cdn.capacms.com", "capa-next-api.fly.dev", "capa-next-admin-api.fly.dev"];
|
|
105
|
+
|
|
106
|
+
function hostedUploadUrl(baseUrl) {
|
|
107
|
+
try {
|
|
108
|
+
const url = new URL(baseUrl);
|
|
109
|
+
return url.protocol === "https:" && HOSTED_API_HOSTS.includes(url.hostname) ? HOSTED_UPLOAD_URL : "";
|
|
110
|
+
} catch {
|
|
111
|
+
return "";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/**
|
|
91
116
|
* Which family a key belongs to, from its prefix and nothing else.
|
|
92
117
|
*
|
|
93
|
-
* `cap_` keys are hashed at rest and accepted on `/api
|
|
94
|
-
*
|
|
118
|
+
* `cap_` keys are hashed at rest and accepted on `/api/`, and on `/v2/agent`
|
|
119
|
+
* only where the deployment says so in `GET /api/me`'s `surfaces`: every other
|
|
120
|
+
* legacy mount refuses the prefix before it looks anything up. Every other key
|
|
95
121
|
* is legacy: `pk_`, `sk_` and the unprefixed keys older tenants hold are
|
|
96
122
|
* plaintext and reach both surfaces. That one fact is what decides which
|
|
97
123
|
* tools this server registers, so it is read once here rather than sniffed
|
|
@@ -144,15 +170,15 @@ export function loadConfig(env = process.env) {
|
|
|
144
170
|
//
|
|
145
171
|
// `X-Tenant-Key` is read by the `/v2` and `/v3` mounts, which is where every
|
|
146
172
|
// legacy tool calls, so a legacy key without it gets a refusal on the
|
|
147
|
-
// first call. A `cap_` key reaches `/api
|
|
148
|
-
//
|
|
149
|
-
// the registry gives a `cap_` key no
|
|
150
|
-
// be demanding a value nothing this
|
|
151
|
-
//
|
|
152
|
-
// matter.
|
|
173
|
+
// first call. A `cap_` key reaches `/api/`, and `/v2/agent` where the
|
|
174
|
+
// deployment accepts it there: both resolve the tenant from the key itself
|
|
175
|
+
// and never read the header, and the registry gives a `cap_` key no tool on
|
|
176
|
+
// any other mount. Demanding it would be demanding a value nothing this
|
|
177
|
+
// process can reach will read, and refusing to start without one would
|
|
178
|
+
// refuse to start over a value that does not matter.
|
|
153
179
|
//
|
|
154
|
-
// It is therefore EMPTY STRING in the returned config for a `cap_` key
|
|
155
|
-
//
|
|
180
|
+
// It is therefore EMPTY STRING in the returned config for a `cap_` key, and
|
|
181
|
+
// no request sends it for one whatever it holds (`tenantHeader` below).
|
|
156
182
|
// Only a legacy key that was given is missing its tenant: with no key yet,
|
|
157
183
|
// nothing says one is needed, and a cap_ key never needs it.
|
|
158
184
|
if (!tenantId && apiKey && family === "legacy") missing.push("CAPA_TENANT_ID");
|
|
@@ -175,7 +201,7 @@ export function loadConfig(env = process.env) {
|
|
|
175
201
|
: ` CAPA_TENANT_ID legacy keys only (pk_, sk_ or unprefixed): the tenant the key belongs to\n`;
|
|
176
202
|
throw new Error(
|
|
177
203
|
`capa-mcp: missing ${missing.join(", ")}.\n` +
|
|
178
|
-
` CAPA_API_URL the API, e.g. https://
|
|
204
|
+
` CAPA_API_URL the API, e.g. https://cdn.capacms.com (or CAPA_BASE_URL)\n` +
|
|
179
205
|
keyLine +
|
|
180
206
|
tenantLine +
|
|
181
207
|
` CAPA_API_VERSION optional; the dated /api/ version, default ${DEFAULT_API_VERSION}`
|
|
@@ -184,11 +210,39 @@ export function loadConfig(env = process.env) {
|
|
|
184
210
|
const route = routeIn(baseUrl);
|
|
185
211
|
if (route) {
|
|
186
212
|
throw new Error(
|
|
187
|
-
`capa-mcp: CAPA_API_URL is the API's address with no route, e.g. https://
|
|
213
|
+
`capa-mcp: CAPA_API_URL is the API's address with no route, e.g. https://cdn.capacms.com: drop ${route}.\n` +
|
|
188
214
|
" Every tool adds the route it calls (/api/graphql, /api/entries), so a URL ending in one calls it twice.",
|
|
189
215
|
);
|
|
190
216
|
}
|
|
191
|
-
|
|
217
|
+
// Optional, both. Where files go (the uploader, a host of its own) and which
|
|
218
|
+
// folder the upload tool may read them from (media-tools.mjs). The first
|
|
219
|
+
// defaults to Capa's uploader for Capa's hosted API only; without one,
|
|
220
|
+
// capa_upload_media is not offered.
|
|
221
|
+
const uploadUrl = (env.CAPA_UPLOAD_URL || "").trim().replace(/\/+$/, "") || hostedUploadUrl(baseUrl);
|
|
222
|
+
if (uploadUrl && !/^https?:\/\/[^/]/.test(uploadUrl)) {
|
|
223
|
+
throw new Error(`capa-mcp: CAPA_UPLOAD_URL is the uploader's address, e.g. https://uploads.example.com; got ${uploadUrl}.`);
|
|
224
|
+
}
|
|
225
|
+
// The folder: CAPA_UPLOAD_ROOT when the person names one, else the project
|
|
226
|
+
// Claude Code names (CLAUDE_PROJECT_DIR), else the server's own folder.
|
|
227
|
+
// CAPA_UPLOAD_ALLOW names files the upload tool would otherwise refuse
|
|
228
|
+
// (hidden ones, keys and certificates), separated as PATH is.
|
|
229
|
+
const uploadRoot = (env.CAPA_UPLOAD_ROOT || "").trim();
|
|
230
|
+
const projectDir = (env.CLAUDE_PROJECT_DIR || "").trim();
|
|
231
|
+
const uploadAllow = String(env.CAPA_UPLOAD_ALLOW || "")
|
|
232
|
+
.split(process.platform === "win32" ? ";" : ":")
|
|
233
|
+
.map((entry) => entry.trim())
|
|
234
|
+
.filter(Boolean);
|
|
235
|
+
return {
|
|
236
|
+
baseUrl,
|
|
237
|
+
apiKey,
|
|
238
|
+
tenantId,
|
|
239
|
+
apiVersion,
|
|
240
|
+
family,
|
|
241
|
+
...(uploadUrl ? { uploadUrl } : {}),
|
|
242
|
+
...(uploadRoot ? { uploadRoot } : {}),
|
|
243
|
+
...(projectDir ? { projectDir } : {}),
|
|
244
|
+
...(uploadAllow.length ? { uploadAllow } : {}),
|
|
245
|
+
};
|
|
192
246
|
}
|
|
193
247
|
|
|
194
248
|
/**
|
|
@@ -250,6 +304,21 @@ export class CapaUnreachable extends Error {
|
|
|
250
304
|
}
|
|
251
305
|
}
|
|
252
306
|
|
|
307
|
+
/**
|
|
308
|
+
* A request Capa did not answer within the request timeout. A class of its
|
|
309
|
+
* own, with the message every such error has always had, so a front end that
|
|
310
|
+
* sets an exit status (the `capa` CLI) can tell a silent host from a refusal
|
|
311
|
+
* without matching the words.
|
|
312
|
+
*/
|
|
313
|
+
export class CapaTimeout extends Error {
|
|
314
|
+
constructor({ method, path, timeoutMs }) {
|
|
315
|
+
super(
|
|
316
|
+
`Capa did not answer ${method} ${path} within ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${timeoutMs} ms`}. Next: ${TIMEOUT_NEXT}`,
|
|
317
|
+
);
|
|
318
|
+
this.name = "CapaTimeout";
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
253
322
|
/**
|
|
254
323
|
* What went wrong under `fetch`'s "fetch failed": the system code
|
|
255
324
|
* (ECONNREFUSED, ENOTFOUND, a TLS code) when there is one. A URL carrying a
|
|
@@ -278,17 +347,17 @@ async function boundedFetch(config, method, path, url, init) {
|
|
|
278
347
|
const own = [init.signal, config.signal].filter(Boolean);
|
|
279
348
|
const failed = (error, midAnswer) => {
|
|
280
349
|
if (own.some((signal) => signal.aborted)) return error;
|
|
281
|
-
if (timeout.aborted) {
|
|
282
|
-
return new Error(
|
|
283
|
-
`Capa did not answer ${method} ${path} within ${timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)} seconds` : `${timeoutMs} ms`}. Next: ${TIMEOUT_NEXT}`,
|
|
284
|
-
);
|
|
285
|
-
}
|
|
350
|
+
if (timeout.aborted) return new CapaTimeout({ method, path, timeoutMs });
|
|
286
351
|
const parsed = new URL(url);
|
|
287
352
|
return new CapaUnreachable({ method, path, origin: parsed.origin, reason: transportReason(error, parsed), midAnswer });
|
|
288
353
|
};
|
|
289
354
|
let res;
|
|
290
355
|
try {
|
|
291
|
-
|
|
356
|
+
// `config.fetch` is the hosted server's: it answers in-process, as the
|
|
357
|
+
// signed-in person (apps/api/src/hosted-mcp). Everything else is global
|
|
358
|
+
// `fetch`, which is what the stdio server always uses.
|
|
359
|
+
const send = config.fetch ?? fetch;
|
|
360
|
+
res = await send(url, {
|
|
292
361
|
...init,
|
|
293
362
|
method,
|
|
294
363
|
signal: own.length ? AbortSignal.any([...own, timeout]) : timeout,
|
|
@@ -303,6 +372,19 @@ async function boundedFetch(config, method, path, url, init) {
|
|
|
303
372
|
}
|
|
304
373
|
}
|
|
305
374
|
|
|
375
|
+
/**
|
|
376
|
+
* `X-Tenant-Key`, for a legacy key only.
|
|
377
|
+
*
|
|
378
|
+
* The `/v2` and `/v3` mounts read it to find a legacy key's tenant. A `cap_`
|
|
379
|
+
* key reaches one of them, `/v2/agent`, where a deployment accepts it there
|
|
380
|
+
* (registry.mjs), and that mount resolves the tenant from the key's own row,
|
|
381
|
+
* as `/api/` does. So a `cap_` key never sends the header, even when
|
|
382
|
+
* CAPA_TENANT_ID happens to be set: a value the route does not read is not
|
|
383
|
+
* part of the request. A config with no family is a legacy one, as it was
|
|
384
|
+
* before the family existed.
|
|
385
|
+
*/
|
|
386
|
+
const tenantHeader = (config) => (config.family === "cap" ? {} : { "X-Tenant-Key": config.tenantId });
|
|
387
|
+
|
|
306
388
|
/** Build the request URL + headers once, for both the JSON and text readers. */
|
|
307
389
|
function prepare(config, path, query) {
|
|
308
390
|
const url = new URL(config.baseUrl + path);
|
|
@@ -327,7 +409,7 @@ export async function apiGetText(config, path, query = {}) {
|
|
|
327
409
|
const { res, text } = await boundedFetch(config, "GET", path, url, {
|
|
328
410
|
headers: {
|
|
329
411
|
"x-api-key": config.apiKey,
|
|
330
|
-
|
|
412
|
+
...tenantHeader(config),
|
|
331
413
|
Accept: "text/plain, */*",
|
|
332
414
|
},
|
|
333
415
|
});
|
|
@@ -340,7 +422,7 @@ export async function apiGet(config, path, query = {}) {
|
|
|
340
422
|
const { res, text } = await boundedFetch(config, "GET", path, url, {
|
|
341
423
|
headers: {
|
|
342
424
|
"x-api-key": config.apiKey,
|
|
343
|
-
|
|
425
|
+
...tenantHeader(config),
|
|
344
426
|
Accept: "application/json",
|
|
345
427
|
},
|
|
346
428
|
});
|
|
@@ -431,7 +513,7 @@ export async function apiWrite(config, method, path, body) {
|
|
|
431
513
|
const { res, text } = await boundedFetch(config, method, path, url, {
|
|
432
514
|
headers: {
|
|
433
515
|
"x-api-key": config.apiKey,
|
|
434
|
-
|
|
516
|
+
...tenantHeader(config),
|
|
435
517
|
Accept: "application/json",
|
|
436
518
|
"Content-Type": "application/json",
|
|
437
519
|
},
|
package/lib/connect.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connect.mjs — what the server learns before it reads a message, as one
|
|
3
|
+
* function every front end calls: the stdio bin, and the `capa` CLI, which
|
|
4
|
+
* runs these same tools as commands and `capa mcp`, which serves them.
|
|
5
|
+
*
|
|
6
|
+
* One `/api/me` call and three probes, before any client message is read.
|
|
7
|
+
* They are what turn the key's family and scopes, and the deployment's
|
|
8
|
+
* features, into the tool list (registry.mjs), and a client reads that list
|
|
9
|
+
* once at startup: deciding later would mean advertising tools and then
|
|
10
|
+
* changing our mind about them mid-session.
|
|
11
|
+
*
|
|
12
|
+
* `notes` are the lines the person running the server reads on stderr, in
|
|
13
|
+
* the order and the words the bin has always written them: why `/api/me` did
|
|
14
|
+
* not answer, which features the deployment does not serve, and which tools
|
|
15
|
+
* the key's scopes leave out. Returned rather than written, so a CLI can show
|
|
16
|
+
* them where its own errors go and a test can read them.
|
|
17
|
+
*/
|
|
18
|
+
import { agentSurface, loadMe, probeGraphQL, probePages, probeRestOnly, probeUploads, scopeNote, selectTools } from "./registry.mjs";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `config` is `loadConfig()`'s. Returns `{ ctx, me, deployment, notes }`:
|
|
22
|
+
* `ctx` is what `dispatch`, `runTool` and `createSession` take; `me` is the
|
|
23
|
+
* `/api/me` body, or null when it did not answer; `deployment` is what the
|
|
24
|
+
* probes found.
|
|
25
|
+
*/
|
|
26
|
+
export async function connect(config) {
|
|
27
|
+
const [me, pages, graphql, restOnly, media] = await Promise.all([
|
|
28
|
+
loadMe(config),
|
|
29
|
+
probePages(config),
|
|
30
|
+
probeGraphQL(config),
|
|
31
|
+
probeRestOnly(config),
|
|
32
|
+
probeUploads(config),
|
|
33
|
+
]);
|
|
34
|
+
const { uploads, mediaList } = media;
|
|
35
|
+
const notes = [];
|
|
36
|
+
// For a cap_ key the line also says the model and workspace tools are left out (registry.mjs loadMe).
|
|
37
|
+
if (!me.ok) notes.push(`capa-mcp: ${me.reason}`);
|
|
38
|
+
// Only an answering /api/ says anything about its features: without one, every /api/ tool registers and reports the real error.
|
|
39
|
+
const deployment = { pages: me.ok ? pages : null, graphql: me.ok ? graphql : null, restOnly, uploads, mediaList };
|
|
40
|
+
if (deployment.pages === false) {
|
|
41
|
+
notes.push("capa-mcp: this deployment does not serve /api/pages (CAPA_SITE_PREVIEW is off), so the page tools are not registered.");
|
|
42
|
+
}
|
|
43
|
+
// Said only where a cap_ key reaches /v2/agent: elsewhere no media tool was ever in reach.
|
|
44
|
+
if (uploads === false && me.ok && agentSurface(me.me) === true) {
|
|
45
|
+
notes.push(
|
|
46
|
+
"capa-mcp: this deployment does not take uploads with an API key (CAPA_AGENT_UPLOADS is off), so capa_list_media and capa_upload_media are not registered.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (uploads === true && mediaList === false && me.ok && agentSurface(me.me) === true) {
|
|
50
|
+
notes.push(
|
|
51
|
+
"capa-mcp: capa_list_media is not offered: 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.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const tools = selectTools(config, me.me, deployment);
|
|
55
|
+
// One line per reason: the key's scopes, and for a cap_ key a deployment that does not say it accepts one on /v2/agent.
|
|
56
|
+
const scoped = scopeNote(config, me.me, deployment);
|
|
57
|
+
if (scoped) notes.push(scoped);
|
|
58
|
+
if (deployment.graphql === false) {
|
|
59
|
+
const instead = tools.some((tool) => tool.name === "capa_read_entries") ? " capa_read_entries reads the same entries over REST instead." : "";
|
|
60
|
+
notes.push(`capa-mcp: this deployment does not serve /api/graphql (CAPA_API_GRAPHQL is off), so the GraphQL tools are not registered.${instead}`);
|
|
61
|
+
}
|
|
62
|
+
if (deployment.graphql !== false && restOnly.length && tools.some((tool) => tool.name === "capa_read_entries")) {
|
|
63
|
+
notes.push(`capa-mcp: GraphQL leaves out ${restOnly.join(", ")} (their type names collide), so capa_read_entries is registered to read them over REST.`);
|
|
64
|
+
}
|
|
65
|
+
const ctx = { config: me.apiMissing ? { ...config, apiMissing: true } : config, tools };
|
|
66
|
+
return { ctx, me: me.ok ? me.me : null, deployment, notes };
|
|
67
|
+
}
|