loopctl-mcp-server 2.39.0 → 2.41.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 +30 -1
- package/index.js +221 -7
- package/lib/generated-tools.js +209 -0
- package/lib/http-helpers.js +92 -0
- package/lib/witness-sth.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP (Model Context Protocol) server for [loopctl](https://loopctl.com) -- structural trust for AI development loops.
|
|
4
4
|
|
|
5
|
-
Wraps the loopctl REST API into
|
|
5
|
+
Wraps the loopctl REST API into 76 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -123,6 +123,10 @@ autonomous agent) can self-remediate without a human. Full agent-tenant lifecycl
|
|
|
123
123
|
|
|
124
124
|
## Tools (84)
|
|
125
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.
|
|
129
|
+
|
|
126
130
|
### Project Tools
|
|
127
131
|
|
|
128
132
|
| Tool | Description |
|
|
@@ -194,6 +198,8 @@ autonomous agent) can self-remediate without a human. Full agent-tenant lifecycl
|
|
|
194
198
|
| How many articles? (counts only) | `knowledge_stats` |
|
|
195
199
|
| Browse the catalog (lightweight id/title/category) | `knowledge_index` |
|
|
196
200
|
| Find by topic / relevance (ranked, published-only, lags writes) | `knowledge_search` |
|
|
201
|
+
| One trustworthy answer + provenance (curated-first, retrieval fallback) | `knowledge_hybrid_search` |
|
|
202
|
+
| Survey a topic cheaply (capped stubs) then open only what you need | `knowledge_progressive_index` + `knowledge_progressive_drill` |
|
|
197
203
|
| Enumerate / dedup / repair, or "does X exist?" (full fields, lag-free, all-status) | `knowledge_list` |
|
|
198
204
|
|
|
199
205
|
| Tool | Description |
|
|
@@ -203,6 +209,9 @@ autonomous agent) can self-remediate without a human. Full agent-tenant lifecycl
|
|
|
203
209
|
| `knowledge_count` | Count articles matching filters **without returning rows** within agent's visible set. Agent callers see only their own and `shared` articles. Same filters as `knowledge_list` (`category`, `status`, `tags`, `match`, `source_type`, `source_id`, `idempotency_key`, `project_id`). With `tags`+`match: all` (+`status`) → "how many published articles tagged both X and Y (that I can see)". Returns `{ count }`. |
|
|
204
210
|
| `knowledge_facets` | Count articles grouped by **distinct tag** within agent's visible set, no rows. Agent callers see only their own and `shared` articles. `tag_prefix` (e.g. `book-`) gives the distinct count of a tag family plus per-member totals. Returns `{ data: { tag: count }, meta: { distinct_count } }`. Optional: `category`, `status`, `tags`, `match`, `project_id`, `limit`. |
|
|
205
211
|
| `knowledge_search` | Search the knowledge wiki by topic (keyword, semantic, or combined). Returns snippets. **Ranked, published-only, and LAGS writes by minutes while embeddings index — do NOT use for existence/idempotency/dedup checks (a fresh write false-negatives); use `knowledge_list` for that.** `q` is optional when `tags`/`category` are supplied — that **list mode** returns the complete filtered set paginated via `offset`/`limit` over `meta.total_count`. `meta.total_count` is mode-dependent — read `meta.total_count_scope` (`keyword_matches`/`ranked_corpus`/`merged_candidates`/`filtered_set`) and don't use a relevance-mode count to size the wiki (use `knowledge_list` or `knowledge_stats`). Optional: `project_id`, `story_id` for attribution. |
|
|
212
|
+
| `knowledge_hybrid_search` | Resolve a topic to a **single best answer with provenance** (US-31.4). Runs combined keyword+semantic over the full ranked pool, then decides whether a governed **curated** source actually answers. `meta.provenance` is `curated` (trust it — the canonical article is first in `data`, `meta.curated_article_id` points at it) or `retrieved` (best fuzzy match, `curated_article_id` null); `meta.confidence` is the winner's absolute score. Prefer over `knowledge_search` when you want one trustworthy answer, not a list to triage. Degrades to keyword-only like `knowledge_search` when embeddings are unavailable. Required: `query`. Optional: `project_id`, `category`, `tags`, `match`, `limit`, `offset`. |
|
|
213
|
+
| `knowledge_progressive_index` | Progressive disclosure — a **cheap, capped index** of what's relevant to a topic (compact stubs: `id`/`title`/`category`/`summary`, **no bodies**), curated-preferred and hub-enriched, capped at top-K (`meta.truncated` when the pool exceeded it). Survey a topic without flooding context, then open only what you need via `knowledge_progressive_drill`. Required: `topic`. Optional: `category`, `limit`. |
|
|
214
|
+
| `knowledge_progressive_drill` | Open one stub from `knowledge_progressive_index` — returns the **full article body** for the given id, scope-enforced. Resolves both tenant-owned articles and published system canonicals (the same set the index surfaces). Required: `article_id`. |
|
|
206
215
|
| `knowledge_list` | List articles (`id`, `title`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, timestamps), filtered + paginated. **Body-less summary by default** (safe to page up to `limit=1000`); pass `include_body: true` to also return `body`, in which case the page is bounded by a ~5 MB byte budget — continue via `meta.next_offset` while `meta.has_more`. **Lag-free, all-status** read of the DB of record — unlike `knowledge_search` (ranked, published-only, lags writes) and `knowledge_index` (id/title/category only). The right tool to enumerate/dedup/repair and for idempotency/existence checks: filter by `tags`, `source_type`+`source_id`, or `idempotency_key` and read `meta.total_count` (exact). Single full body → `knowledge_get`; relevant bodies → `knowledge_context`; bulk dump → `knowledge_export`. Optional: `project_id`, `category`, `status`, `tags`, `source_type`, `source_id`, `idempotency_key`, `offset`, `limit`, `include_body`. |
|
|
207
216
|
| `knowledge_get` | Get full article content by ID. Use after search to read an article in detail. Optional: `project_id`, `story_id` for attribution. |
|
|
208
217
|
| `knowledge_context` | Get relevance-and-recency-ranked full articles for a task query. Best knowledge for your current context. **Agent-memory scoping**: `memory_types` (comma-separated, OR — observation/finding/summary/decision/question/task), `agents` (comma-separated agent_ids, OR), `conversation_id` (exact) filter on article `metadata` (JSONB `@>`). Optional: `project_id`, `story_id` for attribution, `limit`, `recency_weight`. |
|
|
@@ -280,6 +289,26 @@ it is enforced server-side and a no-op for a non-superadmin key — see below.)
|
|
|
280
289
|
| `list_routes` | List all available API routes on the loopctl server. |
|
|
281
290
|
| `get_system_articles` | List or fetch system-scoped (global, cross-tenant) wiki articles. Public — no auth required. Optional: `slug` (fetch one), `category`. |
|
|
282
291
|
|
|
292
|
+
### Dynamic per-tenant Context Retriever tools (Epic 30)
|
|
293
|
+
|
|
294
|
+
Beyond the static tools above, the server appends **per-tenant generated tools** to
|
|
295
|
+
`ListTools` at runtime. When your tenant declares an **entity** (`POST
|
|
296
|
+
/api/v1/entities`), loopctl auto-generates governed query tools over that entity's
|
|
297
|
+
allowlisted columns, and this MCP server fetches them from `GET
|
|
298
|
+
/api/v1/retrieve/tools` and lists them alongside the static tools:
|
|
299
|
+
|
|
300
|
+
| Generated tool | What it does |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `cr_filter_<entity>_by_<field>` | Filter that entity's records where `<field>` equals a value. Params: the field value + `limit`/`offset`. |
|
|
303
|
+
| `cr_search_<entity>` | Full-text search across that entity's searchable text fields. Params: `query` + `limit`/`offset`. |
|
|
304
|
+
|
|
305
|
+
These are **tenant-scoped**: the listing reflects only the tenant of the process
|
|
306
|
+
key (`LOOPCTL_AGENT_KEY`), resolved server-side — you never pass a tenant. A
|
|
307
|
+
`cr_`-prefixed call is dispatched generically to `POST /api/v1/retrieve/:entity`
|
|
308
|
+
through the same authenticated + witness/STH path as every static read tool. If the
|
|
309
|
+
`/retrieve/tools` fetch fails, listing degrades to the static tools (never errors).
|
|
310
|
+
The generated-tool count per tenant is bounded by the per-tenant entity cap.
|
|
311
|
+
|
|
283
312
|
### Dispatch & Chain of Custody (v2) Tools
|
|
284
313
|
|
|
285
314
|
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:
|
|
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
|
-
|
|
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}` };
|
|
@@ -952,6 +962,52 @@ async function knowledgeSearch({ q, project_id, story_id, category, tags, match,
|
|
|
952
962
|
return withRemediationNotice(result);
|
|
953
963
|
}
|
|
954
964
|
|
|
965
|
+
async function knowledgeHybridSearch({ query, project_id, category, tags, match, limit, offset }) {
|
|
966
|
+
const bodyPayload = { query };
|
|
967
|
+
if (project_id) bodyPayload.project_id = project_id;
|
|
968
|
+
if (category) bodyPayload.category = category;
|
|
969
|
+
if (tags) bodyPayload.tags = tags;
|
|
970
|
+
if (match) bodyPayload.match = match;
|
|
971
|
+
if (limit != null) bodyPayload.limit = limit;
|
|
972
|
+
if (offset != null) bodyPayload.offset = offset;
|
|
973
|
+
|
|
974
|
+
const result = await apiCall(
|
|
975
|
+
"POST",
|
|
976
|
+
"/api/v1/knowledge/hybrid_search",
|
|
977
|
+
bodyPayload,
|
|
978
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
979
|
+
);
|
|
980
|
+
// meta.provenance ("curated" | "retrieved") is the load-bearing field and rides
|
|
981
|
+
// through toContent verbatim. Same missing-embedding-key remediation surfacing as
|
|
982
|
+
// knowledge_search, since hybrid runs the combined pool underneath.
|
|
983
|
+
return withRemediationNotice(result);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async function knowledgeProgressiveIndex({ topic, category, limit }) {
|
|
987
|
+
const params = new URLSearchParams();
|
|
988
|
+
if (topic != null) params.set("topic", topic);
|
|
989
|
+
if (category) params.set("category", category);
|
|
990
|
+
if (limit != null) params.set("limit", String(limit));
|
|
991
|
+
|
|
992
|
+
const result = await apiCall(
|
|
993
|
+
"GET",
|
|
994
|
+
`/api/v1/knowledge/progressive_index?${params}`,
|
|
995
|
+
null,
|
|
996
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
997
|
+
);
|
|
998
|
+
return toContent(result);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function knowledgeProgressiveDrill({ article_id }) {
|
|
1002
|
+
const result = await apiCall(
|
|
1003
|
+
"GET",
|
|
1004
|
+
`/api/v1/knowledge/progressive/${article_id}`,
|
|
1005
|
+
null,
|
|
1006
|
+
process.env.LOOPCTL_AGENT_KEY,
|
|
1007
|
+
);
|
|
1008
|
+
return toContent(result);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
955
1011
|
async function knowledgeList({
|
|
956
1012
|
project_id,
|
|
957
1013
|
category,
|
|
@@ -2995,6 +3051,109 @@ const TOOLS = [
|
|
|
2995
3051
|
required: [],
|
|
2996
3052
|
},
|
|
2997
3053
|
},
|
|
3054
|
+
{
|
|
3055
|
+
name: "knowledge_hybrid_search",
|
|
3056
|
+
description:
|
|
3057
|
+
"Resolve a topic to a SINGLE best answer WITH provenance — the hybrid " +
|
|
3058
|
+
"retrieval entrypoint. Runs the combined keyword+semantic search over the full " +
|
|
3059
|
+
"ranked pool, then decides whether a governed CURATED source actually answers. " +
|
|
3060
|
+
"The verdict is meta.provenance: 'curated' means a curated, canonical article " +
|
|
3061
|
+
"answers — TRUST IT (it is guaranteed first in the results and meta.curated_article_id " +
|
|
3062
|
+
"points at it); 'retrieved' means no curated source cleared the bar, so the answer " +
|
|
3063
|
+
"is the best semantic/keyword match (a fuzzy fallback — verify before relying on it, " +
|
|
3064
|
+
"meta.curated_article_id is null). meta.confidence is the winning candidate's absolute " +
|
|
3065
|
+
"score for its provenance class. Prefer this over knowledge_search when you want ONE " +
|
|
3066
|
+
"trustworthy answer plus its provenance rather than a ranked list to triage yourself; " +
|
|
3067
|
+
"use knowledge_search when you want to browse/enumerate matches. Additive — existing " +
|
|
3068
|
+
"knowledge tools are unchanged. If semantic ranking is unavailable it degrades to " +
|
|
3069
|
+
"keyword-only (meta.fallback/fallback_reason), same as knowledge_search.",
|
|
3070
|
+
inputSchema: {
|
|
3071
|
+
type: "object",
|
|
3072
|
+
properties: {
|
|
3073
|
+
query: {
|
|
3074
|
+
type: "string",
|
|
3075
|
+
description: "The topic/question to resolve (max 500 characters). Required.",
|
|
3076
|
+
},
|
|
3077
|
+
project_id: {
|
|
3078
|
+
type: "string",
|
|
3079
|
+
format: "uuid",
|
|
3080
|
+
description: "Optional: scope to a project UUID.",
|
|
3081
|
+
},
|
|
3082
|
+
category: {
|
|
3083
|
+
type: "string",
|
|
3084
|
+
description: "Optional: filter by category.",
|
|
3085
|
+
},
|
|
3086
|
+
tags: {
|
|
3087
|
+
type: "string",
|
|
3088
|
+
description: "Optional: comma-separated tags to filter by.",
|
|
3089
|
+
},
|
|
3090
|
+
match: {
|
|
3091
|
+
type: "string",
|
|
3092
|
+
enum: ["any", "all"],
|
|
3093
|
+
description: "Optional: tag match mode — 'any' (default, OR) or 'all' (AND, every tag).",
|
|
3094
|
+
},
|
|
3095
|
+
limit: {
|
|
3096
|
+
type: "integer",
|
|
3097
|
+
description: "Optional: max results to return (default 10).",
|
|
3098
|
+
},
|
|
3099
|
+
offset: {
|
|
3100
|
+
type: "integer",
|
|
3101
|
+
description: "Optional: results to skip for pagination (default 0).",
|
|
3102
|
+
},
|
|
3103
|
+
},
|
|
3104
|
+
required: ["query"],
|
|
3105
|
+
},
|
|
3106
|
+
},
|
|
3107
|
+
{
|
|
3108
|
+
name: "knowledge_progressive_index",
|
|
3109
|
+
description:
|
|
3110
|
+
"Progressive disclosure — get a CHEAP, capped index of what's relevant to a topic " +
|
|
3111
|
+
"(compact stubs: id/title/category/summary, NO bodies), then open only what you need " +
|
|
3112
|
+
"with knowledge_progressive_drill. Curated sources are preferred and hub-linked " +
|
|
3113
|
+
"neighbors are enriched in; results are capped at a configured top-K (meta.truncated " +
|
|
3114
|
+
"is true when the candidate pool exceeded it). Use this to survey a topic without " +
|
|
3115
|
+
"flooding your context with full article bodies — it's the index half of the hybrid " +
|
|
3116
|
+
"interface. Follow up on a stub's id with knowledge_progressive_drill to read the body.",
|
|
3117
|
+
inputSchema: {
|
|
3118
|
+
type: "object",
|
|
3119
|
+
properties: {
|
|
3120
|
+
topic: {
|
|
3121
|
+
type: "string",
|
|
3122
|
+
description: "The topic to index (max 500 characters). Required.",
|
|
3123
|
+
},
|
|
3124
|
+
category: {
|
|
3125
|
+
type: "string",
|
|
3126
|
+
description: "Optional: filter by category.",
|
|
3127
|
+
},
|
|
3128
|
+
limit: {
|
|
3129
|
+
type: "integer",
|
|
3130
|
+
description: "Optional: top-K override (clamped to the configured cap).",
|
|
3131
|
+
},
|
|
3132
|
+
},
|
|
3133
|
+
required: ["topic"],
|
|
3134
|
+
},
|
|
3135
|
+
},
|
|
3136
|
+
{
|
|
3137
|
+
name: "knowledge_progressive_drill",
|
|
3138
|
+
description:
|
|
3139
|
+
"Drill into one stub from knowledge_progressive_index — returns the FULL article " +
|
|
3140
|
+
"body for the given id, scope-enforced. Resolves both tenant-owned articles and " +
|
|
3141
|
+
"published system canonicals (the same set the index surfaces). This is the drill " +
|
|
3142
|
+
"half of progressive disclosure: index cheaply, then open only the article(s) you " +
|
|
3143
|
+
"need. (knowledge_get works for tenant articles too; use this to also reach the " +
|
|
3144
|
+
"system canonicals the progressive index can surface.)",
|
|
3145
|
+
inputSchema: {
|
|
3146
|
+
type: "object",
|
|
3147
|
+
properties: {
|
|
3148
|
+
article_id: {
|
|
3149
|
+
type: "string",
|
|
3150
|
+
format: "uuid",
|
|
3151
|
+
description: "The UUID of the article to open (from a progressive index stub).",
|
|
3152
|
+
},
|
|
3153
|
+
},
|
|
3154
|
+
required: ["article_id"],
|
|
3155
|
+
},
|
|
3156
|
+
},
|
|
2998
3157
|
{
|
|
2999
3158
|
name: "knowledge_get",
|
|
3000
3159
|
description:
|
|
@@ -4467,6 +4626,41 @@ const TOOLS = [
|
|
|
4467
4626
|
},
|
|
4468
4627
|
];
|
|
4469
4628
|
|
|
4629
|
+
// ---------------------------------------------------------------------------
|
|
4630
|
+
// Context Retriever — dynamic per-tenant generated tools (US-30.5)
|
|
4631
|
+
// ---------------------------------------------------------------------------
|
|
4632
|
+
//
|
|
4633
|
+
// Epic 30 lets a tenant declare ENTITIES whose schema auto-generates agent tools
|
|
4634
|
+
// (`cr_filter_<entity>_by_<field>`, `cr_search_<entity>`) over governed loopctl
|
|
4635
|
+
// data. Those specs are generated SERVER-SIDE per tenant, so the MCP server can't
|
|
4636
|
+
// hard-code them in the static TOOLS array — it must fetch them at ListTools time
|
|
4637
|
+
// and append them, then dispatch any unknown `cr_`-prefixed CallTool to one
|
|
4638
|
+
// generic executor endpoint.
|
|
4639
|
+
//
|
|
4640
|
+
// Trust model: the stdio MCP process is ONE-TENANT-PER-PROCESS — the process key
|
|
4641
|
+
// (LOOPCTL_AGENT_KEY) resolves to a tenant server-side, and GET /retrieve/tools
|
|
4642
|
+
// returns ONLY that tenant's generated specs. The client never picks a tenant, so
|
|
4643
|
+
// cross-tenant listing/calling is impossible by construction (AC-30.5.3). Both the
|
|
4644
|
+
// listing fetch and the generic call ride the SAME `apiCall` path as static read
|
|
4645
|
+
// tools, so they carry identical auth + witness/STH headers (AC-30.5.4).
|
|
4646
|
+
|
|
4647
|
+
// The generated-tools runtime (fetch + short-timeout/negative-TTL cache + generic
|
|
4648
|
+
// dispatch) lives in lib/generated-tools.js so its caching/TTL/negative-cache and
|
|
4649
|
+
// error-classification edge cases are exercised DIRECTLY by the test suite rather
|
|
4650
|
+
// than a hand-copied mirror. We wire the SHIPPED `apiCall` + static tool names +
|
|
4651
|
+
// `toContent` into it here. The process key (LOOPCTL_AGENT_KEY) is read per-call via
|
|
4652
|
+
// a getter so it rides the SAME auth + witness/STH path as every static read tool
|
|
4653
|
+
// (AC-30.5.4), and the static-name set powers the defense-in-depth drop of any spec
|
|
4654
|
+
// that isn't `cr_`-prefixed or that collides with a built-in tool name.
|
|
4655
|
+
const generatedToolsRuntime = createGeneratedToolsRuntime({
|
|
4656
|
+
apiCall,
|
|
4657
|
+
agentKey: () => process.env.LOOPCTL_AGENT_KEY,
|
|
4658
|
+
staticToolNames: new Set(TOOLS.map((t) => t.name)),
|
|
4659
|
+
toContent,
|
|
4660
|
+
});
|
|
4661
|
+
|
|
4662
|
+
const { fetchGeneratedTools, callGeneratedTool } = generatedToolsRuntime;
|
|
4663
|
+
|
|
4470
4664
|
// ---------------------------------------------------------------------------
|
|
4471
4665
|
// MCP Server
|
|
4472
4666
|
// ---------------------------------------------------------------------------
|
|
@@ -4481,9 +4675,13 @@ const server = new Server(
|
|
|
4481
4675
|
}
|
|
4482
4676
|
);
|
|
4483
4677
|
|
|
4484
|
-
server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
4485
|
-
tools
|
|
4486
|
-
|
|
4678
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
4679
|
+
// Static hand-maintained tools PLUS the calling tenant's per-tenant generated
|
|
4680
|
+
// Context Retriever tools (US-30.5). fetchGeneratedTools degrades to the static
|
|
4681
|
+
// tools (returns []/cache) on any fetch failure, so listing never errors.
|
|
4682
|
+
const generated = await fetchGeneratedTools();
|
|
4683
|
+
return { tools: [...TOOLS, ...generated] };
|
|
4684
|
+
});
|
|
4487
4685
|
|
|
4488
4686
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
4489
4687
|
const { name, arguments: args } = request.params;
|
|
@@ -4605,6 +4803,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4605
4803
|
case "knowledge_search":
|
|
4606
4804
|
return await knowledgeSearch(args);
|
|
4607
4805
|
|
|
4806
|
+
case "knowledge_hybrid_search":
|
|
4807
|
+
return await knowledgeHybridSearch(args);
|
|
4808
|
+
|
|
4809
|
+
case "knowledge_progressive_index":
|
|
4810
|
+
return await knowledgeProgressiveIndex(args);
|
|
4811
|
+
|
|
4812
|
+
case "knowledge_progressive_drill":
|
|
4813
|
+
return await knowledgeProgressiveDrill(args);
|
|
4814
|
+
|
|
4608
4815
|
case "knowledge_list":
|
|
4609
4816
|
return await knowledgeList(args);
|
|
4610
4817
|
|
|
@@ -4753,6 +4960,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4753
4960
|
return await getAcceptanceCriteria(args);
|
|
4754
4961
|
|
|
4755
4962
|
default:
|
|
4963
|
+
// Per-tenant generated Context Retriever tools (US-30.5) are not in the
|
|
4964
|
+
// static switch — dispatch any unknown `cr_`-prefixed name generically to
|
|
4965
|
+
// the /retrieve/:entity executor. Static tools are handled above, so this
|
|
4966
|
+
// never regresses them (AC-30.5.2).
|
|
4967
|
+
if (typeof name === "string" && name.startsWith(GENERATED_TOOL_PREFIX)) {
|
|
4968
|
+
return await callGeneratedTool(name, args);
|
|
4969
|
+
}
|
|
4756
4970
|
throw new Error(`Unknown tool: ${name}`);
|
|
4757
4971
|
}
|
|
4758
4972
|
});
|
|
@@ -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
|
+
}
|
package/lib/http-helpers.js
CHANGED
|
@@ -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).
|
package/lib/witness-sth.js
CHANGED
|
@@ -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:
|
|
235
|
+
signal: makeTimeoutSignal(reqTimeoutMs ?? timeoutMs),
|
|
228
236
|
};
|
|
229
237
|
if (serializedBody !== undefined) options.body = serializedBody;
|
|
230
238
|
|