loopctl-mcp-server 2.32.0 → 2.33.1

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 CHANGED
@@ -63,6 +63,7 @@ Or if installed locally:
63
63
  | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (verify, reject, review, import) | -- |
64
64
  | `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
65
65
  | `LOOPCTL_USER_KEY` | User role API key. Required ONLY for destructive admin tools like `knowledge_bulk_publish`. Leave unset if you don't use those tools. | -- |
66
+ | `LOOPCTL_STH_STATE_PATH` | Absolute path for the witness-protocol STH cache file (see [Witness protocol](#witness-protocol-sth)). Optional. | per-(server + key) file under the OS temp dir |
66
67
 
67
68
  Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
68
69
 
@@ -322,6 +323,46 @@ loopctl enforces that nobody marks their own work as done. The API returns `409`
322
323
 
323
324
  The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
324
325
 
326
+ ## Witness protocol (STH)
327
+
328
+ Every authenticated request echoes the caller's last-known Signed Tree Head (STH)
329
+ via the `X-Loopctl-Last-Known-STH` header — loopctl's tamper-evident audit chain
330
+ (chain-of-custody v2, §4.4). A brand-new caller has no STH, so its first request
331
+ opts in with `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
332
+ `x-loopctl-current-sth` response header. **That bootstrap grace is one-time per
333
+ API key** (a deliberate security gate): once consumed, a later request that still
334
+ lacks the header gets `412 witness_bootstrap_already_consumed`.
335
+
336
+ This MCP server handles that transparently, so you never see the 412:
337
+
338
+ - **Retry-once contract (412 only).** Any `412 witness_bootstrap_already_consumed`
339
+ carrying an `x-loopctl-current-sth` header is caught, the STH is cached, and the
340
+ SAME request is retried exactly **once** with `X-Loopctl-Last-Known-STH`. Bounded
341
+ to a single retry (never a loop) and anchored to the response's `error.code`. It
342
+ is safe because the server's witness plug halts *before* the operation runs, so
343
+ the rejected request had no side effect. The 412 body also carries a
344
+ machine-readable `error.remediation.retry` contract describing exactly this.
345
+ - **A `409 witness_divergence` is NOT auto-retried** — deliberately. It means the
346
+ cached STH prefix does not match the server's (the genuine-fork / resync signal,
347
+ custody-01). The client caches the server's STH from the 409 so the *next*
348
+ request self-heals, but the current 409 is surfaced rather than papered over.
349
+ - **Cross-process persistence.** The learned STH is cached to a small state file so
350
+ a **fresh** MCP process (a new Claude session, a script, a CI run) loads it and
351
+ sends a real header on its first request — avoiding the 412 entirely. The file is
352
+ keyed by **(server URL + API key)** so distinct keys/tenants on one host never
353
+ share (or clobber) each other's cache; only a non-secret hash of the key appears
354
+ in the filename (`loopctl-mcp-sth-<hash>.json` under the OS temp dir), never the
355
+ key itself. Override the location with `LOOPCTL_STH_STATE_PATH`. Writes are
356
+ **atomic and symlink-safe** (write to a private `0600` temp file with `O_EXCL`,
357
+ then rename over the target — never write through a symlink), and loads refuse a
358
+ symlinked or foreign-owned file. All file I/O degrades gracefully: a missing,
359
+ corrupt, unwritable, or refused state file falls back to in-memory caching plus
360
+ the retry-once contract above.
361
+
362
+ Dispatch-based (v2) clients that mint a fresh ephemeral key per dispatch are
363
+ unaffected: because the cache is keyed per API key, each fresh key gets its own
364
+ clean one-time bootstrap (no cross-key collision).
365
+
325
366
  ## Troubleshooting
326
367
 
327
368
  ### Connection errors
package/index.js CHANGED
@@ -10,15 +10,20 @@ import {
10
10
  ListToolsRequestSchema,
11
11
  CallToolRequestSchema,
12
12
  } from "@modelcontextprotocol/sdk/types.js";
13
- import { readFileSync } from "node:fs";
13
+ import { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync } from "node:fs";
14
+ import os from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
- import { dirname, join } from "node:path";
16
+ import path, { dirname, join } from "node:path";
16
17
  import {
17
18
  projectsPath,
18
19
  ingestionJobsPath,
19
20
  llmUsagePath,
20
21
  parseJsonResponseBody,
21
22
  } from "./lib/http-helpers.js";
