@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/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/` ONLY: the legacy
94
- * middleware refuses the prefix before it looks anything up. Every other key
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/` and nothing else: that surface
148
- // resolves the tenant from the key itself and never reads the header, and
149
- // the registry gives a `cap_` key no legacy tool to call. Demanding it would
150
- // be demanding a value nothing this process can reach will read, and refusing
151
- // to start without one would refuse to start over a value that does not
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. No
155
- // tool can send it, because no tool that would is registered.
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://api.capacms.com (or CAPA_BASE_URL)\n` +
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://api.capacms.com: drop ${route}.\n` +
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
- return { baseUrl, apiKey, tenantId, apiVersion, family };
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
- res = await fetch(url, {
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
- "X-Tenant-Key": config.tenantId,
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
- "X-Tenant-Key": config.tenantId,
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
- "X-Tenant-Key": config.tenantId,
516
+ ...tenantHeader(config),
435
517
  Accept: "application/json",
436
518
  "Content-Type": "application/json",
437
519
  },
@@ -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
+ }