loopctl-mcp-server 2.54.0 → 2.56.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.
Files changed (3) hide show
  1. package/README.md +16 -2
  2. package/index.js +195 -6
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -16,6 +16,14 @@ Or run directly with npx:
16
16
  npx loopctl-mcp-server
17
17
  ```
18
18
 
19
+ ### Requirements
20
+
21
+ - **Node.js >= 20.6.0.** The server makes all outbound HTTPS calls through Node's
22
+ global `fetch` (undici). Node 20.6.0 is the first release where that transport
23
+ honors `NODE_EXTRA_CA_CERTS` (the setting used for a custom/self-signed loopctl
24
+ CA -- see Troubleshooting below). On older runtimes that env var is silently
25
+ ignored, so the floor is enforced via `engines` in `package.json`.
26
+
19
27
  ## Configuration
20
28
 
21
29
  Add to your `.mcp.json` (Claude Code) or equivalent MCP config:
@@ -121,7 +129,7 @@ REST endpoint (`PATCH /api/v1/tenants/me/llm-config`), and the docs — so you (
121
129
  autonomous agent) can self-remediate without a human. Full agent-tenant lifecycle:
122
130
  [`docs/onboarding-agent-tenant.md`](../docs/onboarding-agent-tenant.md).
123
131
 
124
- ## Tools (109)
132
+ ## Tools
125
133
 
126
134
  > Plus **per-tenant generated Context Retriever tools** (`cr_*`) appended
127
135
  > dynamically at runtime — see [Dynamic per-tenant Context Retriever
@@ -232,6 +240,9 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
232
240
  | Tool | Description |
233
241
  |---|---|
234
242
  | `knowledge_index` | Browse/paginate the knowledge wiki catalog grouped by category. **Agent callers see only articles they own or marked `shared`.** Honors `category`, `tags`, `offset`, `limit` with deterministic ordering over the filtered set (`meta.categories` reports per-category totals within visibility). Use `fields` (default `id,title,category`; request `tags`/`status`/`updated_at` explicitly; `id` and `category` are always included) to keep the payload small. Optional: `project_id`, `story_id`, `category`, `tags`, `offset`, `limit`, `fields`. |
243
+ | `embedding_status` | This tenant's embedding-dimension state: active dimension, whether semantic recall is available (and the exact reason when it is not), the instance's supported dimension set, whether the shared system-scoped corpus has been materialized for this tenant, and per-dimension row counts. Call it when semantic search under-returns or reports `fallback_reason: semantic_recall_unavailable`. |
244
+ | `embedding_materialize_system_corpus` | Embed the shared SYSTEM-scoped corpus for THIS tenant at its active dimension with this tenant's own credential (system articles are keyword-only until then). Idempotent, batched. |
245
+ | `embedding_reembed` | Move the tenant's whole corpus (articles, per-tenant system-article materializations and agent memories) onto `target_dimension`. Recall keeps serving at the current dimension throughout; the pin flips and stale rows drop only when everything is present at the target. One-time and cost-bearing; requires `LOOPCTL_ORCH_KEY`. |
235
246
  | `knowledge_stats` | Aggregate article counts (`total`, `by_category`, `by_status`) via cheap `COUNT(*) GROUP BY` within agent's visible set — no article metadata loaded. Agent callers see only their own and `shared` articles. Counts span all statuses. Optional: `project_id`. |
236
247
  | `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 }`. |
237
248
  | `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`. |
@@ -324,6 +335,8 @@ nothing changes until a scope opts in.
324
335
  | `declare_trusted_endpoint` | Declare a host you attest is YOUR OWN so a `local_only` scope can reach it. **This is an unverified tenant attestation, not network locality** — labelled as such everywhere. Three enforced constraints: PUBLIC addresses only (write time AND pin time), PURPOSE-SCOPED (`inference` / `webhook` / `ingest`), vendor hosts excluded. Carves nothing out of the SSRF denylist. Requires **user** key. |
325
336
  | `revoke_trusted_endpoint` | Revoke a declaration. Invalidation is IMMEDIATE and cluster-wide — a revoked declaration does not keep working for the remainder of the pin TTL. Requires **user** key. |
326
337
  | `egress_repin` | Recover from a `:pin_stale` error (your box got a new DHCP lease and the pinned address set changed — DISTINCT from `egress_blocked`). Re-resolves and re-pins the host. **Agent** key by design: requiring a human user-role write to recover would contradict loopctl's agent-native, no-UI design. |
338
+ | `custody_claim` | The recorded **egress custody claim** for one article or memory row: the append-only sequence of per-operation postures (create, each embed, each re-embed, each classification, each merge) with the endpoints resolved for THAT operation and their verdicts, plus the aggregate. Rides the existing hash-chained audit log + signed tree heads — each entry carries a `chain_position` and the leaf's `chain_entry_hash`, so `GET /api/v1/audit/sth/{tenant_id}/inclusion/{position}` proves inclusion of *that* leaf, and the leaf's payload names this row by a recomputable `posture_digest`. THREE states, only one an attestation: `no_claim_recorded`, `claim_pending`, `claim_recorded` (`complete` / `partial_history` / `incomplete`). Completeness is measured against a persisted per-row high-water mark, so losing the tail of a sequence is a gap, not a clean claim. `third_party_egress_on_covered_paths` is `false` only for NETWORK-local endpoints; a tenant-declared (unverified) endpoint yields `"tenant_declared_unverified"`. Attests ONLY to the endpoints loopctl called on the paths in `coverage` — never to what those endpoints did afterwards. **Agent** key. |
339
+ | `custody_failures` | Custody posture entries whose chain append was DROPPED after exhausting retries, plus `stale_pending` entries stranded by a flush that died outside its own final-attempt branch. Surfaced rather than silently absent: each `data` entry degrades its row's claim to `incomplete`, and a stranded entry would otherwise read as an in-flight claim forever. **Agent** key. |
327
340
 
328
341
  ### Discovery Tools
329
342
 
@@ -519,7 +532,8 @@ clean one-time bootstrap (no cross-key collision).
519
532
 
520
533
  - Verify `LOOPCTL_SERVER` is set and reachable
521
534
  - Check that the server URL includes the protocol (`https://`)
