loopctl-mcp-server 2.33.0 → 2.34.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 CHANGED
@@ -62,10 +62,56 @@ Or if installed locally:
62
62
  | `LOOPCTL_API_KEY` | Global API key override (if set, always used) | -- |
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
- | `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. | -- |
65
+ | `LOOPCTL_USER_KEY` | User role API key (minted at signup). Required for **first-time BYO LLM key provisioning** (`set_llm_config` / `llm_config` see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)) and for destructive admin tools like `knowledge_bulk_publish`. | -- |
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
 
70
+ ## First-time setup — provision your BYO LLM keys
71
+
72
+ **loopctl is agent-native and strictly BYO (bring-your-own-keys).** loopctl fronts
73
+ **no** LLM cost — the knowledge wiki runs entirely on *your* provider keys, which you
74
+ provision once and which bill you directly. Two SEPARATE keys power two capabilities:
75
+
76
+ | Key | Provider | Powers | Missing ⇒ |
77
+ |---|---|---|---|
78
+ | `api_key` | Anthropic | knowledge **ingest** — extraction, classification, merge synthesis | `knowledge_ingest` returns **422** (`code: no_api_key`) |
79
+ | `embedding_api_key` | OpenAI-compatible | article **embeddings + semantic search** | `knowledge_search` silently degrades to **keyword-only** (`meta.fallback_reason: no_embedding_key`) |
80
+
81
+ Both keys are stored **encrypted** and are **never returned** by any tool. You can set
82
+ per-operation model overrides (`extraction_model`, `classification_model`,
83
+ `merge_model`, `embedding_model`); each defaults server-side when omitted.
84
+
85
+ ### The smooth path (once, at onboarding)
86
+
87
+ 1. **Sign up** and obtain your keys. Tenant signup is anchored to a hardware
88
+ authenticator (WebAuthn) — the one human touch. Signup mints your **user-role**
89
+ API key. Also grab your **agent** and **orchestrator** keys for day-to-day work.
90
+ 2. **Set the env** in your `.mcp.json` (see [Configuration](#configuration)):
91
+ `LOOPCTL_SERVER`, `LOOPCTL_USER_KEY` (needed for this step), plus `LOOPCTL_AGENT_KEY`
92
+ / `LOOPCTL_ORCH_KEY`.
93
+ 3. **Provision both keys in one call** (uses `LOOPCTL_USER_KEY`):
94
+
95
+ ```
96
+ set_llm_config({ api_key: "sk-ant-...", embedding_api_key: "sk-..." })
97
+ ```
98
+
99
+ Partial-merge: you can set or rotate one key at a time; omitting a key leaves the
100
+ existing one untouched. Optionally pass model overrides in the same call.
101
+ 4. **You're live.** `knowledge_ingest` extracts articles and `knowledge_search`
102
+ (combined/semantic) ranks by meaning. Check status anytime with
103
+ `llm_config` (reports `has_api_key` / `has_embedding_key` + masked last-4 hints,
104
+ never the key).
105
+
106
+ ### Self-healing: you can't get stuck
107
+
108
+ If you call `knowledge_ingest` or `knowledge_search` **before** provisioning, the
109
+ tool result **leads with an `ACTION REQUIRED` notice** and a machine-readable
110
+ `remediation` object — naming the `set_llm_config` tool, a copy-paste example, the
111
+ REST endpoint (`PATCH /api/v1/tenants/me/llm-config`), and the docs — so you (or an
112
+ autonomous agent) can self-remediate without a human. Full agent-tenant lifecycle:
113
+ [`docs/onboarding-agent-tenant.md`](../docs/onboarding-agent-tenant.md).
114
+
69
115
  ## Tools (72)
70
116
 
71
117
  ### Project Tools
@@ -176,7 +222,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
176
222
  | `knowledge_drafts` | List draft (unpublished) knowledge articles with pagination. Optional: `limit` (default 20, max 1000 — over-max → 400, no silent clamp), `offset` (default 0), `project_id`. Returns `meta.total_count`. |
177
223
  | `knowledge_lint` | Run a lint check on the knowledge wiki to identify stale or low-coverage articles. Optional: `project_id`, `stale_days`, `min_coverage`, `max_per_category` (default 50, max 500). True totals returned in `summary.total_per_category`. |
178
224
  | `knowledge_export` | Export all knowledge articles as an OKF v0.1 bundle (gzipped tar archive, unbounded, bounded-memory streaming, fail-closed). Returns a curl command for direct download — **the download requires `LOOPCTL_USER_KEY`** (an orchestrator key would 403). Pass `format=json` for buffered in-memory JSON (convenience tool for file writers; capped at `export_max_buffered_export_articles` — returns 413 if over-cap). Optional: `project_id`, `format` (`tar.gz` default or `json`). |
179
- | `knowledge_ingest` | Submit a URL or raw content for knowledge extraction. Enqueues an Oban job. Extracted articles are **drafts by default** (lower-trust LLM output); pass `publish: true` to publish on extraction. Required: `source_type`. One of: `url` or `content`. Optional: `project_id`, `publish`. |
225
+ | `knowledge_ingest` | Submit a URL or raw content for knowledge extraction. Enqueues an Oban job. Extracted articles are **drafts by default** (lower-trust LLM output); pass `publish: true` to publish on extraction. **BYO:** runs on the tenant's own Anthropic key — a keyless tenant gets a 422 whose result leads with an `ACTION REQUIRED` notice pointing at `set_llm_config` (see [First-time setup](#first-time-setup--provision-your-byo-llm-keys)). Required: `source_type`. One of: `url` or `content`. Optional: `project_id`, `publish`. |
180
226
  | `knowledge_ingest_batch` | Submit up to 50 ingestion items in a single request. Each item has the same shape as `knowledge_ingest` (incl. `publish`). Returns per-item results. Required: `items`. Optional: batch-level `project_id` / `publish` defaults. |
181
227
  | `knowledge_ingestion_jobs` | List recent content ingestion jobs (last 7 days, max 50). |
182
228
 
@@ -184,8 +230,8 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
184
230
 
185
231
  | Tool | Description |
186
232
  |---|---|
187
- | `llm_config` | Get the tenant's BYO Anthropic LLM config: per-operation models, `has_api_key`, and a masked last-4 hint. Never returns the key. Requires **user** key. |
188
- | `set_llm_config` | Set/rotate the tenant's OWN Anthropic API key (stored encrypted, never returned) + the three per-operation models (`extraction_model`/`classification_model`/`merge_model`). Any subset; omitting `api_key` leaves it untouched. Requires **user** key. |
233
+ | `llm_config` | **Check your onboarding status.** Get the tenant's BYO LLM config: per-operation models and whether each key is set — `has_api_key` (Anthropic) / `has_embedding_key` (OpenAI embedding) — plus masked last-4 hints. Never returns a key. Requires **user** key. |
234
+ | `set_llm_config` | **First-time setup (do once).** Set/rotate the tenant's OWN **Anthropic `api_key`** (powers ingest) AND **OpenAI `embedding_api_key`** (powers semantic search) — both stored encrypted, never returned plus the per-operation models (`extraction_model`/`classification_model`/`merge_model`/`embedding_model`). Any subset; partial-merge (omitting a key leaves it untouched). See [First-time setup](#first-time-setup--provision-your-byo-llm-keys). Requires **user** key. |
189
235
  | `knowledge_llm_usage` | Per-tenant LLM token-usage summary, grouped by operation + model + source_type + day over an optional `from`/`to` range (defaults to a 90-day lookback; effective window echoed in `meta.from`/`meta.to`), with `limit`/`offset` pagination. Record-only (no budget enforcement). Requires orchestrator key. |
190
236
 
191
237
  ### Knowledge Analytics Tools (orchestrator key)
@@ -322,6 +368,46 @@ loopctl enforces that nobody marks their own work as done. The API returns `409`
322
368
 
323
369
  The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
324
370
 
371
+ ## Witness protocol (STH)
372
+
373
+ Every authenticated request echoes the caller's last-known Signed Tree Head (STH)
374
+ via the `X-Loopctl-Last-Known-STH` header — loopctl's tamper-evident audit chain
375
+ (chain-of-custody v2, §4.4). A brand-new caller has no STH, so its first request
376
+ opts in with `X-Loopctl-STH-Bootstrap: true` to receive the current STH in the
377
+ `x-loopctl-current-sth` response header. **That bootstrap grace is one-time per
378
+ API key** (a deliberate security gate): once consumed, a later request that still
379
+ lacks the header gets `412 witness_bootstrap_already_consumed`.
380
+
381
+ This MCP server handles that transparently, so you never see the 412:
382
+
383
+ - **Retry-once contract (412 only).** Any `412 witness_bootstrap_already_consumed`
384
+ carrying an `x-loopctl-current-sth` header is caught, the STH is cached, and the
385
+ SAME request is retried exactly **once** with `X-Loopctl-Last-Known-STH`. Bounded
386
+ to a single retry (never a loop) and anchored to the response's `error.code`. It
387
+ is safe because the server's witness plug halts *before* the operation runs, so
388
+ the rejected request had no side effect. The 412 body also carries a
389
+ machine-readable `error.remediation.retry` contract describing exactly this.
390
+ - **A `409 witness_divergence` is NOT auto-retried** — deliberately. It means the
391
+ cached STH prefix does not match the server's (the genuine-fork / resync signal,
392
+ custody-01). The client caches the server's STH from the 409 so the *next*
393
+ request self-heals, but the current 409 is surfaced rather than papered over.
394
+ - **Cross-process persistence.** The learned STH is cached to a small state file so
395
+ a **fresh** MCP process (a new Claude session, a script, a CI run) loads it and
396
+ sends a real header on its first request — avoiding the 412 entirely. The file is
397
+ keyed by **(server URL + API key)** so distinct keys/tenants on one host never
398
+ share (or clobber) each other's cache; only a non-secret hash of the key appears
399
+ in the filename (`loopctl-mcp-sth-<hash>.json` under the OS temp dir), never the
400
+ key itself. Override the location with `LOOPCTL_STH_STATE_PATH`. Writes are
401
+ **atomic and symlink-safe** (write to a private `0600` temp file with `O_EXCL`,
402
+ then rename over the target — never write through a symlink), and loads refuse a
403
+ symlinked or foreign-owned file. All file I/O degrades gracefully: a missing,
404
+ corrupt, unwritable, or refused state file falls back to in-memory caching plus
405
+ the retry-once contract above.
406
+
407
+ Dispatch-based (v2) clients that mint a fresh ephemeral key per dispatch are
408
+ unaffected: because the cache is keyed per API key, each fresh key gets its own
409
+ clean one-time bootstrap (no cross-key collision).
410
+
325
411
  ## Troubleshooting
326
412
 
327
413
  ### 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
  }
@@ -204,6 +233,51 @@ function toContentCompact(result) {
204
233
  return toContent(result);
205
234
  }
206
235
 
236
+ /**
237
+ * Surface a server-emitted BYO-LLM `remediation` PROMINENTLY as a leading text
238
+ * block. A stranger agent that calls knowledge_ingest / knowledge_search /
239
+ * knowledge_context BEFORE provisioning its keys then reads the exact next step
240
+ * in the tool result — call set_llm_config — instead of hunting through raw JSON.
241
+ *
242
+ * The remediation lives in one of two places depending on the failure shape:
243
+ * - ingest no-key 422 → result.body.error.remediation (code "no_api_key")
244
+ * - search/context degrade → result.meta.remediation (fallback_reason
245
+ * "no_embedding_key"); the request still returns 200 keyword-only results.
246
+ *
247
+ * Returns a notice string, or null when there is no LLM remediation to surface.
248
+ */
249
+ function llmRemediationNotice(result) {
250
+ const rem =
251
+ (result &&
252
+ result.error === true &&
253
+ result.body &&
254
+ result.body.error &&
255
+ result.body.error.remediation) ||
256
+ (result && result.meta && result.meta.remediation) ||
257
+ null;
258
+
259
+ if (!rem || rem.action !== "configure_llm") return null;
260
+
261
+ const missing = Array.isArray(rem.missing) ? rem.missing.join(", ") : rem.missing;
262
+ return (
263
+ `ACTION REQUIRED — BYO LLM key not configured (missing: ${missing}). ` +
264
+ `${rem.message || ""} Provision it ONCE with the ${rem.mcp_tool} MCP tool, e.g. ` +
265
+ `${rem.example} (REST: ${rem.api}). Requires your user-role key (LOOPCTL_USER_KEY). ` +
266
+ `Docs: ${rem.docs}`
267
+ ).replace(/\s+/g, " ").trim();
268
+ }
269
+
270
+ /**
271
+ * toContent, but with any BYO-LLM remediation notice prepended so it is the first
272
+ * thing the agent reads. Preserves isError from the underlying result.
273
+ */
274
+ function withRemediationNotice(result) {
275
+ const base = toContent(result);
276
+ const notice = llmRemediationNotice(result);
277
+ if (!notice) return base;
278
+ return { ...base, content: [{ type: "text", text: notice }, ...base.content] };
279
+ }
280
+
207
281
  // ---------------------------------------------------------------------------
208
282
  // Tool implementations
209
283
  // ---------------------------------------------------------------------------
@@ -802,7 +876,10 @@ async function knowledgeSearch({ q, project_id, story_id, category, tags, match,
802
876
  if (offset != null) params.set("offset", String(offset));
803
877
 
804
878
  const result = await apiCall("GET", `/api/v1/knowledge/search?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
