@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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * uploader-retry.mjs — send to Capa's uploader, and wait out a busy one.
3
+ *
4
+ * The uploader runs a few uploads at once per machine and lets one project,
5
+ * or one person, hold half of them (apps/uploader/app.js). Past that it
6
+ * answers 503 with `Retry-After` and a `reason`: uploader_busy, project_busy
7
+ * or account_busy. Busy is not a failure: the same request a few seconds
8
+ * later goes through. `sendToUploader` waits `Retry-After` (at most 10 s, up
9
+ * to a quarter more for jitter, never past 10 s) and sends again, up to 4
10
+ * times. Every other answer comes back as it is, 408 too_slow and 413
11
+ * too_large included: the same file sent the same way gets the same answer.
12
+ *
13
+ * capa_upload_media (media-tools.mjs) sends through this, and so does
14
+ * `capa media upload`, which runs that tool. The admin follows the same rule
15
+ * (apps/admin/src/lib/external-apis/upload-queue.ts); keep the reasons and
16
+ * the numbers the same in both.
17
+ *
18
+ * `send` makes ONE attempt and answers a fetch Response. It is called again
19
+ * for each retry, so it builds its body and its timeout signal each time: a
20
+ * FormData over a file Blob (`fs.openAsBlob`) reads the file again.
21
+ */
22
+
23
+ /** The `reason`s of the uploader's busy 503s. Any other 503 is answered as it is. */
24
+ export const BUSY_REASONS = new Set(["uploader_busy", "project_busy", "account_busy"]);
25
+
26
+ /** How many times one upload is sent again after a busy answer. */
27
+ export const BUSY_RETRIES = 4;
28
+
29
+ /** The longest one wait lasts, whatever `Retry-After` says. */
30
+ export const MAX_BUSY_WAIT_MS = 10_000;
31
+
32
+ /** The wait when a busy answer names none. The uploader sends 5. */
33
+ const DEFAULT_BUSY_WAIT_SECONDS = 5;
34
+
35
+ function seconds(value) {
36
+ if (value === undefined || value === null || value === "") return null;
37
+ const n = Number(value);
38
+ return Number.isFinite(n) && n >= 0 ? n : null;
39
+ }
40
+
41
+ /**
42
+ * How long to wait before sending again, or null when the answer is not a
43
+ * busy one. `retryAfter` is the header's value; the body's `retryAfter`
44
+ * stands in when there is none.
45
+ */
46
+ export function busyWaitMs(status, body, retryAfter, random = Math.random) {
47
+ if (status !== 503 || !body || typeof body !== "object" || !BUSY_REASONS.has(body.reason)) return null;
48
+ const wait = seconds(retryAfter) ?? seconds(body.retryAfter) ?? DEFAULT_BUSY_WAIT_SECONDS;
49
+ return Math.round(Math.min(MAX_BUSY_WAIT_MS, wait * 1000 * (1 + 0.25 * random())));
50
+ }
51
+
52
+ const pause = (ms, signal) =>
53
+ new Promise((resolve) => {
54
+ if (signal?.aborted) return resolve();
55
+ const timer = setTimeout(resolve, ms);
56
+ signal?.addEventListener("abort", () => {
57
+ clearTimeout(timer);
58
+ resolve();
59
+ }, { once: true });
60
+ });
61
+
62
+ /**
63
+ * Send with `send`, waiting out busy answers. Answers the last attempt as
64
+ * `{ res, text, body, retries }`: its Response, its body as text and parsed
65
+ * (null when not JSON), and how many times it was sent again.
66
+ */
67
+ export async function sendToUploader(send, { retries = BUSY_RETRIES, random = Math.random, sleep = pause, signal } = {}) {
68
+ for (let attempt = 0; ; attempt++) {
69
+ const res = await send();
70
+ const text = await res.text();
71
+ let body = null;
72
+ try {
73
+ body = JSON.parse(text);
74
+ } catch {
75
+ body = null;
76
+ }
77
+ const wait = attempt < retries ? busyWaitMs(res.status, body, res.headers.get("retry-after"), random) : null;
78
+ if (wait === null || signal?.aborted) return { res, text, body, retries: attempt };
79
+ await sleep(wait, signal);
80
+ if (signal?.aborted) return { res, text, body, retries: attempt };
81
+ }
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capacms/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "stdio MCP server for Capa, shaped for agents rather than one tool per endpoint.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://docs.capacms.com/ai/mcp",
@@ -8,6 +8,10 @@
8
8
  "bin": {
9
9
  "capa-mcp": "bin/capa-mcp.mjs"
10
10
  },
11
+ "exports": {
12
+ ".": "./lib/index.mjs",
13
+ "./package.json": "./package.json"
14
+ },
11
15
  "files": [
12
16
  "bin",
13
17
  "lib"
@@ -19,6 +23,6 @@
19
23
  "node": ">=20.3"
20
24
  },
21
25
  "scripts": {
22
- "test": "node --test test/comments.test.mjs test/session.test.mjs test/server.test.mjs test/bound.test.mjs test/document.test.mjs test/graphql-tools.test.mjs test/graphql-contract.test.mjs test/package.test.mjs"
26
+ "test": "node --test test/comments.test.mjs test/session.test.mjs test/server.test.mjs test/bound.test.mjs test/document.test.mjs test/graphql-tools.test.mjs test/graphql-contract.test.mjs test/package.test.mjs test/index.test.mjs test/entry-tools.test.mjs test/tool-contract.test.mjs test/media-tools.test.mjs test/uploader-retry.test.mjs test/hosted.test.mjs"
23
27
  }
24
28
  }