loopctl-mcp-server 2.97.0 → 2.98.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
@@ -519,6 +519,7 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
519
519
  |---|---|
520
520
  | `signup` | **US-26.7.1.** Create a NEW **agent-rooted (KB-tier)** tenant and mint its one-time root API key — entirely through this call, no human operator, no hardware authenticator, no existing API key required. The tenant gets the FULL knowledge-wiki surface but **cannot** perform work-breakdown / chain-of-custody operations (those require a separate human-anchored tenant via the WebAuthn ceremony at `https://loopctl.com/signup`). Rate-limited per client IP (<= 5/hour). The `raw_key` is shown ONCE — save it immediately (e.g. as `LOOPCTL_USER_KEY`). Required: `name`, `slug`, `email`. |
521
521
  | `dispatch` | Mint an ephemeral, scoped api_key for a sub-agent dispatch, carrying its lineage path. The `raw_key` is returned ONCE — pass it to the sub-agent's launch args, never store it in env vars; it expires after `expires_in_seconds` (default 3600, max 14400). Required: `role` (`agent`/`orchestrator`), `agent_id`, and `parent_dispatch_id` for every caller a dispatch minted — a dispatch may only be minted INSIDE the caller's own lineage. Omitting `parent_dispatch_id` requests a ROOT dispatch (a new independent lineage tree), which only the tenant's `user`-role operator key may create; anyone else gets `403 root_dispatch_forbidden` with their own dispatch id in `remediation.your_dispatch_id`. A parent outside the caller's lineage is `403 parent_outside_caller_lineage`. Optional: `story_id`. **LCP-1 §9.2 signed profile:** optionally enroll an agent key via `agent_pubkey` (hex) + `alg` + an `attestation` (from `custody_sign_attestation`) + `attestation_conditions`. |
522
+ | `revoke_dispatch` | **Revoke a dispatch AND ITS WHOLE SUBTREE** (`POST /api/v1/dispatches/:id/revoke`), plus the ephemeral api_key each one minted — the revoke matches the dispatch OR any row carrying it in `lineage_path`, so name a LEAF unless you mean the subtree. Reach for it when a dead session's key is blocking new work: an un-revoked key OCCUPIES its agent's slot in the partial unique index `api_keys_one_role_per_agent_idx` (`(tenant_id, agent_id, role)` WHERE `revoked_at IS NULL`), which cannot also test `expires_at` — a partial-index predicate must be IMMUTABLE and `now()` is STABLE — so even an EXPIRED key holds it and the next mint answers `422 agent already has an active key with this role`. Waiting works too, slowly: the expired-dispatch sweep runs every minute and the expired-api-key sweep every five, so the slot clears at the TTL (four hours for a placement session dispatch). For a PARKED STORY prefer `force_unclaim_story`, which revokes that story's own session dispatch and frees the stage as well. It does NOT clear `implementer_dispatch_id`: that is custody provenance, and a revoked dispatch still resolves, so verify/report/review-complete compare the same lineage afterwards. `role: :orchestrator` WITH the hierarchy (orchestrator, user or superadmin all pass; an agent key is `403 insufficient_role`), so no env var is pinned — `LOOPCTL_USER_KEY` is merely PREFERRED, for the ceiling reason next. `403 dispatch_outside_caller_lineage` when the target is not in your lineage — a dispatch may only be revoked by a caller it descends from, because the revoke cascades; the body carries `remediation.your_dispatch_id`, and the tenant's `user`-role operator key may revoke anywhere in its tenant. `403 unlineaged_revoke_forbidden` when your key was minted by no dispatch and is below `user` role — a legacy `LOOPCTL_ORCH_KEY` is exactly that, and with no lineage there is no subtree that would bound a cascading revoke, so it could otherwise revoke the tenant's root; use the operator key, or `force_unclaim_story` for a parked story, or mint a dispatch under an active parent and revoke from inside that lineage. `403 custody_tier_required` on an agent-rooted tenant, `503 tenant_halted` under a custody halt, 404 for an unknown dispatch or another tenant's. Idempotent: an already-revoked dispatch answers 200 with `revoked_count: 0` and its ORIGINAL `revoked_at`. Required: `dispatch_id` (refused locally if it is not a UUID). |
522
523
  | `register_custody_owner_key` | **LCP-1 §9.2.** Register/rotate the tenant custody OWNER key — the root of trust the attestation chain hangs from. Private half stays with you; requires `LOOPCTL_USER_KEY` and a human-anchored tenant. Required: `owner_pubkey` (hex). ROTATION additionally requires `rotation_proof` (from `custody_sign_owner_rotation`); first registration needs none. |
