loopctl-mcp-server 2.38.0 → 2.40.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
@@ -121,7 +121,11 @@ REST endpoint (`PATCH /api/v1/tenants/me/llm-config`), and the docs — so you (
121
121
  autonomous agent) can self-remediate without a human. Full agent-tenant lifecycle:
122
122
  [`docs/onboarding-agent-tenant.md`](../docs/onboarding-agent-tenant.md).
123
123
 
124
- ## Tools (83)
124
+ ## Tools (84)
125
+
126
+ > Plus **per-tenant generated Context Retriever tools** (`cr_*`) appended
127
+ > dynamically at runtime — see [Dynamic per-tenant Context Retriever
128
+ > tools](#dynamic-per-tenant-context-retriever-tools-epic-30) below.
125
129
 
126
130
  ### Project Tools
127
131
 
@@ -214,6 +218,7 @@ autonomous agent) can self-remediate without a human. Full agent-tenant lifecycl
214
218
  | `knowledge_conflicts` | List potential-conflict article pairs — published articles flagged "too similar to comfortably coexist" by the auto-linker / nightly lint sweep, highest-overlap first. The KB only FLAGS the pair; it does NOT decide redundancy-vs-contradiction — that's your call with live context. Each entry has both articles (id/title/status/category) + similarity. Then merge (supersede one, `knowledge_create` the merged article, or PATCH) or reconcile if they genuinely disagree. Paginated with `total_count` in meta. Agent role. Optional: `limit` (default 50, max 1000, clamped), `offset`. |
215
219
  | `knowledge_resolve_conflict` | Record YOUR verdict on a potential-conflict pair (from `knowledge_conflicts`). Dispositions: `dismiss` (false positive, drops from queue), `supersede` (one wins — pass `authoritative_article_id`; nightly executor links + retires loser, only at `confidence:"high"`, reversible/audited), `merge` (at high confidence an LLM synthesizes both into ONE new DRAFT, sources preserved, never auto-published). Non-destructive at agent role — you record intent, the privileged nightly job executes. Last-write-wins per pair. Required: `source_article_id`, `target_article_id`, `disposition`. Optional: `authoritative_article_id`, `classification`, `evidence`, `confidence`. |
216
220
  | `knowledge_create` | Create a new knowledge article. File findings, document patterns, or record decisions. **Published immediately by default** (visible per `metadata.visibility` — default `owner` for agent authors, only visible to that agent; `shared` for visibility to all agents) — the response `note` says which outcome occurred. Pass `draft: true` to stage it for later review instead (publish afterwards with `knowledge_publish`). Pass `metadata: {visibility: "shared"}` to make the article visible to other agents; higher roles can set visibility and agent_id explicitly. Pass `idempotency_key` for idempotent capture (re-creating with the same key is a no-op returning the existing article — no partial duplicates). Optional: `category`, `tags`, `project_id`, `draft`, `idempotency_key`, `source_type`, `source_id`, `metadata`. |
221
+ | `knowledge_update` | Edit an EXISTING article IN PLACE, **preserving its ID** (IDs are load-bearing — cited in project CLAUDE.mds and cross-links). Fold in a new fact, tidy a hub, retag, or reclassify without churning a new row. Send only the fields to change; `tags` REPLACES the whole array. A changed body/tags re-triggers embedding + auto-linking. Agent role — KB-content curation (reversible + audited); visibility-scoped, so another agent's private/owner memory 404s. Required: `article_id`. Optional: `title`, `body`, `category`, `tags`, `metadata`. |
217
222
  | `knowledge_okf_export` | **Requires `LOOPCTL_USER_KEY`.** Export the wiki as a portable OKF (Open Knowledge Format) v0.1 bundle of markdown files. Writes to `out_dir`, or returns `{files, meta}` inline. |
218
223
  | `knowledge_okf_import` | **Requires `LOOPCTL_USER_KEY`.** Import an OKF v0.1 bundle from a local directory. Creates or (with `merge`) updates articles; tolerates and preserves unknown frontmatter. |
219
224
 
@@ -243,8 +248,8 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
243
248
  | `knowledge_bulk_publish` | **Requires `LOOPCTL_USER_KEY`.** Publish drafts, partial-success style: every valid draft publishes; others are reported per-id as `skipped` (already published — idempotent — or archived/superseded), `not_found`, or `errored`. No 100-id cap (auto-chunked); duplicates ignored; safe to retry. `meta.count` = published; `meta.counts`/`meta.results` give the breakdown. Required: `article_ids` (array). |
244
249
  | `knowledge_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert a published article back to draft (hidden from search/context, not deleted). Required: `article_id`. |
245
250
  | `knowledge_bulk_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert published articles to draft in bulk, partial-success style (mirror of `knowledge_bulk_publish`): per-id `unpublished`/`skipped` (already draft, or archived/superseded)/`not_found`/`errored`. No 100-id cap (auto-chunked, ≤5000); duplicates ignored; safe to retry. Not deleted (re-publish to restore; `knowledge_bulk_delete` to archive). `meta.count`/`meta.counts`/`meta.results` give the breakdown. Required: `article_ids` (array). |