805
- return toContent(result);
879
+ // If search silently degraded to keyword-only for a missing embedding key, the
880
+ // server attaches meta.remediation — surface it PROMINENTLY so the agent knows to
881
+ // call set_llm_config to enable semantic ranking.
882
+ return withRemediationNotice(result);
806
883
  }
807
884
 
808
885
  async function knowledgeList({
@@ -873,7 +950,8 @@ async function knowledgeContext({
873
950
  if (conversation_id) params.set("conversation_id", conversation_id);
874
951
 
875
952
  const result = await apiCall("GET", `/api/v1/knowledge/context?${params}`, null, process.env.LOOPCTL_AGENT_KEY);
876
- return toContent(result);
953
+ // Same as knowledge_search: surface a missing-embedding-key remediation prominently.
954
+ return withRemediationNotice(result);
877
955
  }
878
956
 
879
957
  async function knowledgeCreate({
@@ -1129,7 +1207,9 @@ async function knowledgeIngest({ url, content, source_type, project_id, publish
1129
1207
  if (project_id) body.project_id = project_id;
1130
1208
  if (publish) body.publish = true;
1131
1209
  const result = await apiCall("POST", "/api/v1/knowledge/ingest", body, process.env.LOOPCTL_ORCH_KEY);
1132
- return toContent(result);
1210
+ // A keyless tenant gets a 422 (code no_api_key) carrying a remediation — surface it
1211
+ // prominently so a first-time agent knows to call set_llm_config before ingesting.
1212
+ return withRemediationNotice(result);
1133
1213
  }
1134
1214
 
1135
1215
  async function knowledgeIngestBatch({ items, project_id, publish }) {
@@ -1152,7 +1232,9 @@ async function knowledgeIngestBatch({ items, project_id, publish }) {
1152
1232
  { items: resolvedItems },
1153
1233
  process.env.LOOPCTL_ORCH_KEY
1154
1234
  );
1155
- return toContent(result);
1235
+ // Batch ingest gates on the Anthropic key up front too — a keyless tenant gets a
1236
+ // 422 with the same remediation; surface it prominently.
1237
+ return withRemediationNotice(result);
1156
1238
  }
1157
1239
 
1158
1240
  async function knowledgeIngestionJobs(args = {}) {
@@ -2589,7 +2671,9 @@ const TOOLS = [
2589
2671
  "If semantic ranking is unavailable the search transparently degrades to keyword-only " +
2590
2672
  "(meta.fallback: true, meta.search_mode: 'keyword_only') and now reports meta.fallback_reason " +
2591
2673
  "— a stable tag naming WHY (e.g. no_embedding_key, embedding_circuit_open, " +
2592
- "embedding_provider_error_<status>, embedding_timeout).",
2674
+ "embedding_provider_error_<status>, embedding_timeout). When the reason is a MISSING " +
2675
+ "embedding key (no_embedding_key), the result leads with an ACTION REQUIRED notice + " +
2676
+ "meta.remediation telling you to provision it with set_llm_config (BYO — do it once).",
2593
2677
  inputSchema: {
2594
2678
  type: "object",
2595
2679
  properties: {
@@ -3267,7 +3351,10 @@ const TOOLS = [
3267
3351
  "Enqueues an Oban job that fetches the content (if URL), extracts knowledge articles via LLM, " +
3268
3352
  "and inserts them. Extracted articles are DRAFTS by default (lower-trust LLM output, staged " +
3269
3353
  "for review) — unlike knowledge_create which publishes by default. Pass publish:true to " +
3270
- "publish them on extraction. Requires orchestrator role.",
3354
+ "publish them on extraction. Requires orchestrator role. BYO: extraction runs on the " +
3355
+ "tenant's OWN Anthropic key — a keyless tenant gets a 422 (code no_api_key) and the " +
3356
+ "result leads with an ACTION REQUIRED notice telling you to provision it once via " +
3357
+ "set_llm_config.",
3271
3358
  inputSchema: {
3272
3359
  type: "object",
3273
3360
  properties: {
@@ -3380,29 +3467,44 @@ const TOOLS = [
3380
3467
  {
3381
3468
  name: "llm_config",
3382
3469
  description:
3383
- "Get the tenant's BYO Anthropic + embedding configuration: the per-operation " +
3384
- "model choices, whether each key is configured (has_api_key / has_embedding_key), " +
3385
- "and masked last-4 hints. NEVER returns a key itself. Requires user role " +
3386
- "(LOOPCTL_USER_KEY).",
3470
+ "CHECK your BYO LLM onboarding status. Returns this tenant's per-operation model " +
3471
+ "choices and whether each key is configured — `has_api_key` (Anthropic, powers " +
3472
+ "ingest) and `has_embedding_key` (OpenAI embedding, powers semantic search) plus " +
3473
+ "masked last-4 hints. NEVER returns a key itself. Call this BEFORE ingest/search to " +
3474
+ "confirm setup, or after set_llm_config to verify it took. Uses your user-role key " +
3475
+ "(LOOPCTL_USER_KEY, obtained at signup); managing tenant secrets is user-only, so an " +
3476
+ "agent/orchestrator key is rejected.",
3387
3477
  inputSchema: { type: "object", properties: {}, required: [] },
3388
3478
  },
3389
3479
  {
3390
3480
  name: "set_llm_config",
3391
3481
  description:
3392
- "Set/rotate the tenant's OWN Anthropic API key AND OpenAI embedding key (both " +
3393
- "stored encrypted, never returned) and the per-operation models " +
3394
- "(extraction/classification/merge/embedding). loopctl fronts no cost — the " +
3395
- "tenant's keys bill the tenant. Mandatory BYO for embeddings: without an " +
3396
- "embedding_api_key the tenant's articles are not vector-searchable. Any subset " +
3397
- "of fields may be sent; omitting a key leaves the existing key untouched. Model " +
3398
- "ids are free-form (any model the key permits). Requires user role " +
3399
- "(LOOPCTL_USER_KEY).",
3482
+ "FIRST-TIME SETUP do this ONCE, right after signup, to bring the wiki online. " +
3483
+ "Provision your OWN Anthropic + OpenAI embedding keys so knowledge ingest AND " +
3484
+ "semantic search work. loopctl is strictly BYO: it fronts NO LLM cost — your keys " +
3485
+ "bill you directly and are stored ENCRYPTED, never returned. WHY it's required: with " +
3486
+ "no `api_key`, knowledge_ingest returns 422 (code no_api_key); with no " +
3487
+ "`embedding_api_key`, knowledge_search silently degrades to keyword-only " +
3488
+ "(meta.fallback_reason: no_embedding_key) both responses carry a `remediation` " +
3489
+ "pointing back to THIS tool. WHICH key powers WHAT: `api_key` (Anthropic) → ingest " +
3490
+ "extraction / classification / merge synthesis; `embedding_api_key` " +
3491
+ "(OpenAI-compatible) → article embeddings + semantic ranking. All params are optional " +
3492
+ "and partial-merge — omitting a key leaves the existing one untouched, so you can set " +
3493
+ "or rotate one at a time. The per-operation model overrides " +
3494
+ "(extraction_model / classification_model / merge_model / embedding_model) are " +
3495
+ "free-form (any model the key permits) and default server-side when omitted. Typical " +
3496
+ 'onboarding call: set_llm_config({api_key: "sk-ant-...", embedding_api_key: "sk-..."}). ' +
3497
+ "Verify anytime with llm_config (has_api_key / has_embedding_key). REQUIRES your " +
3498
+ "user-role key LOOPCTL_USER_KEY (minted by the human-anchored signup ceremony); if it " +
3499
+ "is unset the tool fails fast telling you to set it.",
3400
3500
  inputSchema: {
3401
3501
  type: "object",
3402
3502
  properties: {
3403
3503
  api_key: {
3404
3504
  type: "string",
3405
- description: "Anthropic API key (write-only; stored encrypted, never returned).",
3505
+ description:
3506
+ "Anthropic API key — powers knowledge ingest (extraction/classification/merge). " +
3507
+ "Write-only; stored encrypted, never returned.",
3406
3508
  },
3407
3509
  extraction_model: {
3408
3510
  type: "string",
@@ -3419,7 +3521,9 @@ const TOOLS = [
3419
3521
  embedding_api_key: {
3420
3522
  type: "string",
3421
3523
  description:
3422
- "OpenAI-compatible embedding API key (write-only; stored encrypted, never returned).",
3524
+ "OpenAI-compatible embedding API key powers article embeddings + semantic " +
3525
+ "search. Write-only; stored encrypted, never returned. Without it, articles are " +
3526
+ "not vector-searchable and search degrades to keyword-only.",
3423
3527
  },
3424
3528
  embedding_model: {
3425
3529
  type: "string",
@@ -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.33.0",
3
+ "version": "2.34.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",