523
524
  | `list_enrolled_agent_keys` | **LCP-1 §9.1.1 transparency.** List the agent public keys enrolled under your tenant, reconstructed from the tamper-evident audit chain (not a mutable listing). Compare against the keys you generated; any excess is operator-minted. Keyset-paged (`limit`, `cursor`). |
524
525
  | `custody_generate_keypair` | **LCP-1 §9.** Generate an Ed25519 keypair LOCALLY (private key never leaves the process). Returns `public_key_hex` + `private_key_hex`. |
package/index.js CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  resolveEscalation as resolveEscalationRequest,
55
55
  storyStage as storyStageRequest,
56
56
  } from "./lib/delivery-loop.js";
57
+ import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
57
58
 
58
59
  // Single source of truth for the server version: the package.json this file
59
60
  // ships with (npm always includes package.json in the published tarball).
@@ -2918,6 +2919,30 @@ async function createDispatch({
2918
2919
  return toContent(result);
2919
2920
  }
2920
2921
 
2922
+ // #862: the reachable half of `Dispatches.revoke/2`.
2923
+ //
2924
+ // LOOPCTL_USER_KEY is PREFERRED but NOT pinned, and the distinction is the whole point.
2925
+ // The role gate is `role: :orchestrator` WITH the hierarchy, not `exact_role`, so pinning
2926
+ // one variable would refuse configurations the server accepts — the break #861 round 2 had
2927
+ // to revert. But the endpoint also applies the LINEAGE CEILING, and there an UNLINEAGED
2928
+ // caller passes only at `role: :user` (`403 unlineaged_revoke_forbidden` otherwise, review
2929
+ // #862 finding 1). A bare legacy LOOPCTL_ORCH_KEY is exactly that shape, so sending it by
2930
+ // default made the commonest configuration the refused one.
2931
+ //
2932
+ // So the key is passed as an ORDINARY override, not with `exactKey`: `resolveKey`'s order is
2933
+ // LOOPCTL_API_KEY > this > LOOPCTL_ORCH_KEY. A global key still wins (it may be the operator
2934
+ // key, or a dispatch-minted one revoking inside its own subtree — both legitimate), the
2935
+ // operator key is next, and a legacy orchestrator key is still SENT rather than refused
2936
+ // locally, so the caller gets the server's own message naming what to do instead.
2937
+ async function revokeDispatch(args) {
2938
+ return toContent(
2939
+ await revokeDispatchRequest(args, {
2940
+ apiCall: (method, path, body) =>
2941
+ apiCall(method, path, body, process.env.LOOPCTL_USER_KEY),
2942
+ }),
2943
+ );
2944
+ }
2945
+
2921
2946
  // LCP-1 §9.2: register/rotate the tenant custody owner key (root of trust).
2922
2947
  // ROTATION (an owner key already exists) requires `rotation_proof`: a hex Ed25519
2923
2948
  // signature by the OUTGOING owner key over owner_rotation_preimage(tenant_id,
@@ -7638,6 +7663,64 @@ const TOOLS = [
7638
7663
  },
7639
7664
  },
7640
7665
 