246
- | `knowledge_archive` | **Requires `LOOPCTL_USER_KEY`.** Soft-delete an article (draft or published). Row retained for audit; hidden from all reads. Required: `article_id`. |
247
- | `knowledge_delete` | **Requires `LOOPCTL_USER_KEY`.** Alias for `knowledge_archive` — DELETE verb on the REST API archives under the hood. Required: `article_id`. |
251
+ | `knowledge_archive` | Soft-delete an article (draft or published). Row retained for audit; hidden from all reads (reversible — re-publish/edit it back). Agent role — KB-content curation, visibility-scoped (another agent's private/owner memory 404s). Required: `article_id`. |
252
+ | `knowledge_delete` | Alias for `knowledge_archive` — DELETE verb on the REST API archives under the hood (soft delete, reversible, audited). Agent role. (Irreversible HARD delete is `knowledge_bulk_delete hard:true`, which stays `LOOPCTL_USER_KEY`.) Required: `article_id`. |
248
253
  | `knowledge_bulk_delete` | **Requires `LOOPCTL_USER_KEY`.** Bulk archive (default, reversible) or IRREVERSIBLE hard-delete by selector. Provide exactly one selector: `article_ids` (list), `source_type`+`source_id` (every active article from a source), or `tag`+`confirm:true` (every active article with the tag — high blast radius). Default = set-based soft archive (idempotent; `meta.count`=archived, `meta.counts`/`meta.results` give the breakdown; ≤5000). **Dry-run** (`dry_run:true`) mutates nothing, returns `meta.would_affect` (with `hard:true` also a single-use `meta.token`, or `meta.confirm_hash` for oversized selectors). **Hard delete** (irreversible): dry-run with `hard:true` for a token, then call again with `hard:true`+`token` to FK-correctly delete the frozen id-set (links first, access events cascade). |
249
254
  | `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`. |
250
255
  | `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`. |
@@ -279,6 +284,26 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
279
284
  | `list_routes` | List all available API routes on the loopctl server. |
280
285
  | `get_system_articles` | List or fetch system-scoped (global, cross-tenant) wiki articles. Public — no auth required. Optional: `slug` (fetch one), `category`. |
281
286
 
287
+ ### Dynamic per-tenant Context Retriever tools (Epic 30)
288
+
289
+ Beyond the static tools above, the server appends **per-tenant generated tools** to
290
+ `ListTools` at runtime. When your tenant declares an **entity** (`POST
291
+ /api/v1/entities`), loopctl auto-generates governed query tools over that entity's
292
+ allowlisted columns, and this MCP server fetches them from `GET
293
+ /api/v1/retrieve/tools` and lists them alongside the static tools:
294
+
295
+ | Generated tool | What it does |
296
+ |---|---|
297
+ | `cr_filter_<entity>_by_<field>` | Filter that entity's records where `<field>` equals a value. Params: the field value + `limit`/`offset`. |
298
+ | `cr_search_<entity>` | Full-text search across that entity's searchable text fields. Params: `query` + `limit`/`offset`. |
299
+
300
+ These are **tenant-scoped**: the listing reflects only the tenant of the process
301
+ key (`LOOPCTL_AGENT_KEY`), resolved server-side — you never pass a tenant. A
302
+ `cr_`-prefixed call is dispatched generically to `POST /api/v1/retrieve/:entity`
303
+ through the same authenticated + witness/STH path as every static read tool. If the
304
+ `/retrieve/tools` fetch fails, listing degrades to the static tools (never errors).
305
+ The generated-tool count per tenant is bounded by the per-tenant entity cap.
306
+
282
307
  ### Dispatch & Chain of Custody (v2) Tools
283
308
 
284
309
  Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys and capability-token recovery. See `docs/chain-of-custody-v2.md`.
package/index.js CHANGED
@@ -25,6 +25,10 @@ import {
25
25
  createWitnessClient,
26
26
  resolveSthStatePath,
27
27
  } from "./lib/witness-sth.js";
28
+ import {
29
+ createGeneratedToolsRuntime,
30
+ GENERATED_TOOL_PREFIX,
31
+ } from "./lib/generated-tools.js";
28
32
 
29
33
  // Single source of truth for the server version: the package.json this file
30
34
  // ships with (npm always includes package.json in the published tarball).
@@ -61,6 +65,11 @@ const SERVER_VERSION = JSON.parse(
61
65
  // transparent retry above). The write is atomic + symlink-safe (temp + rename).
62
66
  const WITNESS_FS = { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync };
63
67
 
68
+ // Default per-request HTTP timeout. Individual calls may pass a SHORTER budget via
69
+ // `apiCall(..., { timeoutMs })` — e.g. the init-time generated-tools listing fetch,
70
+ // which must degrade to static tools quickly rather than block connection setup.
71
+ const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
72
+
64
73
  // One witness client PER API KEY (#298 review HIGH-2): the STH is per-tenant and
65
74
  // each key resolves to a tenant server-side, so distinct keys must NOT share an
66
75
  // in-memory cache or a state file (a collision causes spurious 409s + false
@@ -77,7 +86,7 @@ function witnessClientFor(apiKey) {
77
86
  fs: WITNESS_FS,
78
87
  getuid: typeof process.getuid === "function" ? () => process.getuid() : undefined,
79
88
  pid: process.pid,
80
- timeoutMs: 30_000,
89
+ timeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
81
90
  });
82
91
  witnessClients.set(apiKey, client);
83
92
  }