23
+ import {
24
+ createWitnessClient,
25
+ resolveSthStatePath,
26
+ } from "./lib/witness-sth.js";
22
27
 
23
28
  // Single source of truth for the server version: the package.json this file
24
29
  // ships with (npm always includes package.json in the published tarball).
@@ -32,11 +37,51 @@ const SERVER_VERSION = JSON.parse(
32
37
  // HTTP helper — witness protocol state
33
38
  // ---------------------------------------------------------------------------
34
39
 
35
- // The witness protocol requires clients to echo back the last-known Signed
36
- // Tree Head (STH) on every authenticated request. On the very first request
37
- // we send X-Loopctl-STH-Bootstrap: true to receive the current STH without
38
- // needing one already. After that we cache and send X-Loopctl-Last-Known-STH.
39
- let lastKnownSTH = null;
40
+ // The witness protocol requires clients to echo back the last-known Signed Tree
41
+ // Head (STH) on every authenticated request. The mechanics live in
42
+ // ./lib/witness-sth.js (shared with the test suite):
43
+ //
44
+ // * On the very first request from a caller that has never seen an STH we send
45
+ // `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
46
+ // `x-loopctl-current-sth` response header.
47
+ // * That STH is cached AND persisted to a small per-server state file, so a
48
+ // FRESH MCP process (new Claude session) loads it and sends a real
49
+ // `X-Loopctl-Last-Known-STH` on its first request — never tripping the
50
+ // one-time bootstrap-grace 412 (`witness_bootstrap_already_consumed`, #298).
51
+ // * If a request still hits that bootstrap-grace 412 (state file missing or
52
+ // corrupt), the client caches the STH from the 412's `x-loopctl-current-sth`
53
+ // header and RETRIES the SAME request exactly ONCE — transparently — so the
54
+ // tool call succeeds. The witness plug halts before the operation runs, so
55
+ // retrying a witness 412 is side-effect-safe even for POSTs.
56
+ //
57
+ // The state file location defaults to a per-(server + key) file under the OS temp
58
+ // dir and can be overridden with LOOPCTL_STH_STATE_PATH. All file I/O degrades
59
+ // gracefully (missing/corrupt/unwritable/symlinked → in-memory cache + the
60
+ // transparent retry above). The write is atomic + symlink-safe (temp + rename).
61
+ const WITNESS_FS = { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync };
62
+
63
+ // One witness client PER API KEY (#298 review HIGH-2): the STH is per-tenant and
64
+ // each key resolves to a tenant server-side, so distinct keys must NOT share an
65
+ // in-memory cache or a state file (a collision causes spurious 409s + false
66
+ // divergence telemetry). The state file is keyed by sha256(serverUrl + ":" + key)
67
+ // — a non-secret hash; the key never hits disk in plaintext.
68
+ const witnessClients = new Map();
69
+
70
+ function witnessClientFor(apiKey) {
71
+ let client = witnessClients.get(apiKey);
72
+ if (!client) {
73
+ const statePath = resolveSthStatePath({ env: process.env, os, path, apiKey });
74
+ client = createWitnessClient({
75
+ statePath,
76
+ fs: WITNESS_FS,
77
+ getuid: typeof process.getuid === "function" ? () => process.getuid() : undefined,
78
+ pid: process.pid,
79
+ timeoutMs: 30_000,
80
+ });
81
+ witnessClients.set(apiKey, client);
82
+ }
83
+ return client;
84
+ }
40
85
 
41
86
  function getBaseUrl() {
42
87
  return (process.env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
@@ -82,26 +127,16 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
82
127
  Accept: "application/json",
83
128
  };
84
129
 
85
- // Witness protocol: send cached STH or request bootstrap
86
- if (lastKnownSTH) {
87
- headers["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
88
- } else {
89
- headers["X-Loopctl-STH-Bootstrap"] = "true";
90
- }
91
-
92
- const options = {
93
- method,
94
- headers,
95
- signal: AbortSignal.timeout(30_000),
96
- };
97
-
98
- if (body !== undefined && body !== null) {
99
- options.body = JSON.stringify(body);
100
- }
130
+ const serializedBody =
131
+ body !== undefined && body !== null ? JSON.stringify(body) : undefined;
101
132
 
133
+ // The witness client injects the STH header, caches + persists any STH the
134
+ // server returns, and transparently retries a bootstrap-grace 412 ONCE (see
135
+ // ./lib/witness-sth.js and the module comment above). It returns the final
136
+ // attempt's Response; we keep body parsing / error shaping here.
102
137
  let response;
103
138
  try {
104
- response = await fetch(url, options);
139
+ response = await witnessClientFor(key).send({ url, method, headers, serializedBody });
105
140
  } catch (err) {
106
141
  if (err.name === "TimeoutError") {
107
142
  return { error: true, status: 0, body: "Request timed out after 30s" };
@@ -110,12 +145,6 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
110
145
  return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
111
146
  }
112
147
 
113
- // Witness protocol: cache the STH from response for subsequent requests
114
- const sthHeader = response.headers.get("x-loopctl-current-sth");
115
- if (sthHeader) {
116
- lastKnownSTH = sthHeader;
117
- }
118
-
119
148
  if (response.status === 204) {
120
149
  return { ok: true };
121
150
  }
@@ -2585,7 +2614,11 @@ const TOOLS = [
2585
2614
  "list mode or knowledge_stats. " +
2586
2615
  "Pass story_id when working on a loopctl story so reads attribute correctly. " +
2587
2616
  "When you knowledge_get a result and it carries `potential_conflicts`, resolve it if it's " +
2588
- "material to your task (see knowledge_get / the conflict-resolution wiki playbook).",
2617
+ "material to your task (see knowledge_get / the conflict-resolution wiki playbook). " +
2618
+ "If semantic ranking is unavailable the search transparently degrades to keyword-only " +
2619
+ "(meta.fallback: true, meta.search_mode: 'keyword_only') and now reports meta.fallback_reason " +
2620
+ "— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
2621
+ "embedding_provider_error_<status>, embedding_timeout).",
2589
2622
  inputSchema: {
2590
2623
  type: "object",
2591
2624
  properties: {
@@ -2674,7 +2707,10 @@ const TOOLS = [
2674
2707
  "memory, scope to a memory_type/agent/conversation via the memory_types/agents/" +
2675
2708
  "conversation_id filters (articles whose metadata carries those keys). NOTE (#163): for " +
2676
2709
  "an agent key, another agent's private/owner memories are never returned (results AND " +
2677
- "linked refs) regardless of the agents= filter — visibility is enforced, not advisory.",
2710
+ "linked refs) regardless of the agents= filter — visibility is enforced, not advisory. " +
2711
+ "If semantic ranking is unavailable this degrades to keyword-only (meta.fallback: true) and " +
2712
+ "now reports meta.fallback_reason — a stable tag naming WHY (e.g. no_embedding_key, " +
2713
+ "embedding_circuit_open, embedding_provider_error_<status>, embedding_timeout).",
2678
2714
  inputSchema: {
2679
2715
  type: "object",
2680
2716
  properties: {
@@ -0,0 +1,296 @@
1
+ // Witness-protocol (Signed Tree Head) client state — shared by index.js and the
2
+ // test suite so both exercise the SAME retry + persistence logic.
3
+ //
4
+ // Background (loopctl chain-of-custody v2, §4.4): every authenticated request
5
+ // must echo the caller's last-known STH via `X-Loopctl-Last-Known-STH`. A brand
6
+ // new caller has no STH, so its FIRST request opts in with
7
+ // `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the response's
8
+ // `x-loopctl-current-sth` header. That bootstrap grace is ONE-TIME PER API KEY
9
+ // (custody-03 / GHSA-36g5-mcrh-rcrm): once consumed, a later request that still
10
+ // lacks the header gets `412 witness_bootstrap_already_consumed`.
11
+ //
12
+ // The MCP server is a short-lived process: a fresh Claude session spawns a fresh
13
+ // process that historically started with no STH, so — once the key's one-time
14
+ // grace was consumed by an earlier process — its very first tool call hit that
15
+ // 412 (#298). This module fixes that on the CLIENT side, two ways:
16
+ //
17
+ // 1. PERSISTENCE — the learned STH is cached to a small state file keyed by
18
+ // (server URL + API key), so a fresh process loads it and sends a real
19
+ // header on its first request, never tripping the bootstrap 412 at all.
20
+ // 2. TRANSPARENT RETRY — if a request still hits a witness-plug 412 that
21
+ // carries the current STH (e.g. the state file was missing/corrupt), the
22
+ // client caches that STH and retries the SAME request ONCE, so the tool
23
+ // call succeeds instead of surfacing the 412.
24
+ //
25
+ // SAFETY: the witness plug runs early and HALTS the request before the operation
26
+ // executes, so a witness 412/409 means the original request had no side effect —
27
+ // retrying it once is safe even for non-idempotent POSTs. The retry is bounded to
28
+ // a single attempt (never a loop). None of this weakens the server-side one-time
29
+ // grace: it only teaches legit clients to carry/relearn the STH they are entitled
30
+ // to. See mcp-server/README.md → "Witness protocol (STH)".
31
+ //
32
+ // 409 IS NOT AUTO-RETRIED (deliberate): a `409 witness_divergence` means the
33
+ // client's cached STH prefix does not match the server's at that position — the
34
+ // genuine-fork / resync signal (custody-01). The client caches the server's STH
35
+ // from the 409's `x-loopctl-current-sth` header so the NEXT request self-heals,
36
+ // but the current request's 409 is surfaced (not silently retried) so a real
37
+ // divergence is never papered over. Only the bootstrap-grace 412 is retried.
38
+
39
+ import { createHash, randomBytes } from "node:crypto";
40
+
41
+ // Explicit override for the STH state-file location (absolute path). When unset,
42
+ // the file is derived under the OS temp dir, keyed by (server URL + API key).
43
+ export const STH_STATE_PATH_ENV = "LOOPCTL_STH_STATE_PATH";
44
+
45
+ // The `x-loopctl-current-sth` header the client caches / retries with. Shape:
46
+ // `<position>:<22-char base64url signature prefix>`. Validating the shape guards
47
+ // against a corrupt state file feeding a malformed header (which the server would
48
+ // itself reject 412 witness_header_malformed).
49
+ const STH_SHAPE = /^\d+:[A-Za-z0-9_-]{22}$/;
50
+
51
+ // The error code of the ONE 412 that is safe to transparently retry.
52
+ const BOOTSTRAP_CONSUMED_CODE = "witness_bootstrap_already_consumed";
53
+
54
+ /**
55
+ * True when `sth` is a well-formed `<position>:<22-char base64url prefix>` value.
56
+ * @param {unknown} sth
57
+ * @returns {boolean}
58
+ */
59
+ export function isValidSth(sth) {
60
+ return typeof sth === "string" && STH_SHAPE.test(sth);
61
+ }
62
+
63
+ /**
64
+ * Resolve the STH state-file path. Honors an explicit `LOOPCTL_STH_STATE_PATH`
65
+ * override; otherwise derives a file under the OS temp dir keyed by BOTH the
66
+ * server URL AND the API key, so two keys/tenants on the same host never share
67
+ * (and clobber / spuriously invalidate) each other's cached STH. Only a NON-SECRET
68
+ * sha256 hash of the key goes in the filename — the key itself never hits disk.
69
+ *
70
+ * @param {{ env: Record<string,string|undefined>, os: { tmpdir(): string }, path: { join(...p: string[]): string }, apiKey?: string }} deps
71
+ * @returns {string}
72
+ */
73
+ export function resolveSthStatePath({ env, os, path, apiKey = "" }) {
74
+ const override = env[STH_STATE_PATH_ENV];
75
+ if (typeof override === "string" && override.trim() !== "") return override;
76
+
77
+ const baseUrl = (env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
78
+ // Scope by server URL AND api key (#298 review HIGH-2): distinct keys must not
79
+ // collide on one cache file. The key is hashed (never stored in plaintext).
80
+ const digest = createHash("sha256").update(`${baseUrl}:${apiKey}`).digest("hex").slice(0, 16);
81
+ return path.join(os.tmpdir(), `loopctl-mcp-sth-${digest}.json`);
82
+ }
83
+
84
+ /**
85
+ * Load a persisted STH string from `filePath`, or `null`. NEVER throws — a
86
+ * missing, unreadable, non-JSON, or shape-invalid file degrades to `null` so the
87
+ * caller falls back to the bootstrap+retry path.
88
+ *
89
+ * SYMLINK / OWNERSHIP GUARD (CWE-59, #298 review HIGH-1): on a shared `/tmp` the
90
+ * cache path is predictable, so a co-located user could pre-plant a file/symlink
91
+ * there. Before reading we `lstat` the path and refuse (→ null, in-memory only) if
92
+ * it is a symlink or is not owned by the current uid.
93
+ *
94
+ * @param {string} filePath
95
+ * @param {{ fs: { readFileSync: Function, lstatSync?: Function }, getuid?: () => number }} deps
96
+ * @returns {string|null}
97
+ */
98
+ export function loadPersistedSth(filePath, { fs, getuid }) {
99
+ try {
100
+ if (typeof fs.lstatSync === "function") {
101
+ const st = fs.lstatSync(filePath);
102
+ if (st.isSymbolicLink()) return null;
103
+ if (typeof getuid === "function" && st.uid !== getuid()) return null;
104
+ }
105
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
106
+ const sth = parsed && typeof parsed.sth === "string" ? parsed.sth : null;
107
+ return isValidSth(sth) ? sth : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Persist an STH string to `filePath`. NEVER throws — a write failure (read-only
115
+ * temp dir, full disk, symlink refusal) degrades to in-memory-only caching.
116
+ * Returns whether the write succeeded.
117
+ *
118
+ * ATOMIC + SYMLINK-SAFE (CWE-59, #298 review HIGH-1): writes to a fresh
119
+ * per-process temp file opened with `wx` (O_CREAT|O_EXCL — refuses a pre-planted
120
+ * symlink at the temp path) and mode 0o600, then `rename`s it over `filePath`.
121
+ * `rename` replaces the destination path ENTRY atomically and never writes THROUGH
122
+ * a symlink at `filePath`, so an attacker's symlink there is replaced, not
123
+ * followed — no arbitrary-file clobber. The rename-over also fixes the torn-file
124
+ * race from a killed mid-write.
125
+ *
126
+ * @param {string} filePath
127
+ * @param {string} sth
128
+ * @param {{ fs: { writeFileSync: Function, renameSync: Function, unlinkSync?: Function }, pid?: number }} deps
129
+ * @returns {boolean}
130
+ */
131
+ export function persistSth(filePath, sth, { fs, pid = process.pid }) {
132
+ if (!isValidSth(sth)) return false;
133
+ const tmp = `${filePath}.${pid}.${randomBytes(6).toString("hex")}.tmp`;
134
+ try {
135
+ fs.writeFileSync(
136
+ tmp,
137
+ JSON.stringify({ sth, updated_at: new Date().toISOString() }),
138
+ { encoding: "utf8", flag: "wx", mode: 0o600 },
139
+ );
140
+ fs.renameSync(tmp, filePath);
141
+ return true;
142
+ } catch {
143
+ // Best-effort cleanup of the temp file; ignore any failure.
144
+ try {
145
+ if (typeof fs.unlinkSync === "function") fs.unlinkSync(tmp);
146
+ } catch {
147
+ /* ignore */
148
+ }
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Decide whether a response should trigger a one-shot transparent retry, and with
155
+ * which STH. Returns the STH to retry with, or `null`.
156
+ *
157
+ * Gated on `412` AND a shape-valid `x-loopctl-current-sth` header — which the
158
+ * witness plug sets ONLY on the `witness_bootstrap_already_consumed` branch (not
159
+ * on the malformed/missing 412s, where a retry couldn't help). This precisely
160
+ * targets the fresh-process bootstrap-grace 412 (#298). The caller additionally
161
+ * confirms the body's `error.code` (see `createWitnessClient`).
162
+ *
163
+ * @param {number} status
164
+ * @param {string|null|undefined} currentSthHeader
165
+ * @returns {string|null}
166
+ */
167
+ export function bootstrapRetrySth(status, currentSthHeader) {
168
+ return status === 412 && isValidSth(currentSthHeader) ? currentSthHeader : null;
169
+ }
170
+
171
+ /**
172
+ * Create a witness-aware request client that owns the shared STH state (loaded
173
+ * from `statePath` at construction), injects the witness header on every attempt,
174
+ * caches + persists any STH the server returns, coalesces concurrent cold-start
175
+ * bootstraps, and transparently retries a bootstrap-grace 412 ONCE.
176
+ *
177
+ * `send` returns the raw `Response` of the FINAL attempt so the caller keeps full
178
+ * control of body parsing / error shaping. Network errors from `fetchImpl`
179
+ * propagate (the caller's try/catch handles them); a network failure is not
180
+ * retried.
181
+ *
182
+ * @param {{
183
+ * fetchImpl?: typeof fetch,
184
+ * statePath?: string|null,
185
+ * fs?: object,
186
+ * getuid?: () => number,
187
+ * pid?: number,
188
+ * timeoutMs?: number,
189
+ * }} [deps]
190
+ */
191
+ export function createWitnessClient({
192
+ fetchImpl = fetch,
193
+ statePath = null,
194
+ fs,
195
+ getuid,
196
+ pid,
197
+ timeoutMs = 30_000,
198
+ } = {}) {
199
+ // Load any persisted STH so a FRESH process sends a real header on request #1
200
+ // and skips the bootstrap-grace 412 entirely.
201
+ let lastKnownSTH =
202
+ statePath && fs ? loadPersistedSth(statePath, { fs, getuid }) : null;
203
+
204
+ // Cold-start singleflight (#298 review MEDIUM-6): when several tool calls fire
205
+ // at process start they all observe lastKnownSTH === null; without coalescing
206
+ // each would send its own bootstrap. The FIRST becomes the leader; the rest
207
+ // await its result and then send with the learned header.
208
+ let bootstrapInFlight = null;
209
+
210
+ function remember(sth) {
211
+ if (!isValidSth(sth) || sth === lastKnownSTH) return;
212
+ lastKnownSTH = sth;
213
+ if (statePath && fs) persistSth(statePath, sth, { fs, pid });
214
+ }
215
+
216
+ async function attempt({ url, method, headers, serializedBody }) {
217
+ // Witness protocol: echo the cached STH, or (never seen one) request a
218
+ // one-time bootstrap. A fresh AbortSignal per attempt so the retry gets its
219
+ // own timeout budget.
220
+ const withWitness = { ...headers };
221
+ if (lastKnownSTH) withWitness["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
222
+ else withWitness["X-Loopctl-STH-Bootstrap"] = "true";
223
+
224
+ const options = {
225
+ method,
226
+ headers: withWitness,
227
+ signal: AbortSignal.timeout(timeoutMs),
228
+ };
229
+ if (serializedBody !== undefined) options.body = serializedBody;
230
+
231
+ return fetchImpl(url, options);
232
+ }
233
+
234
+ // Confirm a bootstrap-grace 412 against the DOCUMENTED contract before retrying
235
+ // (#298 review LOW-8): status 412 + a valid STH header, AND — when the body is
236
+ // readable — error.code === "witness_bootstrap_already_consumed". A body-read
237
+ // failure falls back to the status+header decision so a transient parse issue
238
+ // never defeats the retry. Reads a CLONE so the caller can still consume the
239
+ // body of a response we end up NOT retrying.
240
+ async function retrySthFor(response) {
241
+ const sth = response.headers.get("x-loopctl-current-sth");
242
+ if (!bootstrapRetrySth(response.status, sth)) return null;
243
+ try {
244
+ const body = await response.clone().json();
245
+ const code = body && body.error && body.error.code;
246
+ if (code && code !== BOOTSTRAP_CONSUMED_CODE) return null;
247
+ } catch {
248
+ /* unreadable body → fall back to status+header */
249
+ }
250
+ return sth;
251
+ }
252
+
253
+ async function performSend(req) {
254
+ let response = await attempt(req);
255
+ remember(response.headers.get("x-loopctl-current-sth"));
256
+
257
+ // Bounded, single transparent retry for the bootstrap-grace 412. `remember`
258
+ // above already cached the STH, so this second attempt sends a real header.
259
+ if (await retrySthFor(response)) {
260
+ response = await attempt(req);
261
+ remember(response.headers.get("x-loopctl-current-sth"));
262
+ }
263
+
264
+ return response;
265
+ }
266
+
267
+ async function send(req) {
268
+ // Cold-start coalescing: only the leader bootstraps; followers await it and
269
+ // then send with the learned STH. On the (rare) leader failure, followers
270
+ // fall through and each bootstrap — acceptable degradation.
271
+ if (lastKnownSTH === null && bootstrapInFlight) {
272
+ await bootstrapInFlight;
273
+ return performSend(req);
274
+ }
275
+
276
+ if (lastKnownSTH === null) {
277
+ let done;
278
+ bootstrapInFlight = new Promise((resolve) => {
279
+ done = resolve;
280
+ });
281
+ try {
282
+ return await performSend(req);
283
+ } finally {
284
+ done();
285
+ bootstrapInFlight = null;
286
+ }
287
+ }
288
+
289
+ return performSend(req);
290
+ }
291
+
292
+ return {
293
+ send,
294
+ getSTH: () => lastKnownSTH,
295
+ };
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.32.0",
3
+ "version": "2.33.1",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",