7666
+ {
7667
+ name: "revoke_dispatch",
7668
+ description:
7669
+ "REVOKE A DISPATCH AND ITS WHOLE SUBTREE (POST /api/v1/dispatches/:id/revoke), plus the " +
7670
+ "ephemeral api_key each one minted. `Dispatches.revoke/2` matches the dispatch OR any row " +
7671
+ "carrying it in `lineage_path`, so revoking a tree's root revokes the tree — name a LEAF " +
7672
+ "unless you mean the subtree.\n\n" +
7673
+ "WHAT IT IS FOR. A session dispatch whose session is gone still has `revoked_at IS NULL`, " +
7674
+ "so it OCCUPIES its agent's slot in the partial unique index " +
7675
+ "`api_keys_one_role_per_agent_idx` — `(tenant_id, agent_id, role)` WHERE " +
7676
+ "`revoked_at IS NULL`. That index cannot also test `expires_at` (Postgres requires a " +
7677
+ "partial-index predicate to be IMMUTABLE and `now()` is STABLE), so an EXPIRED key still " +
7678
+ "holds the slot and the next mint for that agent answers 422 'agent already has an active " +
7679
+ "key with this role'. Measured on story d9975b31: place_dispatch returned exactly that and " +
7680
+ "wrote nothing.\n\n" +
7681
+ "WAITING ALSO WORKS, SLOWLY. RevokeExpiredDispatchesWorker sweeps expired dispatches every " +
7682
+ "minute and RevokeExpiredApiKeysWorker sweeps expired keys every five, so a stranded " +
7683
+ "credential clears at its TTL — four hours for a placement session dispatch. Use this to " +
7684
+ "not wait. For a PARKED STORY prefer force_unclaim_story: it revokes that story's own " +
7685
+ "session dispatch on its way past, and it frees the stage as well.\n\n" +
7686
+ "IT DOES NOT CLEAR `implementer_dispatch_id`. That is custody provenance, and loopctl " +
7687
+ "resolves it with a read that returns a REVOKED row exactly as it returns a live one, so " +
7688
+ "verify / report / review-complete compare the same lineage afterwards. Revoking kills the " +
7689
+ "credential and changes no custody verdict.\n\n" +
7690
+ "REFUSALS. `role: :orchestrator` WITH the hierarchy, so an orchestrator, user or superadmin " +
7691
+ "key passes and an agent key is 403 insufficient_role — this is not exact_role, so no " +
7692
+ "particular env var is pinned; LOOPCTL_USER_KEY is merely PREFERRED, because of the " +
7693
+ "ceiling below. 403 custody_tier_required on an agent-rooted tenant. " +
7694
+ "403 dispatch_outside_caller_lineage " +
7695
+ "when the target is not in your lineage: a dispatch may only be revoked by a caller it " +
7696
+ "descends from, because the revoke cascades — the body carries your own dispatch id as " +
7697
+ "remediation.your_dispatch_id, and the tenant's user-role operator key may revoke anywhere " +
7698
+ "in its tenant. 403 unlineaged_revoke_forbidden when your key was minted by NO dispatch " +
7699
+ "and is below user role — a legacy LOOPCTL_ORCH_KEY is exactly that: it carries no " +
7700
+ "lineage, so there is no subtree that would bound a cascading revoke, and it would " +
7701
+ "otherwise be able to revoke the tenant's root. Use the user-role operator key, or " +
7702
+ "force_unclaim_story for a parked story, or mint a dispatch under an active parent and " +
7703
+ "revoke from inside that lineage. " +
7704
+ "503 tenant_halted under a custody halt. 404 for an unknown dispatch, and " +
7705
+ "for another tenant's. A `dispatch_id` that is not a UUID is refused here, before any " +
7706
+ "call.\n\n" +
7707
+ "IDEMPOTENT: an already-revoked dispatch answers 200 with revoked_count 0 and its ORIGINAL " +
7708
+ "revoked_at. No request body.",
7709
+ inputSchema: {
7710
+ type: "object",
7711
+ properties: {
7712
+ dispatch_id: {
7713
+ type: "string",
7714
+ description:
7715
+ "UUID of the dispatch to revoke, together with every descendant. From the " +
7716
+ "`dispatch` tool's response, from `implementer_dispatch_id` on a story, or from " +
7717
+ "GET /api/v1/dispatches.",
7718
+ },
7719
+ },
7720
+ required: ["dispatch_id"],
7721
+ },
7722
+ },
7723
+
7641
7724
  // Issue #809: runner enrollment and the Presence pool (user key)