@@ -104,7 +113,7 @@ function resolveKey(keyOverride) {
104
113
  );
105
114
  }
106
115
 
107
- async function apiCall(method, path, body, keyOverride, { exactKey = false } = {}) {
116
+ async function apiCall(method, path, body, keyOverride, { exactKey = false, timeoutMs } = {}) {
108
117
  const url = `${getBaseUrl()}${path}`;
109
118
  // Secret-managing tools pass exactKey:true so the request uses the EXACT
110
119
  // role-pinned key (LOOPCTL_USER_KEY) and does NOT fall back to the global
@@ -137,10 +146,11 @@ async function apiCall(method, path, body, keyOverride, { exactKey = false } = {
137
146
  // attempt's Response; we keep body parsing / error shaping here.
138
147
  let response;
139
148
  try {
140
- response = await witnessClientFor(key).send({ url, method, headers, serializedBody });
149
+ response = await witnessClientFor(key).send({ url, method, headers, serializedBody, timeoutMs });
141
150
  } catch (err) {
142
151
  if (err.name === "TimeoutError") {
143
- return { error: true, status: 0, body: "Request timed out after 30s" };
152
+ const secs = Math.max(1, Math.round((timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS) / 1000));
153
+ return { error: true, status: 0, body: `Request timed out after ${secs}s` };
144
154
  }
145
155
  const cause = err.cause?.message ? ` (${err.cause.message})` : "";
146
156
  return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
@@ -1065,6 +1075,26 @@ async function knowledgeCreate({
1065
1075
  return toContent(result);
1066
1076
  }
1067
1077
 
1078
+ // In-place edit of an existing article (#331). PATCH preserves the ID; only the
1079
+ // provided fields change. Agent role — KB-content curation, visibility-scoped
1080
+ // server-side (another agent's private/owner memory 404s).
1081
+ async function knowledgeUpdate({ article_id, title, body, category, tags, metadata }) {
1082
+ const payload = {};
1083
+ if (title != null) payload.title = title;
1084
+ if (body != null) payload.body = body;
1085
+ if (category != null) payload.category = category;
1086
+ if (tags != null) payload.tags = tags;
1087
+ if (metadata != null) payload.metadata = metadata;
1088
+
1089
+ const result = await apiCall(
1090
+ "PATCH",
1091
+ `/api/v1/articles/${article_id}`,
1092
+ payload,
1093
+ process.env.LOOPCTL_AGENT_KEY,
1094
+ );
1095
+ return toContent(result);
1096
+ }
1097
+
1068
1098
  // --- Agent Memory Tools (US-28.4) ---
1069
1099
  //
1070
1100
  // memory_* is YOUR own scoped, private, accumulated working state — recall
@@ -1237,22 +1267,25 @@ async function knowledgeUnpublish({ article_id }) {
1237
1267
  return toContent(result);
1238
1268
  }
1239
1269
 
1270
+ // #331: single-article archive is agent-role KB curation (reversible soft delete,
1271
+ // audited, visibility-scoped server-side).
1240
1272
  async function knowledgeArchive({ article_id }) {
1241
1273
  const result = await apiCall(
1242
1274
  "POST",
1243
1275
  `/api/v1/articles/${article_id}/archive`,
1244
1276
  null,
1245
- process.env.LOOPCTL_USER_KEY
1277
+ process.env.LOOPCTL_AGENT_KEY
1246
1278
  );
1247
1279
  return toContent(result);
1248
1280
  }
1249
1281
 
1282
+ // #331: soft-delete (archive) is agent-role KB curation, same as knowledge_archive.
1250
1283
  async function knowledgeDelete({ article_id }) {
1251
1284
  const result = await apiCall(
1252
1285
  "DELETE",
1253
1286
  `/api/v1/articles/${article_id}`,
1254
1287
  null,
1255
- process.env.LOOPCTL_USER_KEY
1288
+ process.env.LOOPCTL_AGENT_KEY
1256
1289
  );
1257
1290
  return toContent(result);
1258
1291
  }
@@ -3157,6 +3190,54 @@ const TOOLS = [
3157
3190
  required: ["title", "body"],
3158
3191
  },
3159
3192
  },
3193
+ {
3194
+ name: "knowledge_update",
3195
+ description:
3196
+ "Edit an EXISTING knowledge article IN PLACE, preserving its ID. Use this to fold a " +
3197
+ "new fact into a canonical article, tidy a hub, retag, or reclassify — WITHOUT " +
3198
+ "creating a new row and churning the article ID (IDs are load-bearing: cited in " +
3199
+ "project CLAUDE.mds and cross-links). Send only the fields you want to change; every " +
3200
+ "field is optional except article_id. `tags` REPLACES the whole array (send the full " +
3201
+ "desired set, not a delta). A changed body/tags re-triggers embedding + auto-linking. " +
3202
+ "Agent role — this is KB-content curation (reversible + audited). Visibility-scoped: " +
3203
+ "you can only edit an article you can see, so another agent's private/owner memory " +
3204
+ "returns 404. `tenant_id` is never accepted. Returns the full updated article. To " +
3205
+ "instead retire/replace an article, use knowledge_archive or knowledge_resolve_conflict.",
3206
+ inputSchema: {
3207
+ type: "object",
3208
+ properties: {
3209
+ article_id: {
3210
+ type: "string",
3211
+ description: "The UUID of the article to edit (preserved across the update).",
3212
+ },
3213
+ title: {
3214
+ type: "string",
3215
+ description: "Optional: new title.",
3216
+ },
3217
+ body: {
3218
+ type: "string",
3219
+ description: "Optional: new body (Markdown). Re-triggers embedding + linking.",
3220
+ },
3221
+ category: {
3222
+ type: "string",
3223
+ description: "Optional: new category.",
3224
+ },
3225
+ tags: {
3226
+ type: "array",
3227
+ items: { type: "string" },
3228
+ description: "Optional: REPLACES the whole tags array (send the full desired set).",
3229
+ },
3230
+ metadata: {
3231
+ type: "object",
3232
+ description:
3233
+ "Optional: extensible JSONB (merged/replaced wholesale). For an agent key, an " +
3234
+ "agent-memory `agent_id` is stamped from your verified key identity — you cannot " +
3235
+ "re-attribute a memory to another agent.",
3236
+ },
3237
+ },
3238
+ required: ["article_id"],
3239
+ },
3240
+ },
3160
3241
 
3161
3242
  // Agent Memory Tools (US-28.4)
3162
3243
  {
@@ -3421,9 +3502,10 @@ const TOOLS = [
3421
3502
  name: "knowledge_archive",
3422
3503
  description:
3423
3504
  "Archive an article (soft delete). The article is hidden from search, context, " +
3424
- "and the index but the row is retained for audit/history. Works for drafts and " +
3425
- "published articles. REQUIRES LOOPCTL_USER_KEY (user role orchestrator role is " +
3426
- "NOT sufficient for this destructive operation).",
3505
+ "and the index but the row is retained for audit/history (reversible re-publish " +
3506
+ "or edit it back). Works for drafts and published articles. Agent role KB-content " +
3507
+ "curation. Visibility-scoped: you can only archive an article you can see, so " +
3508
+ "another agent's private/owner memory returns 404.",
3427
3509
  inputSchema: {
3428
3510
  type: "object",
3429
3511
  properties: {
@@ -3440,8 +3522,9 @@ const TOOLS = [
3440
3522
  description:
3441
3523
  "Delete an article. Under the hood this performs the same soft-delete (archive) " +
3442
3524
  "as knowledge_archive — use whichever name is clearer at the call site. The row " +
3443
- "is retained for audit; there is no hard delete. REQUIRES LOOPCTL_USER_KEY (user " +
3444
- "role orchestrator role is NOT sufficient for this destructive operation).",
3525
+ "is retained for audit; there is no hard delete (that is knowledge_bulk_delete " +
3526
+ "hard:true, which stays user-gated). Agent role KB-content curation, reversible + " +
3527
+ "audited, visibility-scoped (another agent's private/owner memory 404s).",
3445
3528
  inputSchema: {
3446
3529
  type: "object",
3447
3530
  properties: {
@@ -4394,6 +4477,41 @@ const TOOLS = [
4394
4477
  },
4395
4478
  ];
4396
4479
 
4480
+ // ---------------------------------------------------------------------------
4481
+ // Context Retriever — dynamic per-tenant generated tools (US-30.5)
4482
+ // ---------------------------------------------------------------------------
4483
+ //
4484
+ // Epic 30 lets a tenant declare ENTITIES whose schema auto-generates agent tools
4485
+ // (`cr_filter_<entity>_by_<field>`, `cr_search_<entity>`) over governed loopctl
4486
+ // data. Those specs are generated SERVER-SIDE per tenant, so the MCP server can't
4487
+ // hard-code them in the static TOOLS array — it must fetch them at ListTools time
4488
+ // and append them, then dispatch any unknown `cr_`-prefixed CallTool to one
4489
+ // generic executor endpoint.
4490
+ //
4491
+ // Trust model: the stdio MCP process is ONE-TENANT-PER-PROCESS — the process key
4492
+ // (LOOPCTL_AGENT_KEY) resolves to a tenant server-side, and GET /retrieve/tools
4493
+ // returns ONLY that tenant's generated specs. The client never picks a tenant, so
4494
+ // cross-tenant listing/calling is impossible by construction (AC-30.5.3). Both the
4495
+ // listing fetch and the generic call ride the SAME `apiCall` path as static read
4496
+ // tools, so they carry identical auth + witness/STH headers (AC-30.5.4).
4497
+
4498
+ // The generated-tools runtime (fetch + short-timeout/negative-TTL cache + generic
4499
+ // dispatch) lives in lib/generated-tools.js so its caching/TTL/negative-cache and
4500
+ // error-classification edge cases are exercised DIRECTLY by the test suite rather
4501
+ // than a hand-copied mirror. We wire the SHIPPED `apiCall` + static tool names +
4502
+ // `toContent` into it here. The process key (LOOPCTL_AGENT_KEY) is read per-call via
4503
+ // a getter so it rides the SAME auth + witness/STH path as every static read tool
4504
+ // (AC-30.5.4), and the static-name set powers the defense-in-depth drop of any spec
4505
+ // that isn't `cr_`-prefixed or that collides with a built-in tool name.
4506
+ const generatedToolsRuntime = createGeneratedToolsRuntime({
4507
+ apiCall,
4508
+ agentKey: () => process.env.LOOPCTL_AGENT_KEY,
4509
+ staticToolNames: new Set(TOOLS.map((t) => t.name)),
4510
+ toContent,
4511
+ });
4512
+
4513
+ const { fetchGeneratedTools, callGeneratedTool } = generatedToolsRuntime;
4514
+
4397
4515
  // ---------------------------------------------------------------------------
4398
4516
  // MCP Server
4399
4517
  // ---------------------------------------------------------------------------
@@ -4408,9 +4526,13 @@ const server = new Server(
4408
4526
  }
4409
4527
  );
4410
4528
 
4411
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
4412
- tools: TOOLS,
4413
- }));
4529
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
4530
+ // Static hand-maintained tools PLUS the calling tenant's per-tenant generated
4531
+ // Context Retriever tools (US-30.5). fetchGeneratedTools degrades to the static
4532
+ // tools (returns []/cache) on any fetch failure, so listing never errors.
4533
+ const generated = await fetchGeneratedTools();
4534
+ return { tools: [...TOOLS, ...generated] };
4535
+ });
4414
4536
 
4415
4537
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4416
4538
  const { name, arguments: args } = request.params;
@@ -4544,6 +4666,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4544
4666
  case "knowledge_create":
4545
4667
  return await knowledgeCreate(args);
4546
4668
 
4669
+ case "knowledge_update":
4670
+ return await knowledgeUpdate(args);
4671
+
4547
4672
  // Agent Memory Tools
4548
4673
  case "memory_remember":
4549
4674
  return await memoryRemember(args);
@@ -4677,6 +4802,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4677
4802
  return await getAcceptanceCriteria(args);
4678
4803
 
4679
4804
  default:
4805
+ // Per-tenant generated Context Retriever tools (US-30.5) are not in the
4806
+ // static switch — dispatch any unknown `cr_`-prefixed name generically to
4807
+ // the /retrieve/:entity executor. Static tools are handled above, so this
4808
+ // never regresses them (AC-30.5.2).
4809
+ if (typeof name === "string" && name.startsWith(GENERATED_TOOL_PREFIX)) {
4810
+ return await callGeneratedTool(name, args);
4811
+ }
4680
4812
  throw new Error(`Unknown tool: ${name}`);
4681
4813
  }
4682
4814
  });
@@ -0,0 +1,209 @@
1
+ // Per-tenant generated Context Retriever tool runtime (US-30.5).
2
+ //
3
+ // index.js is a stdio entry point with top-level await (importing it boots the MCP
4
+ // server), so its handlers can't be imported by tests. This module extracts the
5
+ // generated-tools fetch / cache / dispatch runtime — the logic with real caching,
6
+ // TTL, negative-cache and error-classification edge cases — into a side-effect-free
7
+ // factory (like lib/http-helpers.js). index.js wires the SHIPPED `apiCall`, static
8
+ // tool names, and `toContent` into `createGeneratedToolsRuntime`, and the test suite
9
+ // exercises the SAME code, so a regression fails CI instead of silently passing
10
+ // against a hand-copied mirror.
11
+
12
+ import {
13
+ retrieveToolsPath,
14
+ retrieveEntityPath,
15
+ specToMcpTool,
16
+ buildRetrieveBody,
17
+ } from "./http-helpers.js";
18
+
19
+ export const GENERATED_TOOL_PREFIX = "cr_";
20
+
21
+ // ListTools is an init-time, agent-blocking call (Claude Code blocks on it during
22
+ // connection setup). So the generated-tools fetch uses BOTH a SHORT timeout and a
23
+ // NEGATIVE TTL: during any Context-Retriever backend slowness/outage the listing
24
+ // degrades to the static tool surface quickly, and a failed fetch is cached for a
25
+ // short window so subsequent ListTools calls do NOT each re-issue a blocking fetch
26
+ // before degrading (us_30.5.json technical_notes: "keep it cached/short-timeout and
27
+ // degrade to static on failure").
28
+ export const GENERATED_TOOLS_CACHE_TTL_MS = 30_000; // positive: last fetch succeeded
29
+ export const GENERATED_TOOLS_NEGATIVE_TTL_MS = 5_000; // negative: last fetch failed
30
+ export const GENERATED_TOOLS_FETCH_TIMEOUT_MS = 5_000; // short per-fetch AbortSignal budget
31
+
32
+ /**
33
+ * Build a generated-tools runtime bound to injected dependencies. State (cache +
34
+ * name->metadata map) is private to the returned closure.
35
+ *
36
+ * @param {{
37
+ * apiCall: (method: string, path: string, body: unknown, key: (string|undefined), opts?: object) => Promise<any>,
38
+ * agentKey: () => (string|undefined),
39
+ * staticToolNames?: Set<string>,
40
+ * toContent: (result: any) => object,
41
+ * now?: () => number,
42
+ * log?: (msg: string) => void,
43
+ * cacheTtlMs?: number,
44
+ * negativeTtlMs?: number,
45
+ * fetchTimeoutMs?: number,
46
+ * prefix?: string,
47
+ * }} deps
48
+ */
49
+ export function createGeneratedToolsRuntime({
50
+ apiCall,
51
+ agentKey,
52
+ staticToolNames = new Set(),
53
+ toContent,
54
+ now = () => Date.now(),
55
+ log = (msg) => console.error(msg),
56
+ cacheTtlMs = GENERATED_TOOLS_CACHE_TTL_MS,
57
+ negativeTtlMs = GENERATED_TOOLS_NEGATIVE_TTL_MS,
58
+ fetchTimeoutMs = GENERATED_TOOLS_FETCH_TIMEOUT_MS,
59
+ prefix = GENERATED_TOOL_PREFIX,
60
+ }) {
61
+ // `ok` records the LAST fetch outcome so (a) the TTL guard applies the negative
62
+ // TTL after a failure and (b) an unresolved CallTool can tell "temporarily
63
+ // unavailable" from "genuinely unknown to this tenant". `tools`/`metadata` retain
64
+ // the last KNOWN-GOOD listing so a failure degrades to it, never poisons it.
65
+ let cache = { tools: [], fetchedAt: 0, ok: true };
66
+ let metadataByName = new Map();
67
+
68
+ function cacheFailure(at, message) {
69
+ log(message);
70
+ // Negative caching: stamp fetchedAt on failure (ok:false) WITHOUT touching the
71
+ // retained tools/metadata, so subsequent calls within the negative-TTL window
72
+ // return the degraded static surface instead of re-issuing a blocking fetch.
73
+ cache = { tools: cache.tools, fetchedAt: at, ok: false };
74
+ return cache.tools;
75
+ }
76
+
77
+ /**
78
+ * Fetch the calling tenant's generated tool specs from GET /retrieve/tools and
79
+ * map them to the MCP `{ name, description, inputSchema }` shape, (re)populating
80
+ * the name->metadata map. On ANY failure this logs, negative-caches, and returns
81
+ * the last-known (possibly empty) tools so ListTools degrades to static rather
82
+ * than erroring (AC-30.5.1). Honors the positive/negative TTL unless `force`.
83
+ *
84
+ * @param {{ force?: boolean }} [opts]
85
+ * @returns {Promise<Array<{ name: string, description: string, inputSchema: object }>>}
86
+ */
87
+ async function fetchGeneratedTools({ force = false } = {}) {
88
+ const at = now();
89
+ if (!force && cache.fetchedAt !== 0) {
90
+ const ttl = cache.ok ? cacheTtlMs : negativeTtlMs;
91
+ if (at - cache.fetchedAt < ttl) return cache.tools;
92
+ }
93
+
94
+ let result;
95
+ try {
96
+ result = await apiCall("GET", retrieveToolsPath(), null, agentKey(), {
97
+ timeoutMs: fetchTimeoutMs,
98
+ });
99
+ } catch (err) {
100
+ return cacheFailure(
101
+ at,
102
+ `loopctl: failed to fetch generated tools, degrading to static tools: ${err.message}`,
103
+ );
104
+ }
105
+
106
+ if (result && result.error === true) {
107
+ const detail = typeof result.body === "string" ? result.body : JSON.stringify(result.body);
108
+ return cacheFailure(
109
+ at,
110
+ `loopctl: failed to fetch generated tools (HTTP ${result.status}), degrading to static tools: ${detail}`,
111
+ );
112
+ }
113
+
114
+ const specs = result && Array.isArray(result.data) ? result.data : [];
115
+ const tools = [];
116
+ const metadata = new Map();
117
+ for (const spec of specs) {
118
+ const tool = specToMcpTool(spec);
119
+ if (!tool) continue;
120
+ // Defense-in-depth (US-30.5 review): the `cr_` prefix is a security-relevant
121
+ // property the CallTool dispatcher relies on and the server is trusted to
122
+ // enforce. Re-check it HERE so a non-cr_ spec (which would be listed but
123
+ // UNCALLABLE) or one colliding with a STATIC tool name (which would shadow a
124
+ // trusted built-in's description/inputSchema under tenant-controlled text —
125
+ // a tool-confusion/description-spoofing vector) can never be listed or
126
+ // registered under a trusted identity.
127
+ if (!tool.name.startsWith(prefix) || staticToolNames.has(tool.name)) {
128
+ log(
129
+ `loopctl: dropping generated tool spec with invalid name "${tool.name}" ` +
130
+ `(must be ${prefix}-prefixed and not collide with a static tool)`,
131
+ );
132
+ continue;
133
+ }
134
+ tools.push(tool);
135
+ if (spec.metadata && typeof spec.metadata === "object") {
136
+ metadata.set(tool.name, spec.metadata);
137
+ }
138
+ }
139
+
140
+ metadataByName = metadata;
141
+ cache = { tools, fetchedAt: at, ok: true };
142
+ return tools;
143
+ }
144
+
145
+ /**
146
+ * Resolve a generated tool name to its STRUCTURED dispatch metadata. On a miss
147
+ * (name not in the current map) force ONE fresh fetch — bypassing the warm-TTL
148
+ * short-circuit — so a tool created server-side since the last listing resolves
149
+ * instead of being reported as authoritatively unknown.
150
+ *
151
+ * @param {string} name
152
+ * @returns {Promise<object|null>}
153
+ */
154
+ async function resolveGeneratedToolMetadata(name) {
155
+ if (metadataByName.has(name)) return metadataByName.get(name);
156
+ await fetchGeneratedTools({ force: true });
157
+ return metadataByName.get(name) || null;
158
+ }
159
+
160
+ /**
161
+ * Generic dispatcher for a per-tenant generated tool. Resolves (entity, field,
162
+ * operation) from STRUCTURED metadata (never by name-splitting — AC-30.5.2),
163
+ * builds the US-30.4 body, and POSTs to /retrieve/:entity via the SAME `apiCall`
164
+ * path as static reads (same auth + witness/STH — AC-30.5.4). An unresolvable
165
+ * name after a forced refetch distinguishes a TRANSIENT backend failure (503,
166
+ * retryable) from a name genuinely UNKNOWN to this tenant (404).
167
+ *
168
+ * @param {string} name
169
+ * @param {object} [args]
170
+ */
171
+ async function callGeneratedTool(name, args = {}) {
172
+ const metadata = await resolveGeneratedToolMetadata(name);
173
+ if (metadata && typeof metadata.entity === "string" && typeof metadata.operation === "string") {
174
+ const result = await apiCall(
175
+ "POST",
176
+ retrieveEntityPath(metadata.entity),
177
+ buildRetrieveBody(metadata, args),
178
+ agentKey(),
179
+ );
180
+ return toContent(result);
181
+ }
182
+
183
+ // Unresolved. If the forced refetch above FAILED (cache.ok === false), this is a
184
+ // transient backend blip, not a definitive "unknown to this tenant" — say so, so
185
+ // an agent retries instead of abandoning a valid tool.
186
+ if (!metadata && !cache.ok) {
187
+ return toContent({
188
+ error: true,
189
+ status: 503,
190
+ body:
191
+ `Generated tool "${name}" could not be resolved: the Context Retriever ` +
192
+ `tool listing is temporarily unavailable (the tools fetch failed). Retry shortly.`,
193
+ });
194
+ }
195
+ return toContent({
196
+ error: true,
197
+ status: 404,
198
+ body: `Unknown tool: ${name}. No generated tool by that name is available to this tenant.`,
199
+ });
200
+ }
201
+
202
+ return {
203
+ fetchGeneratedTools,
204
+ resolveGeneratedToolMetadata,
205
+ callGeneratedTool,
206
+ // Introspection seam for tests (not used by the server).
207
+ cacheState: () => ({ ...cache }),
208
+ };
209
+ }
@@ -86,6 +86,98 @@ export function memoryPath({ limit, offset, include_superseded, all_subjects } =
86
86
  ])}`;
87
87
  }
88
88
 
89
+ /**
90
+ * Path for the Context Retriever generated-tool specs (US-30.5, US-30.4).
91
+ * The literal `/retrieve/tools` route is fixed and carries no query params — the
92
+ * calling tenant is resolved SERVER-SIDE from the API key, never from the client,
93
+ * so there is nothing for the client to parameterize here (AC-30.5.3/AC-30.5.4).
94
+ *
95
+ * @returns {string}
96
+ */
97
+ export function retrieveToolsPath() {
98
+ return "/api/v1/retrieve/tools";
99
+ }
100
+
101
+ /**
102
+ * Path for executing a generated tool against a single entity (US-30.5, US-30.4).
103
+ * `entity` comes from the STRUCTURED metadata on a generated tool spec (never by
104
+ * splitting the tool name — entity/field names contain underscores), so it is
105
+ * encoded defensively.
106
+ *
107
+ * @param {string} entity
108
+ * @returns {string}
109
+ */
110
+ export function retrieveEntityPath(entity) {
111
+ return `/api/v1/retrieve/${encodeURIComponent(entity)}`;
112
+ }
113
+
114
+ /**
115
+ * Map a raw generated-tool spec (as returned by GET /api/v1/retrieve/tools —
116
+ * `{ name, description, input_schema, metadata }`) into the MCP tool shape the
117
+ * ListTools handler must return (`{ name, description, inputSchema }`). The server
118
+ * emits snake_case `input_schema`; MCP expects camelCase `inputSchema`.
119
+ *
120
+ * Returns null for a malformed spec (no string `name`) so a single bad entry is
121
+ * skipped rather than corrupting the whole listing.
122
+ *
123
+ * @param {{ name?: unknown, description?: unknown, input_schema?: unknown }} spec
124
+ * @returns {{ name: string, description: string, inputSchema: object } | null}
125
+ */
126
+ export function specToMcpTool(spec) {
127
+ if (!spec || typeof spec.name !== "string") return null;
128
+ return {
129
+ name: spec.name,
130
+ description: typeof spec.description === "string" ? spec.description : "",
131
+ inputSchema:
132
+ spec.input_schema && typeof spec.input_schema === "object"
133
+ ? spec.input_schema
134
+ : { type: "object", properties: {} },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Build the POST /api/v1/retrieve/:entity request body for a generated-tool call
140
+ * from the tool's STRUCTURED dispatch metadata (US-30.2 — `{ entity, field,
141
+ * operation }`) plus the caller's runtime `args`. The (entity, field, operation)
142
+ * come from metadata, NOT from splitting the tool name (AC-30.5.2), so entity and
143
+ * field names containing underscores dispatch unambiguously.
144
+ *
145
+ * The body shape matches the US-30.4 controller (`RetrieveRequest`): `op` selects
146
+ * the operation; `filter` carries `field` + `value`, `search` carries `query`;
147
+ * `limit`/`offset` pagination pass through when present. Nullish args are omitted
148
+ * so unset params never override server defaults.
149
+ *
150
+ * Filter-value sourcing (US-30.5 fix): the US-30.2 ToolGenerator emits the filter
151
+ * tool's input_schema with the value argument under the FIELD-NAME key (e.g.
152
+ * `{status}`, `required: ["status"]`) — there is NO `value` property. A
153
+ * schema-compliant agent therefore calls `cr_filter_project_by_status({status:
154
+ * "active"})`. So read the value from the field-named arg FIRST, falling back to a
155
+ * literal `value` arg (the shape the controller also accepts) for tolerance.
156
+ * Reading only `args.value` would drop every real agent-supplied filter value and
157
+ * dispatch an empty filter that silently returns zero rows.
158
+ *
159
+ * @param {{ entity: string, field?: string|null, operation: string }} metadata
160
+ * @param {Record<string, unknown>} [args]
161
+ * @returns {object}
162
+ */
163
+ export function buildRetrieveBody(metadata, args = {}) {
164
+ const body = { op: metadata.operation };
165
+
166
+ if (metadata.operation === "filter") {
167
+ body.field = metadata.field;
168
+ const fieldArg = metadata.field != null ? args[metadata.field] : undefined;
169
+ const filterValue = fieldArg !== undefined ? fieldArg : args.value;
170
+ if (filterValue !== undefined) body.value = filterValue;
171
+ } else if (metadata.operation === "search") {
172
+ if (args.query !== undefined) body.query = args.query;
173
+ }
174
+
175
+ if (args.limit != null) body.limit = args.limit;
176
+ if (args.offset != null) body.offset = args.offset;
177
+
178
+ return body;
179
+ }
180
+
89
181
  /**
90
182
  * Defensively parse the raw text body of a JSON-content-type HTTP response
91
183
  * (#249, mcp-03).
@@ -186,6 +186,7 @@ export function bootstrapRetrySth(status, currentSthHeader) {
186
186
  * getuid?: () => number,
187
187
  * pid?: number,
188
188
  * timeoutMs?: number,
189
+ * makeTimeoutSignal?: (ms: number) => (AbortSignal|undefined),
189
190
  * }} [deps]
190
191
  */
191
192
  export function createWitnessClient({
@@ -195,6 +196,10 @@ export function createWitnessClient({
195
196
  getuid,
196
197
  pid,
197
198
  timeoutMs = 30_000,
199
+ // Injectable so tests can observe the EFFECTIVE per-attempt timeout (an
200
+ // AbortSignal from AbortSignal.timeout does not expose its ms). Production uses
201
+ // the real timer.
202
+ makeTimeoutSignal = (ms) => AbortSignal.timeout(ms),
198
203
  } = {}) {
199
204
  // Load any persisted STH so a FRESH process sends a real header on request #1
200
205
  // and skips the bootstrap-grace 412 entirely.
@@ -213,10 +218,13 @@ export function createWitnessClient({
213
218
  if (statePath && fs) persistSth(statePath, sth, { fs, pid });
214
219
  }
215
220
 
216
- async function attempt({ url, method, headers, serializedBody }) {
221
+ async function attempt({ url, method, headers, serializedBody, timeoutMs: reqTimeoutMs }) {
217
222
  // Witness protocol: echo the cached STH, or (never seen one) request a
218
223
  // one-time bootstrap. A fresh AbortSignal per attempt so the retry gets its
219
- // own timeout budget.
224
+ // own timeout budget. A per-request `timeoutMs` (e.g. the SHORT budget the
225
+ // init-time ListTools generated-tools fetch uses so a backend blip degrades to
226
+ // static quickly instead of blocking connection setup for the full 30s)
227
+ // overrides the client default.
220
228
  const withWitness = { ...headers };
221
229
  if (lastKnownSTH) withWitness["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
222
230
  else withWitness["X-Loopctl-STH-Bootstrap"] = "true";
@@ -224,7 +232,7 @@ export function createWitnessClient({
224
232
  const options = {
225
233
  method,
226
234
  headers: withWitness,
227
- signal: AbortSignal.timeout(timeoutMs),
235
+ signal: makeTimeoutSignal(reqTimeoutMs ?? timeoutMs),
228
236
  };
229
237
  if (serializedBody !== undefined) options.body = serializedBody;
230
238
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.38.0",
3
+ "version": "2.40.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",