@capacms/mcp 0.2.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 +881 -0
- package/bin/capa-mcp.mjs +96 -0
- package/lib/annotations.mjs +27 -0
- package/lib/answers.mjs +69 -0
- package/lib/arguments.mjs +140 -0
- package/lib/bound.mjs +546 -0
- package/lib/client.mjs +512 -0
- package/lib/error-guide.mjs +750 -0
- package/lib/explore.mjs +471 -0
- package/lib/graphql/build.mjs +725 -0
- package/lib/graphql/document.mjs +388 -0
- package/lib/graphql/filter-values.mjs +92 -0
- package/lib/graphql/more.mjs +97 -0
- package/lib/graphql/names.mjs +131 -0
- package/lib/graphql/schema.mjs +237 -0
- package/lib/graphql/sdl.mjs +144 -0
- package/lib/graphql/served.mjs +82 -0
- package/lib/graphql-tools.mjs +1177 -0
- package/lib/guide.mjs +55 -0
- package/lib/instructions.mjs +32 -0
- package/lib/prompts.mjs +68 -0
- package/lib/registry.mjs +235 -0
- package/lib/resources.mjs +134 -0
- package/lib/rest-tools.mjs +176 -0
- package/lib/server.mjs +194 -0
- package/lib/session.mjs +90 -0
- package/lib/suggest.mjs +32 -0
- package/lib/tools.mjs +1158 -0
- package/package.json +24 -0
package/lib/client.mjs
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.mjs — the HTTP client every tool goes through.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies, matching @capa/config-guard and the Circular MCP server:
|
|
5
|
+
* a server an agent runs on its own machine should not drag a dependency tree
|
|
6
|
+
* along with it, and this one only needs `fetch`.
|
|
7
|
+
*
|
|
8
|
+
* AUTH IS NO LONGER READ-ONLY, AND THE COLUMN IS NOW A CONTROL
|
|
9
|
+
*
|
|
10
|
+
* This file used to say there was "no write verb behind an API key anywhere in
|
|
11
|
+
* the codebase". That was true when it was measured (2026-08-21) and is not
|
|
12
|
+
* true now. Two things changed it:
|
|
13
|
+
*
|
|
14
|
+
* - THE AGENT SURFACE (#383 / ZVN-5741) mounted `/v2/agent/models` and
|
|
15
|
+
* `/v2/agent/model-instances` behind `verifyApiKey`, and
|
|
16
|
+
* ADMIN_UI_OVERHAUL 0h.4b added `/v2/agent/workspaces`. These are the same
|
|
17
|
+
* handlers the admin uses, with the key authenticator instead of the
|
|
18
|
+
* session one.
|
|
19
|
+
* - `tenant_api_keys.permission` STOPPED BEING DECORATION (#4646).
|
|
20
|
+
* `requireApiKeyPermission` reads it through the same `can()` every
|
|
21
|
+
* session route asks, so `read`, `write`, `delete` and `agent` now grant
|
|
22
|
+
* genuinely different things. The old note here said the column was "read
|
|
23
|
+
* by nothing" — do not restore that claim.
|
|
24
|
+
*
|
|
25
|
+
* So a write tool CAN be built, and the workspace tools below are. What a key
|
|
26
|
+
* may actually do is its bundle's business: a `read` key gets 403 on every
|
|
27
|
+
* write, and this client surfaces the API's own message rather than guessing.
|
|
28
|
+
*
|
|
29
|
+
* Still true, and still the reason most tools here are reads: the CONTENT write
|
|
30
|
+
* surface an agent would want (edit an entry, publish it) is behind
|
|
31
|
+
* `/v2/agent/model-instances`, and the tools for it are a separate piece of
|
|
32
|
+
* work. Absent rather than stubbed — a tool that always fails teaches an agent
|
|
33
|
+
* to stop trying.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export class CapaApiError extends Error {
|
|
37
|
+
/**
|
|
38
|
+
* `envelope` is the `error` object of an `/api/` failure, when there was one.
|
|
39
|
+
* The legacy surface answers `{ error: "<string>" }` and has no such object,
|
|
40
|
+
* so the three legacy functions never pass it and `code`/`hint` stay null.
|
|
41
|
+
*/
|
|
42
|
+
constructor(status, path, body, envelope) {
|
|
43
|
+
super(
|
|
44
|
+
envelope && envelope.code
|
|
45
|
+
? `Capa API ${status} on ${path}: ${envelope.code}: ${envelope.message ?? ""}`.trimEnd()
|
|
46
|
+
: `Capa API ${status} on ${path}${body ? `: ${body.slice(0, 200)}` : ""}`,
|
|
47
|
+
);
|
|
48
|
+
this.name = "CapaApiError";
|
|
49
|
+
this.status = status;
|
|
50
|
+
this.path = path;
|
|
51
|
+
/**
|
|
52
|
+
* The response body, whole and unsliced.
|
|
53
|
+
*
|
|
54
|
+
* The message carries only the first 200 characters, which is right for a
|
|
55
|
+
* log line and wrong for a caller that has to ACT on the body. A 400 from
|
|
56
|
+
* `PUT /v2/models/:id/layout` is `{ error, path }` where `path` names the
|
|
57
|
+
* node to fix (ADMIN_UI_OVERHAUL 0m.4), and an agent can only fix it if the
|
|
58
|
+
* tool can read it back out.
|
|
59
|
+
*/
|
|
60
|
+
this.body = body ?? "";
|
|
61
|
+
/**
|
|
62
|
+
* The `/api/` envelope's two act-on-it fields.
|
|
63
|
+
*
|
|
64
|
+
* `code` is the exact reason (`page_not_found`, `scope_missing`), which is
|
|
65
|
+
* what a caller branches on: the STATUS is also produced by things that are
|
|
66
|
+
* not the API, so a tool that switches on 404 alone is switching on
|
|
67
|
+
* something a proxy can author. `hint` names the fix in this deployment's
|
|
68
|
+
* own values, which is the only part of an error a model can act on without
|
|
69
|
+
* guessing. Null for the legacy surface, which carries neither.
|
|
70
|
+
*/
|
|
71
|
+
this.code = envelope?.code ?? null;
|
|
72
|
+
this.hint = envelope?.hint ?? null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The dated `/api/` platform version this package asks for when the
|
|
78
|
+
* environment names none.
|
|
79
|
+
*
|
|
80
|
+
* A COPY, and deliberately so: this package has no dependencies and cannot
|
|
81
|
+
* import from the api. The source of truth is the registry in
|
|
82
|
+
* `apps/api/src/api-next/versions.ts`, whose newest date this is; a date added
|
|
83
|
+
* there moves this constant and the README row with it. A date the deployment
|
|
84
|
+
* does not know answers `400 invalid_version` with a hint listing the dates it
|
|
85
|
+
* does, so a stale copy here fails loudly rather than quietly serving a shape
|
|
86
|
+
* nobody asked for.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_API_VERSION = "2026-10-01";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which family a key belongs to, from its prefix and nothing else.
|
|
92
|
+
*
|
|
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
|
|
95
|
+
* is legacy: `pk_`, `sk_` and the unprefixed keys older tenants hold are
|
|
96
|
+
* plaintext and reach both surfaces. That one fact is what decides which
|
|
97
|
+
* tools this server registers, so it is read once here rather than sniffed
|
|
98
|
+
* per call.
|
|
99
|
+
*
|
|
100
|
+
* Case-sensitive, matching the api: `CAP_live_…` is not a key this system
|
|
101
|
+
* mints, and a lookup path chosen by a header's capitalisation is a bug.
|
|
102
|
+
* `@capacms/sdk` applies the same rule; both packages are tested against
|
|
103
|
+
* `packages/sdk/test/fixtures/key-family.json`.
|
|
104
|
+
*/
|
|
105
|
+
export function keyFamily(apiKey) {
|
|
106
|
+
return String(apiKey ?? "").startsWith("cap_") ? "cap" : "legacy";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read config from the environment, failing with a message that names the fix.
|
|
111
|
+
*
|
|
112
|
+
* ONE PAIR OF NAMES FOR THE WHOLE PRODUCT: `CAPA_API_URL` and `CAPA_KEY`, the
|
|
113
|
+
* names `@capacms/sdk/nextjs`, `capa-codegen`, the SDK snippets these tools
|
|
114
|
+
* write and every curl example in docs/api read, so one
|
|
115
|
+
* `.env` serves a site and this server. `CAPA_BASE_URL` and `CAPA_API_KEY`,
|
|
116
|
+
* this package's first names, still work as aliases; the new name wins when
|
|
117
|
+
* both are set.
|
|
118
|
+
*
|
|
119
|
+
* NOTE ON THE ENV INVENTORY. `gen-env-template.mjs` scans for the literal
|
|
120
|
+
* `process.env.X` form, and the `env = process.env` parameter here (which
|
|
121
|
+
* exists so this is testable) hides every name below from it. The names reach
|
|
122
|
+
* .env.next.template anyway, because `packages/sdk`'s codegen bin reads them
|
|
123
|
+
* in the literal form; their notes in scripts/env-notes.json say this package
|
|
124
|
+
* shares them. CAPA_API_VERSION is read HERE and nowhere else, so it is listed
|
|
125
|
+
* in that file's `unread` block instead, which is the generator's second
|
|
126
|
+
* source for exactly this case.
|
|
127
|
+
*/
|
|
128
|
+
export function loadConfig(env = process.env) {
|
|
129
|
+
// Trimmed: a key pasted with a space or a newline around it is still the key,
|
|
130
|
+
// and its prefix decides the family below.
|
|
131
|
+
const baseUrl = (env.CAPA_API_URL || env.CAPA_BASE_URL || "").trim().replace(/\/+$/, "");
|
|
132
|
+
const apiKey = (env.CAPA_KEY || env.CAPA_API_KEY || "").trim();
|
|
133
|
+
const tenantId = (env.CAPA_TENANT_ID || "").trim();
|
|
134
|
+
// Optional, and the only one of the four that is. An agent that never sets it
|
|
135
|
+
// gets today's shape; one pinning an older date gets the shape it was written
|
|
136
|
+
// against, which is the whole point of a dated surface.
|
|
137
|
+
const apiVersion = (env.CAPA_API_VERSION || "").trim() || DEFAULT_API_VERSION;
|
|
138
|
+
const family = keyFamily(apiKey);
|
|
139
|
+
|
|
140
|
+
const missing = [];
|
|
141
|
+
if (!baseUrl) missing.push("CAPA_API_URL");
|
|
142
|
+
if (!apiKey) missing.push("CAPA_KEY");
|
|
143
|
+
// REQUIRED FOR A LEGACY KEY ONLY, and that is not a convenience.
|
|
144
|
+
//
|
|
145
|
+
// `X-Tenant-Key` is read by the `/v2` and `/v3` mounts, which is where every
|
|
146
|
+
// 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.
|
|
153
|
+
//
|
|
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.
|
|
156
|
+
// Only a legacy key that was given is missing its tenant: with no key yet,
|
|
157
|
+
// nothing says one is needed, and a cap_ key never needs it.
|
|
158
|
+
if (!tenantId && apiKey && family === "legacy") missing.push("CAPA_TENANT_ID");
|
|
159
|
+
|
|
160
|
+
if (missing.length) {
|
|
161
|
+
// Each line says which key needs it, so a cap_ key's reader never goes
|
|
162
|
+
// hunting for a tenant id that changes nothing.
|
|
163
|
+
const keyLine = !apiKey
|
|
164
|
+
? ` CAPA_KEY a cap_ key from Developers > Keys, or a legacy key\n` +
|
|
165
|
+
` (or CAPA_API_KEY)\n`
|
|
166
|
+
: family === "cap"
|
|
167
|
+
? ` CAPA_KEY a cap_ key (or CAPA_API_KEY). Its scopes decide which tools register;\n` +
|
|
168
|
+
` the page tools need instance:read\n`
|
|
169
|
+
: ` CAPA_KEY a tenant API key, or CAPA_API_KEY (read scope for the read tools;\n` +
|
|
170
|
+
` the workspace write tools need a key with write permission)\n`;
|
|
171
|
+
const tenantLine =
|
|
172
|
+
family === "cap" && apiKey
|
|
173
|
+
? ` CAPA_TENANT_ID not needed for a cap_ key: /api/ resolves the tenant\n` +
|
|
174
|
+
` from the key, and no legacy tool is registered\n`
|
|
175
|
+
: ` CAPA_TENANT_ID legacy keys only (pk_, sk_ or unprefixed): the tenant the key belongs to\n`;
|
|
176
|
+
throw new Error(
|
|
177
|
+
`capa-mcp: missing ${missing.join(", ")}.\n` +
|
|
178
|
+
` CAPA_API_URL the API, e.g. https://api.capacms.com (or CAPA_BASE_URL)\n` +
|
|
179
|
+
keyLine +
|
|
180
|
+
tenantLine +
|
|
181
|
+
` CAPA_API_VERSION optional; the dated /api/ version, default ${DEFAULT_API_VERSION}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const route = routeIn(baseUrl);
|
|
185
|
+
if (route) {
|
|
186
|
+
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` +
|
|
188
|
+
" Every tool adds the route it calls (/api/graphql, /api/entries), so a URL ending in one calls it twice.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return { baseUrl, apiKey, tenantId, apiVersion, family };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The API route an address ends in, pasted with it: `/api`, `/api/graphql`,
|
|
196
|
+
* `/v2/api`. A path before the routes (a proxy's `/capa`) is the address.
|
|
197
|
+
*/
|
|
198
|
+
function routeIn(baseUrl) {
|
|
199
|
+
let path;
|
|
200
|
+
try {
|
|
201
|
+
path = new URL(baseUrl).pathname;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const at = path.search(/\/(?:api|v2|v3)(?:\/|$)/);
|
|
206
|
+
return at === -1 ? null : path.slice(at);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* How long one request may take, response body included, on every surface.
|
|
211
|
+
* `fetch` has no timeout of its own (undici gives up after about five
|
|
212
|
+
* minutes), and a tool call held open that long stalls the agent's whole
|
|
213
|
+
* turn. The API's own statement timeout is 5 seconds by default, so 25 leaves
|
|
214
|
+
* room for a slow network without letting a silent host hold the call.
|
|
215
|
+
* `config.requestTimeoutMs` overrides it (tests use a few hundred ms).
|
|
216
|
+
*/
|
|
217
|
+
export const REQUEST_TIMEOUT_MS = 25_000;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What to do when Capa never answered, in the words every such error ends
|
|
221
|
+
* with: this client's own, and capa_explain_error's for one pasted from a
|
|
222
|
+
* site's log (error-guide.mjs).
|
|
223
|
+
*/
|
|
224
|
+
export const UNREACHABLE_NEXT =
|
|
225
|
+
"check that CAPA_API_URL is right and that the API is running and reachable from this machine, then retry.";
|
|
226
|
+
export const TIMEOUT_NEXT =
|
|
227
|
+
"retry once, narrower if you can (fewer fields, a smaller first); if it times out again, the API at CAPA_API_URL is down or unreachable.";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A request that never got an answer from Capa: the host refused the
|
|
231
|
+
* connection, its name did not resolve, TLS failed, or the connection closed
|
|
232
|
+
* mid-answer. `fetch` reports every one of those as a bare "fetch failed" and
|
|
233
|
+
* keeps the reason in `cause`, which is the part a person can act on, so the
|
|
234
|
+
* message names the host, that reason and the next step.
|
|
235
|
+
*
|
|
236
|
+
* `origin` is CAPA_API_URL's scheme, host and port, never its path or any
|
|
237
|
+
* credentials written into it.
|
|
238
|
+
*/
|
|
239
|
+
export class CapaUnreachable extends Error {
|
|
240
|
+
constructor({ method, path, origin, reason, midAnswer }) {
|
|
241
|
+
super(
|
|
242
|
+
midAnswer
|
|
243
|
+
? `Capa at ${origin} closed the connection before ${method} ${path} finished (${reason}). ` +
|
|
244
|
+
"Next: retry once; if it happens again, the API at CAPA_API_URL is failing mid-answer."
|
|
245
|
+
: `Could not reach Capa at ${origin} for ${method} ${path} (${reason}). Next: ${UNREACHABLE_NEXT}`,
|
|
246
|
+
);
|
|
247
|
+
this.name = "CapaUnreachable";
|
|
248
|
+
this.origin = origin;
|
|
249
|
+
this.reason = reason;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What went wrong under `fetch`'s "fetch failed": the system code
|
|
255
|
+
* (ECONNREFUSED, ENOTFOUND, a TLS code) when there is one. A URL carrying a
|
|
256
|
+
* user name or password is refused by `fetch` before any connection, in a
|
|
257
|
+
* message that quotes the URL whole, so that case is named here instead.
|
|
258
|
+
*/
|
|
259
|
+
function transportReason(error, url) {
|
|
260
|
+
if (url.username || url.password) {
|
|
261
|
+
return "CAPA_API_URL carries a user name or password, which fetch refuses to send; remove them, the key goes in CAPA_KEY";
|
|
262
|
+
}
|
|
263
|
+
const cause = error?.cause;
|
|
264
|
+
return cause?.code ?? cause?.message ?? error?.message ?? String(error);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `fetch` plus reading the body as text, bounded by the request timeout, by
|
|
269
|
+
* `signal` when the caller has its own (the startup `/api/me` call does), and
|
|
270
|
+
* by `config.signal`, the tool call's, which the client aborts when it
|
|
271
|
+
* cancels the call. A caller's own abort surfaces as it was raised, so the
|
|
272
|
+
* caller can tell it apart; this file's timeout and a failed connection
|
|
273
|
+
* become errors naming the next step.
|
|
274
|
+
*/
|
|
275
|
+
async function boundedFetch(config, method, path, url, init) {
|
|
276
|
+
const timeoutMs = config.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
277
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
278
|
+
const own = [init.signal, config.signal].filter(Boolean);
|
|
279
|
+
const failed = (error, midAnswer) => {
|
|
280
|
+
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
|
+
}
|
|
286
|
+
const parsed = new URL(url);
|
|
287
|
+
return new CapaUnreachable({ method, path, origin: parsed.origin, reason: transportReason(error, parsed), midAnswer });
|
|
288
|
+
};
|
|
289
|
+
let res;
|
|
290
|
+
try {
|
|
291
|
+
res = await fetch(url, {
|
|
292
|
+
...init,
|
|
293
|
+
method,
|
|
294
|
+
signal: own.length ? AbortSignal.any([...own, timeout]) : timeout,
|
|
295
|
+
});
|
|
296
|
+
} catch (error) {
|
|
297
|
+
throw failed(error, false);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
return { res, text: await res.text() };
|
|
301
|
+
} catch (error) {
|
|
302
|
+
throw failed(error, true);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Build the request URL + headers once, for both the JSON and text readers. */
|
|
307
|
+
function prepare(config, path, query) {
|
|
308
|
+
const url = new URL(config.baseUrl + path);
|
|
309
|
+
for (const [k, v] of Object.entries(query)) {
|
|
310
|
+
if (v === undefined || v === null || v === "") continue;
|
|
311
|
+
url.searchParams.set(k, String(v));
|
|
312
|
+
}
|
|
313
|
+
return url;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* GET an endpoint that returns text rather than JSON.
|
|
318
|
+
*
|
|
319
|
+
* /v2/schema/types serves `text/plain` — generated TypeScript source, not a
|
|
320
|
+
* JSON document — so apiGet's JSON.parse would reject a perfectly good
|
|
321
|
+
* response. Kept as a separate function rather than a flag on apiGet so the
|
|
322
|
+
* caller states which it expects and a content-type change surfaces as a
|
|
323
|
+
* failing test instead of a silent shape change.
|
|
324
|
+
*/
|
|
325
|
+
export async function apiGetText(config, path, query = {}) {
|
|
326
|
+
const url = prepare(config, path, query);
|
|
327
|
+
const { res, text } = await boundedFetch(config, "GET", path, url, {
|
|
328
|
+
headers: {
|
|
329
|
+
"x-api-key": config.apiKey,
|
|
330
|
+
"X-Tenant-Key": config.tenantId,
|
|
331
|
+
Accept: "text/plain, */*",
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
if (!res.ok) throw new CapaApiError(res.status, path, text);
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function apiGet(config, path, query = {}) {
|
|
339
|
+
const url = prepare(config, path, query);
|
|
340
|
+
const { res, text } = await boundedFetch(config, "GET", path, url, {
|
|
341
|
+
headers: {
|
|
342
|
+
"x-api-key": config.apiKey,
|
|
343
|
+
"X-Tenant-Key": config.tenantId,
|
|
344
|
+
Accept: "application/json",
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
if (!res.ok) throw new CapaApiError(res.status, path, text);
|
|
348
|
+
try {
|
|
349
|
+
return JSON.parse(text);
|
|
350
|
+
} catch {
|
|
351
|
+
throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* GET a route on the `/api/` surface — a SECOND request path, not a flag on
|
|
357
|
+
* `apiGet`, because the two surfaces differ in three ways a caller has to see.
|
|
358
|
+
*
|
|
359
|
+
* NO `X-Tenant-Key`. `/api/` resolves the tenant from the key itself, so the
|
|
360
|
+
* header is not merely unnecessary, it is a value this surface never reads.
|
|
361
|
+
* Sending it would teach the next reader that a tenant id is part of an
|
|
362
|
+
* `/api/` request, and it is not.
|
|
363
|
+
*
|
|
364
|
+
* `Capa-Version`. Every `/api/` response is shaped by a dated version. Not
|
|
365
|
+
* sending one means the key's stored pin decides, which makes the shape a
|
|
366
|
+
* property of when the key was minted rather than of what this code expects.
|
|
367
|
+
* One date from every environment is the point of the header.
|
|
368
|
+
*
|
|
369
|
+
* `{ data, meta, page }`, not a bare body. `meta` carries the counters a
|
|
370
|
+
* list's row array cannot hold (the window it measured, the cap, whether the
|
|
371
|
+
* cap cut anything), and `page` an entry list's cursors, so every part comes
|
|
372
|
+
* back rather than the caller being handed `data` and told the rest did not
|
|
373
|
+
* exist.
|
|
374
|
+
*
|
|
375
|
+
* Errors are the `/api/` envelope, `{ error: { type, code, message, param,
|
|
376
|
+
* hint, docs } }`, and `code` plus `hint` are lifted onto `CapaApiError` so a
|
|
377
|
+
* tool can branch on the reason and hand the model the fix in one step.
|
|
378
|
+
*/
|
|
379
|
+
export async function apiNextGet(config, path, query = {}, init = {}) {
|
|
380
|
+
const url = prepare(config, path, query);
|
|
381
|
+
const { res, text } = await boundedFetch(config, "GET", path, url, {
|
|
382
|
+
headers: {
|
|
383
|
+
"x-api-key": config.apiKey,
|
|
384
|
+
"Capa-Version": config.apiVersion || DEFAULT_API_VERSION,
|
|
385
|
+
Accept: "application/json",
|
|
386
|
+
},
|
|
387
|
+
// `init.signal` is how the startup call bounds itself more tightly than
|
|
388
|
+
// the request timeout: it runs before the first client message is read.
|
|
389
|
+
signal: init.signal,
|
|
390
|
+
});
|
|
391
|
+
let body = null;
|
|
392
|
+
try {
|
|
393
|
+
body = text ? JSON.parse(text) : null;
|
|
394
|
+
} catch {
|
|
395
|
+
body = null;
|
|
396
|
+
}
|
|
397
|
+
if (!res.ok) {
|
|
398
|
+
const envelope = body && typeof body === "object" ? body.error : null;
|
|
399
|
+
// A non-object `error` is the LEGACY body shape (`{ error: "<string>" }`),
|
|
400
|
+
// which reaches here when a request lands on a stack that does not serve
|
|
401
|
+
// `/api/` at all. It has no code and no hint, and pretending otherwise
|
|
402
|
+
// would invent both.
|
|
403
|
+
throw new CapaApiError(
|
|
404
|
+
res.status,
|
|
405
|
+
path,
|
|
406
|
+
text,
|
|
407
|
+
envelope && typeof envelope === "object" ? envelope : undefined,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
if (!body || typeof body !== "object") {
|
|
411
|
+
throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
|
|
412
|
+
}
|
|
413
|
+
return { data: body.data, meta: body.meta ?? null, page: body.page ?? null };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A write verb behind the API key — POST, PUT, PATCH or DELETE.
|
|
418
|
+
*
|
|
419
|
+
* Deliberately a separate function from `apiGet` rather than a `method` flag on
|
|
420
|
+
* it: the two have different failure modes an agent has to distinguish. A 403
|
|
421
|
+
* here means "this key may read but not write", which is a fixable
|
|
422
|
+
* configuration fact, and the caller has to be able to say so without parsing a
|
|
423
|
+
* shared error message.
|
|
424
|
+
*
|
|
425
|
+
* The API's OWN message is preserved rather than replaced. `CapaApiError`
|
|
426
|
+
* carries the body, so `capa_set_workspace` can hand the model back exactly
|
|
427
|
+
* what the server said about the grant it lacked.
|
|
428
|
+
*/
|
|
429
|
+
export async function apiWrite(config, method, path, body) {
|
|
430
|
+
const url = new URL(config.baseUrl + path);
|
|
431
|
+
const { res, text } = await boundedFetch(config, method, path, url, {
|
|
432
|
+
headers: {
|
|
433
|
+
"x-api-key": config.apiKey,
|
|
434
|
+
"X-Tenant-Key": config.tenantId,
|
|
435
|
+
Accept: "application/json",
|
|
436
|
+
"Content-Type": "application/json",
|
|
437
|
+
},
|
|
438
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
439
|
+
});
|
|
440
|
+
if (!res.ok) throw new CapaApiError(res.status, path, text);
|
|
441
|
+
if (!text) return null;
|
|
442
|
+
try {
|
|
443
|
+
return JSON.parse(text);
|
|
444
|
+
} catch {
|
|
445
|
+
throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The status of a GraphQL request the API refused as a whole, however it was
|
|
451
|
+
* sent. GraphQL over HTTP answers the refusal 200 on `application/json`, which
|
|
452
|
+
* this client asks for, and 4xx on `application/graphql-response+json`
|
|
453
|
+
* (decision of 2026-09-25); every refusal it moves to a 200 is a 400 in the
|
|
454
|
+
* API's error table, so both read the same.
|
|
455
|
+
*/
|
|
456
|
+
export const REFUSED_STATUS = 400;
|
|
457
|
+
|
|
458
|
+
/** GraphQL over HTTP: a refused request has `errors` and no `data` entry; a body with `data`, null included, ran. */
|
|
459
|
+
function refusedWhole(body) {
|
|
460
|
+
return Array.isArray(body?.errors) && body.errors.length > 0 && !Object.hasOwn(body, "data");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* POST JSON to the `/api/` surface: today only `/api/graphql`.
|
|
465
|
+
*
|
|
466
|
+
* The same three rules as `apiNextGet` (no `X-Tenant-Key`, `Capa-Version` on
|
|
467
|
+
* every call, the envelope's `code` and `hint` lifted onto the error), plus
|
|
468
|
+
* the one GraphQL adds: a request the API ran is an answer even when it
|
|
469
|
+
* carries `errors`, because the root fields that worked still carry data. So
|
|
470
|
+
* this returns the whole parsed body when the API ran the request, and throws
|
|
471
|
+
* `CapaApiError` when it refused it: any status but 2xx, or a 200 with
|
|
472
|
+
* `errors` and no `data`, which throws as the 400 it is on
|
|
473
|
+
* `application/graphql-response+json`.
|
|
474
|
+
*
|
|
475
|
+
* A refused GraphQL request answers `{ errors: [...] }` rather than the REST
|
|
476
|
+
* `{ error }` object, so the envelope is read from `errors[0].extensions` when
|
|
477
|
+
* that is what came back. `body` keeps every error for a caller that wants
|
|
478
|
+
* them all.
|
|
479
|
+
*/
|
|
480
|
+
export async function apiNextPost(config, path, payload, init = {}) {
|
|
481
|
+
const url = new URL(config.baseUrl + path);
|
|
482
|
+
const { res, text } = await boundedFetch(config, "POST", path, url, {
|
|
483
|
+
headers: {
|
|
484
|
+
"x-api-key": config.apiKey,
|
|
485
|
+
"Capa-Version": config.apiVersion || DEFAULT_API_VERSION,
|
|
486
|
+
Accept: "application/json",
|
|
487
|
+
"Content-Type": "application/json",
|
|
488
|
+
},
|
|
489
|
+
body: JSON.stringify(payload),
|
|
490
|
+
signal: init.signal,
|
|
491
|
+
});
|
|
492
|
+
let body = null;
|
|
493
|
+
try {
|
|
494
|
+
body = text ? JSON.parse(text) : null;
|
|
495
|
+
} catch {
|
|
496
|
+
body = null;
|
|
497
|
+
}
|
|
498
|
+
const refused = res.ok && refusedWhole(body);
|
|
499
|
+
if (!res.ok || refused) {
|
|
500
|
+
const first = Array.isArray(body?.errors) ? body.errors[0] : null;
|
|
501
|
+
const envelope = first
|
|
502
|
+
? { code: first.extensions?.code, message: first.message, hint: first.extensions?.hint }
|
|
503
|
+
: body && typeof body.error === "object"
|
|
504
|
+
? body.error
|
|
505
|
+
: undefined;
|
|
506
|
+
throw new CapaApiError(refused ? REFUSED_STATUS : res.status, path, text, envelope);
|
|
507
|
+
}
|
|
508
|
+
if (!body || typeof body !== "object") {
|
|
509
|
+
throw new CapaApiError(res.status, path, `response was not JSON: ${text.slice(0, 200)}`);
|
|
510
|
+
}
|
|
511
|
+
return body;
|
|
512
|
+
}
|