7642
7725
  {
7643
7726
  name: "runner_enroll",
@@ -8877,6 +8960,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8877
8960
  case "dispatch":
8878
8961
  return await createDispatch(args);
8879
8962
 
8963
+ case "revoke_dispatch":
8964
+ return await revokeDispatch(args);
8965
+
8880
8966
  case "runner_enroll":
8881
8967
  return await runnerEnroll(args);
8882
8968
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `revoke_dispatch` — `POST /api/v1/dispatches/:id/revoke`.
3
+ *
4
+ * ## The defect it exists for
5
+ *
6
+ * `Loopctl.Dispatches.revoke/3` had no route and no tool, so nothing outside the app
7
+ * could call it. That made a stranded ephemeral key unfixable rather than merely
8
+ * inconvenient, because of an invariant two layers down:
9
+ *
10
+ * `api_keys_one_role_per_agent_idx` is a PARTIAL UNIQUE index over
11
+ * `(tenant_id, agent_id, role)` with the predicate
12
+ * `revoked_at IS NULL AND role NOT IN ('user','superadmin')`. It CANNOT also test
13
+ * `expires_at` — Postgres requires a partial-index predicate to be IMMUTABLE and
14
+ * `now()` is STABLE — so the index's idea of "active" is `revoked_at IS NULL` while the
15
+ * auth pipeline's is `revoked_at IS NULL AND expires_at > now()`. A key whose session
16
+ * died is unusable for authentication and STILL OCCUPIES its agent's slot, so the next
17
+ * mint for that agent at that role is refused
18
+ * `422 agent already has an active key with this role`.
19
+ *
20
+ * Measured on story `d9975b31` (2026-09-15): `place_dispatch` answered exactly that 422
21
+ * and wrote nothing, because a session dispatch from an earlier attempt was never
22
+ * revoked. Nothing an operator could reach would clear it before its TTL.
23
+ *
24
+ * ## WHAT IT REVOKES — say this out loud before calling it
25
+ *
26
+ * The dispatch AND EVERY DESCENDANT, plus the ephemeral api_key each one minted. That
27
+ * is `Dispatches.revoke/3`'s own semantics (its query is
28
+ * `d.id == ^dispatch_id or ^dispatch_id in d.lineage_path`), not something this tool
29
+ * adds. Revoking a tree's root revokes the tree.
30
+ *
31
+ * ## WHAT IT DOES NOT DO
32
+ *
33
+ * It does not clear `stories.implementer_dispatch_id`. That field is custody
34
+ * provenance: loopctl resolves it with `Dispatches.get_dispatch/2`, which reads a
35
+ * REVOKED row exactly as it reads a live one, so `verify` / `report` /
36
+ * `review-complete` compare the same lineage afterwards. Revoking kills the credential
37
+ * and changes no custody verdict.
38
+ *
39
+ * ## REFUSALS
40
+ *
41
+ * - `403 insufficient_role` — the endpoint is `role: :orchestrator` WITH the hierarchy,
42
+ * so an orchestrator, user or superadmin key passes and an agent key does not. It is
43
+ * NOT `exact_role`, so this tool pins no particular env var; it merely PREFERS
44
+ * LOOPCTL_USER_KEY, for the ceiling reason two bullets down.
45
+ * - `403 custody_tier_required` — an agent-rooted tenant. Such a tenant cannot mint a
46
+ * dispatch either, so it has none to revoke.
47
+ * - `403 dispatch_outside_caller_lineage` — THE LINEAGE CEILING. A dispatch may only be
48
+ * revoked by a caller it descends from, because the revoke cascades: without it one
49
+ * principal could take down another's whole tree, and an implementer could prune the
50
+ * pool `select_verifier/3` draws from (it admits only dispatches that are
51
+ * `revoked_at IS NULL AND expires_at > now`) until the verifier it prefers is the one
52
+ * left. The tenant's `user`-role operator key — one no dispatch minted — may revoke
53
+ * anywhere in its tenant. The refusal carries `remediation.your_dispatch_id` when the
54
+ * caller has one.
55
+ * - `403 unlineaged_revoke_forbidden` — THE OTHER HALF OF THAT CEILING, and the one a
56
+ * default configuration meets. A key that no dispatch minted carries no lineage, so
57
+ * there is no subtree a cascading revoke could be bounded by; it passes only at
58
+ * `role: :user`, the tenant's operator key. A legacy LOOPCTL_ORCH_KEY is exactly that
59
+ * shape and is refused. Remedies the refusal names: use the user-role operator key,
60
+ * `force_unclaim_story` for a parked story (it revokes that story's own session
61
+ * dispatch through a path this ceiling does not sit on), or mint a dispatch under an
62
+ * active parent and revoke from inside that lineage.
63
+ * - `503 tenant_halted` — a custody halt suspends this route, like dispatch minting.
64
+ * - `404` — no such dispatch in your tenant. Another tenant's id answers 404 too.
65
+ *
66
+ * ## IDEMPOTENT
67
+ *
68
+ * An already-revoked dispatch answers 200 with `revoked_count: 0` and its ORIGINAL
69
+ * `revoked_at`; the revoke statement only touches rows that are `revoked_at IS NULL`.
70
+ * Retrying after a timeout is safe and never rewrites a revocation timestamp.
71
+ *
72
+ * ## EXPIRY IS SWEPT, SO REACH FOR THIS ONLY WHEN WAITING IS TOO SLOW
73
+ *
74
+ * `Loopctl.Workers.RevokeExpiredDispatchesWorker` (cron, every minute) revokes a
75
+ * dispatch and its key once `expires_at` has passed, and
76
+ * `RevokeExpiredApiKeysWorker` (every five minutes) catches a key minted with no
77
+ * dispatch behind it. So a stranded credential DOES clear itself — at its TTL, which for
78
+ * a placement session dispatch is four hours. This tool is for not waiting.
79
+ * `force_unclaim_story` also revokes the session dispatch of the story it frees, so on a
80
+ * parked story that is the one call to make.
81
+ */
82
+
83
+ const MISSING_ID =
84
+ "`dispatch_id` is required: the dispatch to revoke, from `mcp__loopctl__dispatch`'s " +
85
+ "response or from the `implementer_dispatch_id` on a story.";
86
+
87
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
88
+
89
+ function refuse(body) {
90
+ return { error: true, status: 0, body };
91
+ }
92
+
93
+ export function revokeDispatchPath(dispatchId) {
94
+ return `/api/v1/dispatches/${encodeURIComponent(dispatchId)}/revoke`;
95
+ }
96
+
97
+ /**
98
+ * A malformed id is refused HERE, before any call.
99
+ *
100
+ * Not for tidiness: a path id that is not a UUID reaches
101
+ * `LoopctlWeb.CastErrorHandler`'s `Ecto.Query.CastError` clause and answers 404 — the
102
+ * SAME 404 a well-formed id naming no dispatch gets. Two problems with opposite remedies
103
+ * ("fix the argument" versus "that dispatch is gone") would otherwise have one
104
+ * indistinguishable answer.
105
+ *
106
+ * The refusal names the SHAPE and never echoes the value: a malformed id is often a
107
+ * token or a pasted line that landed in the wrong argument, and a tool result goes into
108
+ * the transcript.
109
+ */
110
+ export async function revokeDispatch({ dispatch_id } = {}, { apiCall } = {}) {
111
+ if (typeof dispatch_id !== "string" || dispatch_id.trim() === "") {
112
+ return refuse(MISSING_ID);
113
+ }
114
+
115
+ if (!UUID_RE.test(dispatch_id)) {
116
+ return refuse(
117
+ "`dispatch_id` must be a UUID (8-4-4-4 hex digits then 12, lowercase or upper). " +
118
+ `Got a ${dispatch_id.length}-character string that is not one. The value is not ` +
119
+ "repeated here: a malformed id is often something pasted into the wrong argument, " +
120
+ "and a tool result lands in the transcript.",
121
+ );
122
+ }
123
+
124
+ return apiCall("POST", revokeDispatchPath(dispatch_id), null);
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.97.0",
3
+ "version": "2.98.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",