522
- - If using a self-signed certificate, set `NODE_TLS_REJECT_UNAUTHORIZED=0` in your environment (not recommended for production)
535
+ - If your loopctl server uses a custom or self-signed CA, set `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` in your environment so Node trusts that CA while keeping TLS certificate verification enabled. This requires **Node >= 20.6.0** (or the >= 18.19.0 backport): this server issues all requests through Node's global `fetch` (undici), which only began honoring `NODE_EXTRA_CA_CERTS` in those releases. On older runtimes the variable is silently ignored and you will still see `unable to verify the first certificate` / self-signed-cert errors -- upgrade Node rather than disabling verification.
536
+ - Do NOT set `NODE_TLS_REJECT_UNAUTHORIZED=0` -- it disables certificate verification for the entire Node process (all outbound TLS), exposing every connection to MITM, not just the loopctl one
523
537
 
524
538
  ### Authentication errors (401)
525
539
 
package/index.js CHANGED
@@ -1101,6 +1101,41 @@ async function knowledgeIndex({ project_id, story_id, category, tags, match, off
1101
1101
  return toContent(result);
1102
1102
  }
1103
1103
 
1104
+ // US-41.1 — the per-tenant embedding DIMENSION surface. `status` and the system-corpus
1105
+ // materialization are agent-role; the re-embed is ORCHESTRATOR-role because its completion
1106
+ // step deletes the stale-dimension rows (loopctl reserves data-removing operations for
1107
+ // higher roles), so it deliberately does NOT fall back to the agent key.
1108
+ async function embeddingStatus() {
1109
+ const result = await apiCall("GET", "/api/v1/knowledge/embeddings", null, process.env.LOOPCTL_AGENT_KEY);
1110
+ return toContent(result);
1111
+ }
1112
+
1113
+ async function embeddingMaterializeSystemCorpus() {
1114
+ const result = await apiCall(
1115
+ "POST",
1116
+ "/api/v1/knowledge/embeddings/system-corpus",
1117
+ {},
1118
+ process.env.LOOPCTL_AGENT_KEY,
1119
+ );
1120
+ return toContent(result);
1121
+ }
1122
+
1123
+ async function embeddingReembed({ target_dimension }) {
1124
+ if (!Number.isInteger(target_dimension) || target_dimension <= 0) {
1125
+ return {
1126
+ content: [{ type: "text", text: "Error: target_dimension must be a positive integer." }],
1127
+ isError: true,
1128
+ };
1129
+ }
1130
+ const result = await apiCall(
1131
+ "POST",
1132
+ "/api/v1/knowledge/embeddings/reembed",
1133
+ { target_dimension },
1134
+ process.env.LOOPCTL_ORCH_KEY,
1135
+ );
1136
+ return toContent(result);
1137
+ }
1138
+
1104
1139
  async function knowledgeStats({ project_id }) {
1105
1140
  if (project_id && !UUID_RE.test(project_id)) {
1106
1141
  return {
@@ -1902,6 +1937,33 @@ async function egressPosture() {
1902
1937
  return toContent(result);
1903
1938
  }
1904
1939
 
1940
+ // --- US-41.7: witnessed custody claim ---------------------------------------
1941
+ //
1942
+ // A READ, at agent role for the same reason egress_posture is: verifying a
1943
+ // harvest AFTER the fact must work with the key an agent already holds.
1944
+ async function custodyClaim({ subject_type, subject_id } = {}) {
1945
+ if (!subject_type || !subject_id) {
1946
+ throw new Error("subject_type and subject_id are required");
1947
+ }
1948
+ const result = await apiCall(
1949
+ "GET",
1950
+ `/api/v1/custody/claims/${encodeURIComponent(subject_type)}/${encodeURIComponent(subject_id)}`,
1951
+ null,
1952
+ process.env.LOOPCTL_AGENT_KEY,
1953
+ );
1954
+ return toContent(result);
1955
+ }
1956
+
1957
+ async function custodyFailures() {
1958
+ const result = await apiCall(
1959
+ "GET",
1960
+ "/api/v1/custody/failures",
1961
+ null,
1962
+ process.env.LOOPCTL_AGENT_KEY,
1963
+ );
1964
+ return toContent(result);
1965
+ }
1966
+
1905
1967
  async function setLocalOnly({ project_id, acknowledge } = {}) {
1906
1968
  const body = {};
1907
1969
  if (project_id != null) body.project_id = project_id;
@@ -3520,6 +3582,49 @@ const TOOLS = [
3520
3582
  required: [],
3521
3583
  },
3522
3584
  },
3585
+ {
3586
+ name: "embedding_status",
3587
+ description:
3588
+ "Report this tenant's EMBEDDING DIMENSION state: the active dimension, whether semantic " +
3589
+ "recall is currently available (and the exact reason when it is not — e.g. a non-default " +
3590
+ "dimension whose side-table reads have not been enabled yet), the instance's supported " +
3591
+ "dimension set, whether the shared SYSTEM-scoped corpus has been materialized for this " +
3592
+ "tenant (until it is, those articles are keyword-only), and per-dimension row counts. " +
3593
+ "Call this when semantic search returns fewer results than expected or reports " +
3594
+ "fallback_reason 'semantic_recall_unavailable' — it tells you WHY instead of leaving an " +
3595
+ "empty result set to be misread as 'nothing relevant'.",
3596
+ inputSchema: { type: "object", properties: {}, required: [] },
3597
+ },
3598
+ {
3599
+ name: "embedding_materialize_system_corpus",
3600
+ description:
3601
+ "Materialize the shared SYSTEM-scoped article corpus for THIS tenant at its active " +
3602
+ "embedding dimension, using this tenant's own embedding credential. System articles " +
3603
+ "cannot be embedded once for everyone (embeddings are BYO), so until this runs they are " +
3604
+ "matched by keyword only for you. Idempotent and batched; safe to call repeatedly.",
3605
+ inputSchema: { type: "object", properties: {}, required: [] },
3606
+ },
3607
+ {
3608
+ name: "embedding_reembed",
3609
+ description:
3610
+ "Move this tenant's WHOLE corpus (articles, per-tenant system-article materializations " +
3611
+ "and agent memories) onto target_dimension. Recall keeps serving at the CURRENT " +
3612
+ "dimension for the entire run; the recorded dimension is flipped and the stale-dimension " +
3613
+ "rows dropped only once everything is present at the target. ONE-TIME and COST-BEARING — " +
3614
+ "it re-bills your embedding provider for the entire corpus. Requires an ORCHESTRATOR key " +
3615
+ "(the completion step deletes data). An unsupported dimension is rejected with the " +
3616
+ "supported set named.",
3617
+ inputSchema: {
3618
+ type: "object",
3619
+ properties: {
3620
+ target_dimension: {
3621
+ type: "integer",
3622
+ description: "The embedding dimension to move to. Must be in the instance's supported set.",
3623
+ },
3624
+ },
3625
+ required: ["target_dimension"],
3626
+ },
3627
+ },
3523
3628
  {
3524
3629
  name: "knowledge_stats",
3525
3630
  description:
@@ -3753,6 +3858,10 @@ const TOOLS = [
3753
3858
  "a keyword and a semantic sub-search, each capped at 100, so up to ~200), or filtered_set " +
3754
3859
  "(list mode: the full set). Do NOT use a relevance-mode total_count to size the wiki — use " +
3755
3860
  "list mode or knowledge_stats. " +
3861
+ "SCORE: in combined mode each result's `score` is a Reciprocal Rank Fusion weight " +
3862
+ "(~0.008-0.016 at the top), NOT a normalized 0..1 confidence — use it only to compare " +
3863
+ "RANK/order within one response; for an absolute 0..1 confidence use knowledge_hybrid_search " +
3864
+ "(meta.confidence). " +
3756
3865
  "Pass story_id when working on a loopctl story so reads attribute correctly. " +
3757
3866
  "When you knowledge_get a result and it carries `potential_conflicts`, resolve it if it's " +
3758
3867
  "material to your task (see knowledge_get / the conflict-resolution wiki playbook). " +
@@ -4962,7 +5071,8 @@ const TOOLS = [
4962
5071
  name: "egress_posture",
4963
5072
  description:
4964
5073
  "VERIFY BEFORE YOU HARVEST. Reports this instance's egress posture for YOUR tenant: " +
4965
- "the resolved embedding and chat endpoints, a locality VERDICT for each " +
5074
+ "the resolved embedding and chat endpoints, EVERY webhook destination " +
5075
+ "(webhook_destinations), a locality VERDICT for each " +
4966
5076
  "(network-local / 'tenant-declared (unverified attestation), not network-local' / " +
4967
5077
  "non-local), your declared trusted endpoints with their purposes, per-scope " +
4968
5078
  "local_only status, and any named posture defects. Endpoints are shown; KEYS NEVER " +
@@ -4971,10 +5081,70 @@ const TOOLS = [
4971
5081
  "key you already have. NOTE the deployment allowlist CONTENTS are operator " +
4972
5082
  "infrastructure and are NOT disclosed at agent role: you get only a boolean per " +
4973
5083
  "endpoint saying whether its verdict came from the allowlist (contents at user+). " +
5084
+ "Webhook destinations follow the same split: HOST plus verdict plus " +
5085
+ "blocked_by_local_only at agent role, the FULL destination URL (endpoint) only at " +
5086
+ "user+ — a webhook path is frequently the credential. " +
4974
5087
  "SCOPE OF THE GUARANTEE: fail-closed enforcement covers every outbound HTTP call " +
4975
- "made by loopctl application code on the MODEL-PROVIDER path. Webhook delivery is " +
4976
- "not covered yet (US-41.5), and HTTP performed inside a dependency, plus this " +
4977
- "separate mcp-server codebase, are outside the static chokepoint check.",
5088
+ "made by loopctl application code on every CONTENT-CARRYING path model-provider " +
5089
+ "calls, the ingestion fetch, and webhook delivery (US-41.5). HTTP performed inside " +
5090
+ "a dependency, plus this separate mcp-server codebase, are outside the static " +
5091
+ "chokepoint check; the remaining non-content outbound paths are triaged in " +
5092
+ "docs/egress-guard.md.",
5093
+ inputSchema: { type: "object", properties: {}, required: [] },
5094
+ },
5095
+ {
5096
+ name: "custody_claim",
5097
+ description:
5098
+ "The recorded EGRESS CUSTODY CLAIM for one article or memory row: the append-only " +
5099
+ "sequence of per-operation postures (create, each embedding, each re-embed, each " +
5100
+ "classification/merge) with the endpoint loopctl resolved for THAT operation and its " +
5101
+ "locality verdict, plus the aggregate over them. Each claim rides the existing " +
5102
+ "hash-chained audit log and its signed tree heads — every recorded entry carries the " +
5103
+ "chain_position you can fetch an inclusion proof for at " +
5104
+ "GET /api/v1/audit/sth/{tenant_id}/inclusion/{position} and check against the public " +
5105
+ "STH with the tenant's published audit key. THREE STATES, and only one of them is an " +
5106
+ "attestation: 'no_claim_recorded' (no operation sequence was ever assigned — the row " +
5107
+ "predates recording or its scope is not marked local_only; this asserts NOTHING in " +
5108
+ "either direction), 'claim_pending' (sequences assigned, batch append not yet " +
5109
+ "flushed), and 'claim_recorded', itself 'complete', 'partial_history' or " +
5110
+ "'incomplete'. An INCOMPLETE sequence (a gap, a lost tail, or a dropped append) is " +
5111
+ "never reported as no-third-party-egress: an unrecorded operation may have called " +
5112
+ "any endpoint. Completeness is measured against a PERSISTED per-row high-water mark, " +
5113
+ "not against the rows that happen to survive, so truncating the sequence cannot " +
5114
+ "restore a satisfied claim. 'partial_history' means operation 0 is not this row's " +
5115
+ "creation — recording began after the row already existed (typically the scope was " +
5116
+ "marked local_only later), so loopctl has no record of how it was produced. " +
5117
+ "third_party_egress_on_covered_paths is `false` ONLY when every recorded endpoint " +
5118
+ "was NETWORK-LOCAL; when the sequence leans on a TENANT-DECLARED endpoint (a public " +
5119
+ "host the tenant merely attested is its own, which loopctl never verified) the value " +
5120
+ "is the string 'tenant_declared_unverified', not false. " +
5121
+ "SCOPE, precisely: the claim attests ONLY to the endpoints loopctl called for the " +
5122
+ "recorded operations on this row, on the egress paths enumerated in the `coverage` " +
5123
+ "field. It makes NO statement about what those endpoints did with the data " +
5124
+ "afterwards, and none about a path listed as uncovered. READ tool, AGENT role.",
5125
+ inputSchema: {
5126
+ type: "object",
5127
+ properties: {
5128
+ subject_type: {
5129
+ type: "string",
5130
+ enum: ["article", "memory"],
5131
+ description: "The kind of row the claim is bound to.",
5132
+ },
5133
+ subject_id: { type: "string", description: "The row's UUID." },
5134
+ },
5135
+ required: ["subject_type", "subject_id"],
5136
+ },
5137
+ },
5138
+ {
5139
+ name: "custody_failures",
5140
+ description:
5141
+ "Custody posture entries whose audit-chain append was DROPPED after exhausting " +
5142
+ "retries, plus (under `stale_pending`) entries that have been in flight longer than " +
5143
+ "the stale window. A recording failure is surfaced here rather than silently absent, " +
5144
+ "because a missing claim must never read as a satisfied one: every entry under " +
5145
+ "`data` degrades its row's claim to 'incomplete', and a stranded `stale_pending` " +
5146
+ "entry is one a flush stamped and then died on — it would otherwise read as an " +
5147
+ "in-flight claim indefinitely. READ tool, AGENT role.",
4978
5148
  inputSchema: { type: "object", properties: {}, required: [] },
4979
5149
  },
4980
5150
  {
@@ -4985,10 +5155,13 @@ const TOOLS = [
4985
5155
  "everywhere; nothing changes until a scope opts in. Scope resolution is " +
4986
5156
  "MOST-RESTRICTIVE-WINS (project OR tenant) and a project can NEVER relax a tenant " +
4987
5157
  "marking. MANDATORY PRE-FLIGHT: the call is REFUSED with 409 would_block_endpoints, " +
4988
- "naming every endpoint that would become egress_blocked, unless you pass " +
5158
+ "naming every endpoint that would become egress_blocked including every WEBHOOK " +
5159
+ "SUBSCRIPTION whose destination would be refused (kind: 'webhook', US-41.5) — " +
5160
+ "unless you pass " +
4989
5161
  "acknowledge: true — because on a tenant still using vendor default endpoints this " +
4990
5162
  "instantly stops embedding, extraction, classification and merge, and only a " +
4991
- "human user-role key can undo it. Requires an ORCHESTRATOR key: tightening is safe " +
5163
+ "human user-role key can undo it. Subscriptions are never silently disabled: they " +
5164
+ "are either the reason for the refusal, or reported back to you on acknowledgement. Requires an ORCHESTRATOR key: tightening is safe " +
4992
5165
  "to automate. CLEARING is a different tool (clear_local_only) and is user-only.",
4993
5166
  inputSchema: {
4994
5167
  type: "object",
@@ -5846,6 +6019,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5846
6019
  case "knowledge_index":
5847
6020
  return await knowledgeIndex(args);
5848
6021
 
6022
+ case "embedding_status":
6023
+ return await embeddingStatus();
6024
+
6025
+ case "embedding_materialize_system_corpus":
6026
+ return await embeddingMaterializeSystemCorpus();
6027
+
6028
+ case "embedding_reembed":
6029
+ return await embeddingReembed(args);
6030
+
5849
6031
  case "knowledge_stats":
5850
6032
  return await knowledgeStats(args);
5851
6033
 
@@ -5975,6 +6157,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5975
6157
  case "egress_posture":
5976
6158
  return await egressPosture();
5977
6159
 
6160
+ // US-41.7 — witnessed custody claim
6161
+ case "custody_claim":
6162
+ return await custodyClaim(args);
6163
+
6164
+ case "custody_failures":
6165
+ return await custodyFailures();
6166
+
5978
6167
  case "set_local_only":
5979
6168
  return await setLocalOnly(args);
5980
6169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.54.0",
3
+ "version": "2.56.0",
4
4
  "description": "MCP server for loopctl \u2014 structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -30,7 +30,7 @@
30
30
  "license": "MIT",
31
31
  "exports": "./index.js",
32
32
  "engines": {
33
- "node": ">=18"
33
+ "node": ">=20.6.0"
34
34
  },
35
35
  "files": [
36
36
  "index.js",