loopctl-mcp-server 2.97.0 → 2.99.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
@@ -240,6 +240,7 @@ Epic 39 Repo Coordination Bus — a lightweight, tenant-isolated channel for age
240
240
  | `list_ready_stories` | List stories that are ready to be worked on (contracted, dependencies met). |
241
241
  | `get_story` | Get full details for a single story by ID. |
242
242
  | `create_story` | Create a single story inside an existing epic. Use instead of wrapping a story in a bulk import. Accepts either `epic_id` (UUID) or (`project_id` + `epic_number`). |
243
+ | `update_story` | **Correct a filed story** (`PATCH /api/v1/stories/:id`) — its `title`, `description`, `acceptance_criteria`, `estimated_hours` or `metadata`, and nothing else. `number` is fixed at creation and the two status fields are custody state with their own gated endpoints. **Sending `metadata` REPLACES THE WHOLE MAP** — there is no merge on this path, so a partial send silently drops every key you left out and answers 200. Read the story first (`get_story`), then send the complete map; `acceptance_criteria` is replaced whole for the same reason. The one key it can no longer erase is `lifecycle_entered_at`, which is a COLUMN precisely because it used to live in metadata and one ordinary PATCH erased it, restoring the claim -> force-unclaim -> backfill-to-verified launder. A field cannot be set to `null` either: the controller drops every nil before the changeset, so `null` is refused here rather than answering 200 unchanged, and so is an `estimated_hours` no decimal parser accepts. Requires orchestrator role or higher (`LOOPCTL_ORCH_KEY`, or `LOOPCTL_API_KEY` of that role) on a human-anchored tenant. |
243
244
  | `backfill_story` | **Bypasses the review/verify chain.** Marks a story as verified when the work was completed outside loopctl (e.g. before the project was onboarded). Refused for any story with `assigned_agent_id` set — those must go through the normal report → review → verify flow, not backfill. Also refused for already `:verified` or `:rejected` stories. Records provenance (`reason`, `evidence_url`, `pr_number`) in `metadata.backfill` and emits a `story.backfilled` webhook. |
244
245
  | `get_acceptance_criteria` | List a story's acceptance criteria with each one's verification status. Required: `story_id`. |
245
246
 
@@ -474,6 +475,7 @@ Two modes, pinned at creation:
474
475
  | Tool | Description |
475
476
  |---|---|
476
477
  | `list_routes` | List all available API routes on the loopctl server. |
478
+ | `mcp_version` | **Which version of this MCP server you are running, and which one loopctl ships.** Returns `running`, `expected`, a `status` of `current` / `behind` / `ahead` / `unknown`, and the remedy. Takes no API key: it reads loopctl's unauthenticated discovery document (`GET /.well-known/loopctl`), so it answers in a session where nothing else does. MCP binds its tool list at session start, so from inside a session "this tool does not exist" and "this tool exists and my process is older than it" look identical — this is the discriminator. At `current`, a tool you cannot see genuinely is not in this version. At `behind`, the remedy is `/mcp` or a session restart, **never a retry**: the tool list is fixed for the life of the process. `expected` is the version in the tree the deployment you are pointed at was built from, not a registry lookup — so `behind` can briefly precede the npm publish, and `ahead` is normal when running this package from a checkout. If this tool is itself missing from your surface, that is the answer: your process predates 2.99.0. |
477
479
  | `get_system_articles` | List or fetch system-scoped (global, cross-tenant) wiki articles. Public — no auth required. Optional: `slug` (fetch one), `category`. |
478
480
 
479
481
  ### Dynamic per-tenant Context Retriever tools (Epic 30)
@@ -519,6 +521,7 @@ Key distribution for the dispatch pattern (Epic 26): per-dispatch ephemeral keys
519
521
  |---|---|
520
522
  | `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
523
  | `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`. |
524
+ | `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
525
  | `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
526
  | `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
527
  | `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
@@ -13,8 +13,7 @@ import {
13
13
  import { readFileSync, writeFileSync, renameSync, lstatSync, unlinkSync } from "node:fs";
14
14
  import os from "node:os";
15
15
  import crypto from "node:crypto";
16
- import { fileURLToPath } from "node:url";
17
- import path, { dirname, join } from "node:path";
16
+ import path from "node:path";
18
17
  import { applyArgAliases } from "./lib/arg-aliases.js";
19
18
  import { clientContextHeader } from "./lib/client-context.js";
20
19
  import { resolveClaimSessionId } from "./lib/claim-session.js";
@@ -54,14 +53,16 @@ import {
54
53
  resolveEscalation as resolveEscalationRequest,
55
54
  storyStage as storyStageRequest,
56
55
  } from "./lib/delivery-loop.js";
56
+ import { updateStory as updateStoryRequest } from "./lib/story-update.js";
57
+ import { mcpVersion as mcpVersionRequest, packageVersion } from "./lib/mcp-version.js";
58
+ import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
57
59
 
58
60
  // Single source of truth for the server version: the package.json this file
59
61
  // ships with (npm always includes package.json in the published tarball).
60
62
  // Keeping it derived prevents the handshake version from drifting from the
61
- // published package version.
62
- const SERVER_VERSION = JSON.parse(
63
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")
64
- ).version;
63
+ // published package version — and, since #846.7, from the version the
64
+ // `mcp_version` tool reports, which reads it through the same function.
65
+ const SERVER_VERSION = packageVersion();
65
66
 
66
67
  // ---------------------------------------------------------------------------
67
68
  // HTTP helper — witness protocol state
@@ -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,
@@ -3264,6 +3289,36 @@ async function forceUnclaimStory(args) {
3264
3289
  );
3265
3290
  }
3266
3291
 
3292
+ // #846.6: correct a filed story. The action is `role: :orchestrator`
3293
+ // (`story_controller.ex:29-30`) and NOT `exact_role:`, so the hierarchy applies and a `:user`
3294
+ // or `:superadmin` key passes too. That is why LOOPCTL_ORCH_KEY goes through `resolveKey` as an
3295
+ // ordinary override here rather than being pinned with `exactKey` the way the custody verbs
3296
+ // are: there is no role a global LOOPCTL_API_KEY could hold that the gate accepts and this
3297
+ // override would improve on. Same selection as `create_story` and `backfill_story`.
3298
+ async function updateStory(args) {
3299
+ return toContent(
3300
+ await updateStoryRequest(args, {
3301
+ apiCall: (method, path, body) =>
3302
+ apiCall(method, path, body, process.env.LOOPCTL_ORCH_KEY),
3303
+ }),
3304
+ );
3305
+ }
3306
+
3307
+ // #846.7: what this process is running versus what loopctl ships. NO KEY — `publicApiCall`
3308
+ // sends no Authorization header at all, and /.well-known/loopctl is unauthenticated
3309
+ // (`router.ex:113-118`), which is the point: the staleness question arises precisely in a
3310
+ // session where keyed calls are failing. SERVER_VERSION is passed rather than re-read so the
3311
+ // handshake and this report cannot disagree; both come from `packageVersion()`.
3312
+ async function mcpVersion(args) {
3313
+ return toContent(
3314
+ await mcpVersionRequest(args, {
3315
+ publicApiCall,
3316
+ version: SERVER_VERSION,
3317
+ baseUrl: getBaseUrl(),
3318
+ }),
3319
+ );
3320
+ }
3321
+
3267
3322
  // US-26: Signed Tree Head retrieval
3268
3323
  async function getSth({ tenant_id }) {
3269
3324
  const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
@@ -4022,6 +4077,69 @@ const TOOLS = [
4022
4077
  required: ["story"],
4023
4078
  },
4024
4079
  },
4080
+ {
4081
+ name: "update_story",
4082
+ description:
4083
+ "CORRECT A FILED STORY (PATCH /api/v1/stories/:id): its title, description, acceptance " +
4084
+ "criteria, estimate or metadata. This is the tool for a story whose own author found it " +
4085
+ "wrong — a severity that understates the defect, a title that misnames it — without " +
4086
+ "resending its siblings the way import_stories with merge would.\n\n" +
4087
+ "SENDING `metadata` REPLACES THE WHOLE MAP. There is no merge on this path: the field " +
4088
+ "is cast straight onto the story, so a partial send keeps the keys you sent and " +
4089
+ "SILENTLY DROPS every other one, and answers 200. READ THE STORY FIRST (get_story), " +
4090
+ "then send the complete map with your change in it. That erasure is not hypothetical: " +
4091
+ "the marker that says a story has entered the lifecycle — and therefore may not be " +
4092
+ "backfilled straight to `verified` — used to live in metadata, and one ordinary PATCH " +
4093
+ "erased it. It is a COLUMN now (`lifecycle_entered_at`) that NO changeset casts, which " +
4094
+ "is why this endpoint can no longer reach it; every other metadata key is still yours " +
4095
+ "to lose. `acceptance_criteria` is replaced whole for the same reason.\n\n" +
4096
+ "WHAT IT CANNOT DO. Only these five fields are updatable — `number` is fixed at " +
4097
+ "creation, and `agent_status` / `verified_status` are custody state with their own " +
4098
+ "gated endpoints (contract, claim, report, review-complete, verify), never a PATCH. A " +
4099
+ "field cannot be NULLED either: the controller drops every nil before the changeset " +
4100
+ "sees it, so `null` is refused here rather than answering 200 unchanged. Requires an " +
4101
+ "orchestrator-role key or higher (LOOPCTL_ORCH_KEY, or LOOPCTL_API_KEY of that role) " +
4102
+ "and a human-anchored tenant (403 `custody_tier_required` otherwise). 404 for an " +
4103
+ "unknown story; 422 with the changeset errors for a title over 500 characters or a " +
4104
+ "description over 50000. A `story_id` that is not a UUID is refused here, before any " +
4105
+ "call — loopctl answers one with the IDENTICAL 404 an unknown story gets, so that " +
4106
+ "status means one thing only because this tool takes the other case first.",
4107
+ inputSchema: {
4108
+ type: "object",
4109
+ properties: {
4110
+ story_id: { type: "string", description: "The UUID of the story to correct." },
4111
+ title: {
4112
+ type: "string",
4113
+ description: "Replaces the title. Max 500 characters (422 beyond it).",
4114
+ },
4115
+ description: {
4116
+ type: "string",
4117
+ description: "Replaces the description. Max 50000 characters (422 beyond it).",
4118
+ },
4119
+ acceptance_criteria: {
4120
+ type: "array",
4121
+ items: { type: "object" },
4122
+ description:
4123
+ "REPLACES the whole list — each entry is an object, conventionally " +
4124
+ "{ id, description }. Send every criterion you want the story to keep.",
4125
+ },
4126
+ estimated_hours: {
4127
+ type: "number",
4128
+ description:
4129
+ "Replaces the estimate. loopctl parses this with a decimal parser and DROPS the " +
4130
+ "field when the parse fails, so an unparseable value would answer 200 with the " +
4131
+ "old estimate; this tool refuses one instead.",
4132
+ },
4133
+ metadata: {
4134
+ type: "object",
4135
+ description:
4136
+ "REPLACES the whole metadata map — read the story first and send it complete. " +
4137
+ "Anything you leave out is gone.",
4138
+ },
4139
+ },
4140
+ required: ["story_id"],
4141
+ },
4142
+ },
4025
4143
  {
4026
4144
  name: "import_stories",
4027
4145
  description:
@@ -7568,6 +7686,35 @@ const TOOLS = [
7568
7686
  required: [],
7569
7687
  },
7570
7688
  },
7689
+ {
7690
+ name: "mcp_version",
7691
+ description:
7692
+ "WHICH VERSION OF THIS MCP SERVER YOU ARE ACTUALLY RUNNING, and which one loopctl " +
7693
+ "ships. Answers `running`, `expected`, a `status` of current / behind / ahead / " +
7694
+ "unknown, and the remedy. Needs no API key and reads loopctl's unauthenticated " +
7695
+ "discovery document, so it answers in a session where nothing else does.\n\n" +
7696
+ "WHAT IT IS FOR. MCP binds its tool list at session start, so 'this tool does not " +
7697
+ "exist' and 'this tool exists and my process is older than it' look IDENTICAL from " +
7698
+ "inside a session — a name that is not in the surface. That is how a merged, deployed, " +
7699
+ "green tool got reported as a missing capability. This is the discriminator: at " +
7700
+ "`current`, a tool you cannot see genuinely is not in this version, so look for another " +
7701
+ "name or file it rather than waiting. At `behind`, your surface is stale and the tool " +
7702
+ "may well exist.\n\n" +
7703
+ "THE REMEDY FOR `behind` IS /mcp OR A SESSION RESTART, NEVER A RETRY: the tool list is " +
7704
+ "fixed for the life of this process, so the same call will fail the same way for ever. " +
7705
+ "If a reconnect does not pick the tool up, npm may not have finished publishing yet — " +
7706
+ "the deploy and the publish run independently — so wait and reconnect again.\n\n" +
7707
+ "`expected` is the version in the tree the DEPLOYMENT you are pointed at was built " +
7708
+ "from (loopctl publishes it at /.well-known/loopctl), not a registry lookup; nothing " +
7709
+ "here talks to npm. `ahead` is normal when running this package from a checkout. And " +
7710
+ "if this tool is itself absent from your surface, that IS the answer: your process " +
7711
+ "predates 2.99.0.",
7712
+ inputSchema: {
7713
+ type: "object",
7714
+ properties: {},
7715
+ required: [],
7716
+ },
7717
+ },
7571
7718
 
7572
7719
  // Dispatch Tool (US-26.2.3)
7573
7720
  {
@@ -7638,6 +7785,64 @@ const TOOLS = [
7638
7785
  },
7639
7786
  },
7640
7787
 
7788
+ {
7789
+ name: "revoke_dispatch",
7790
+ description:
7791
+ "REVOKE A DISPATCH AND ITS WHOLE SUBTREE (POST /api/v1/dispatches/:id/revoke), plus the " +
7792
+ "ephemeral api_key each one minted. `Dispatches.revoke/2` matches the dispatch OR any row " +
7793
+ "carrying it in `lineage_path`, so revoking a tree's root revokes the tree — name a LEAF " +
7794
+ "unless you mean the subtree.\n\n" +
7795
+ "WHAT IT IS FOR. A session dispatch whose session is gone still has `revoked_at IS NULL`, " +
7796
+ "so it OCCUPIES its agent's slot in the partial unique index " +
7797
+ "`api_keys_one_role_per_agent_idx` — `(tenant_id, agent_id, role)` WHERE " +
7798
+ "`revoked_at IS NULL`. That index cannot also test `expires_at` (Postgres requires a " +
7799
+ "partial-index predicate to be IMMUTABLE and `now()` is STABLE), so an EXPIRED key still " +
7800
+ "holds the slot and the next mint for that agent answers 422 'agent already has an active " +
7801
+ "key with this role'. Measured on story d9975b31: place_dispatch returned exactly that and " +
7802
+ "wrote nothing.\n\n" +
7803
+ "WAITING ALSO WORKS, SLOWLY. RevokeExpiredDispatchesWorker sweeps expired dispatches every " +
7804
+ "minute and RevokeExpiredApiKeysWorker sweeps expired keys every five, so a stranded " +
7805
+ "credential clears at its TTL — four hours for a placement session dispatch. Use this to " +
7806
+ "not wait. For a PARKED STORY prefer force_unclaim_story: it revokes that story's own " +
7807
+ "session dispatch on its way past, and it frees the stage as well.\n\n" +
7808
+ "IT DOES NOT CLEAR `implementer_dispatch_id`. That is custody provenance, and loopctl " +
7809
+ "resolves it with a read that returns a REVOKED row exactly as it returns a live one, so " +
7810
+ "verify / report / review-complete compare the same lineage afterwards. Revoking kills the " +
7811
+ "credential and changes no custody verdict.\n\n" +
7812
+ "REFUSALS. `role: :orchestrator` WITH the hierarchy, so an orchestrator, user or superadmin " +
7813
+ "key passes and an agent key is 403 insufficient_role — this is not exact_role, so no " +
7814
+ "particular env var is pinned; LOOPCTL_USER_KEY is merely PREFERRED, because of the " +
7815
+ "ceiling below. 403 custody_tier_required on an agent-rooted tenant. " +
7816
+ "403 dispatch_outside_caller_lineage " +
7817
+ "when the target is not in your lineage: a dispatch may only be revoked by a caller it " +
7818
+ "descends from, because the revoke cascades — the body carries your own dispatch id as " +
7819
+ "remediation.your_dispatch_id, and the tenant's user-role operator key may revoke anywhere " +
7820
+ "in its tenant. 403 unlineaged_revoke_forbidden when your key was minted by NO dispatch " +
7821
+ "and is below user role — a legacy LOOPCTL_ORCH_KEY is exactly that: it carries no " +
7822
+ "lineage, so there is no subtree that would bound a cascading revoke, and it would " +
7823
+ "otherwise be able to revoke the tenant's root. Use the user-role operator key, or " +
7824
+ "force_unclaim_story for a parked story, or mint a dispatch under an active parent and " +
7825
+ "revoke from inside that lineage. " +
7826
+ "503 tenant_halted under a custody halt. 404 for an unknown dispatch, and " +
7827
+ "for another tenant's. A `dispatch_id` that is not a UUID is refused here, before any " +
7828
+ "call.\n\n" +
7829
+ "IDEMPOTENT: an already-revoked dispatch answers 200 with revoked_count 0 and its ORIGINAL " +
7830
+ "revoked_at. No request body.",
7831
+ inputSchema: {
7832
+ type: "object",
7833
+ properties: {
7834
+ dispatch_id: {
7835
+ type: "string",
7836
+ description:
7837
+ "UUID of the dispatch to revoke, together with every descendant. From the " +
7838
+ "`dispatch` tool's response, from `implementer_dispatch_id` on a story, or from " +
7839
+ "GET /api/v1/dispatches.",
7840
+ },
7841
+ },
7842
+ required: ["dispatch_id"],
7843
+ },
7844
+ },
7845
+
7641
7846
  // Issue #809: runner enrollment and the Presence pool (user key)
7642
7847
  {
7643
7848
  name: "runner_enroll",
@@ -7768,6 +7973,17 @@ const TOOLS = [
7768
7973
  "Optional. The idempotency key. Generated when absent; pass the id you used " +
7769
7974
  "before to RETRY rather than start a second session.",
7770
7975
  },
7976
+ repo: {
7977
+ type: "string",
7978
+ description:
7979
+ "Optional. `owner/name` of the repository the session works in, OVERRIDING the " +
7980
+ "repository loopctl would derive from the project's intake source. Pass " +
7981
+ "`base_branch` alongside it: the derivation supplies both from one intake " +
7982
+ "source, so naming a repository whose base branch loopctl still has to guess " +
7983
+ "is how a session gets cut from the wrong trunk. Required in practice on a " +
7984
+ "project bound to NO intake source (409 `no_intake_source`) or to TWO (409 " +
7985
+ "`ambiguous_intake_source`) — both refusals name these two parameters.",
7986
+ },
7771
7987
  branch: { type: "string", description: "Optional. The branch the session works on." },
7772
7988
  base_branch: { type: "string", description: "Optional. The branch it is cut from." },
7773
7989
  wall_clock_seconds: { type: "integer", description: "Optional. Overrides the default." },
@@ -8586,6 +8802,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8586
8802
  case "create_story":
8587
8803
  return await createStory(args);
8588
8804
 
8805
+ case "update_story":
8806
+ return await updateStory(args);
8807
+
8589
8808
  case "import_stories":
8590
8809
  return await importStories(args);
8591
8810
 
@@ -8874,9 +9093,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8874
9093
  case "list_routes":
8875
9094
  return await listRoutes();
8876
9095
 
9096
+ case "mcp_version":
9097
+ return await mcpVersion(args);
9098
+
8877
9099
  case "dispatch":
8878
9100
  return await createDispatch(args);
8879
9101
 
9102
+ case "revoke_dispatch":
9103
+ return await revokeDispatch(args);
9104
+
8880
9105
  case "runner_enroll":
8881
9106
  return await runnerEnroll(args);
8882
9107
 
@@ -50,6 +50,14 @@ function refuse(body) {
50
50
  /**
51
51
  * A client-side shape check on the ids that go into a URL PATH: `story_id` and `runner_id`.
52
52
  *
53
+ * EXPORTED, because it is one check and not a pattern to copy. `update_story`
54
+ * (`lib/story-update.js`) interpolates a `story_id` into a path exactly as the verbs below do
55
+ * and shipped without it, which is the way a rule stated in a comment decays: the comment says
56
+ * "any argument interpolated into a URL path", and the check was reachable only from this file.
57
+ * A second implementation would give the same parameter two different refusals depending on
58
+ * which tool took it — the thing the paragraph on `place_dispatch`'s `story_id` below already
59
+ * refuses to do.
60
+ *
53
61
  * NOT on `place_dispatch`'s `dispatch_id`, which is an id and is forwarded unchecked. That is a
54
62
  * decision and not an omission: it travels in the request BODY, and `Placement.place/4` casts it
55
63
  * with `fetch_uuid/2` (`lib/loopctl/delivery/placement.ex:786-791`) as the second clause of its
@@ -104,7 +112,7 @@ function refuse(body) {
104
112
  */
105
113
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
106
114
 
107
- function uuid(value, field) {
115
+ export function uuid(value, field) {
108
116
  if (typeof value !== "string" || value.trim() === "") {
109
117
  return refuse(`\`${field}\` is required.`);
110
118
  }
@@ -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
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `mcp_version` — the running package's version, and loopctl's expectation of it (#846.7).
3
+ *
4
+ * ## THE DEFECT
5
+ *
6
+ * MCP binds at session start. A tool merged, green and deployed is still not callable until
7
+ * the package publishes AND the session reconnects, and from inside that session the two
8
+ * states "this tool does not exist" and "this tool exists and my process is older than it" are
9
+ * IDENTICAL: both are a name that is not in the surface. loopctl #861 is the measured case —
10
+ * `force_unclaim_story` merged at 00:32:45Z on 2026-09-16 and the session that needed it to
11
+ * free a parked story reported the capability as MISSING while it was merely STALE.
12
+ *
13
+ * The remedy is the one the runner contract already uses. A runner sends its
14
+ * `contract_version` on join and loopctl answers with its own
15
+ * (`lib/loopctl_web/runner_channel.ex:155`, `{:ok, %{contract_version:
16
+ * RunnerContract.version()}}`), so a runner holding a vendored 1.11.0 against a server
17
+ * speaking 1.12.0 KNOWS it is behind instead of discovering it through a missing field. Both
18
+ * sides publish; neither has to infer.
19
+ *
20
+ * ## WHY A TOOL OF ITS OWN, RATHER THAN A FIELD ON AN EXISTING CALL
21
+ *
22
+ * The version is ALREADY published on this connection and it reaches nobody who needs it:
23
+ * `new Server({ name: "loopctl", version: SERVER_VERSION }, …)` in `index.js` puts it in the
24
+ * MCP handshake, which the model never sees. So "report it on the tool surface" has to mean
25
+ * something a session can CALL, and three properties decide which:
26
+ *
27
+ * - IT MUST ANSWER WITH NO KEY. The staleness question arises exactly when calls are
28
+ * failing, and every other cheap call needs a configured key of some role. This one reads
29
+ * `/.well-known/loopctl`, which is unauthenticated (`lib/loopctl_web/router.ex:113-118`,
30
+ * `pipe_through :api` with no auth pipeline), through `publicApiCall` — so it answers on a
31
+ * process with nothing configured at all.
32
+ * - A FIELD IS ONLY READ BY SOMEONE ALREADY MAKING THAT CALL. Bolting the version onto
33
+ * `list_routes` answers a session that had another reason to list routes, which is not the
34
+ * session in trouble, and it changes that tool's response shape for every existing caller.
35
+ * - THE TOOL'S OWN ABSENCE IS PART OF THE ANSWER. A name missing from the surface is the one
36
+ * signal a stale process can still emit: no `mcp_version` means the process predates
37
+ * 2.99.0. A field cannot be absent in a way anyone notices without first making the call
38
+ * that carries it.
39
+ *
40
+ * ## WHAT `expected` ACTUALLY IS — stated precisely, because it is not "the npm latest"
41
+ *
42
+ * `GET /.well-known/loopctl` returns `mcp_server.npm_version`, read by the DEPLOYED SERVER at
43
+ * ITS compile time from this repository's own `mcp-server/package.json`
44
+ * (`lib/loopctl_web/controllers/well_known_controller.ex:20-27`, an `@external_resource` +
45
+ * `File.read` at module compile). So `expected` is the version in the tree that deployment was
46
+ * built from — not a registry lookup, and this client never talks to npm.
47
+ *
48
+ * Two consequences a caller must not be surprised by:
49
+ *
50
+ * - `behind` can be true for a few minutes with nothing to install yet: a merge to master
51
+ * that bumps `mcp-server/package.json` triggers the deploy and
52
+ * `.github/workflows/mcp-autopublish.yml` INDEPENDENTLY, so the deployed server can
53
+ * advertise a version npm has not finished publishing. The remedy does not change —
54
+ * reconnect, once it is published — but "behind" is not by itself proof that a newer
55
+ * package is installable this second.
56
+ * - `ahead` is normal on a development machine running the package from a checkout, and it
57
+ * means the deployment is older than your client rather than anything being wrong.
58
+ *
59
+ * NEITHER SIDE IS A GATE. Nothing here refuses a call, and nothing should: this is a report so
60
+ * that a session stops guessing, exactly as the join reply is for a runner.
61
+ */
62
+
63
+ import { readFileSync } from "node:fs";
64
+ import { fileURLToPath } from "node:url";
65
+ import path from "node:path";
66
+
67
+ /** RFC 8615 discovery document. Unauthenticated; see the header. */
68
+ export const DISCOVERY_PATH = "/.well-known/loopctl";
69
+
70
+ /**
71
+ * The version of the `package.json` this module ships beside.
72
+ *
73
+ * SINGLE SOURCE OF TRUTH, and the reason this is a function rather than a literal is the whole
74
+ * of #846.7 one layer down: a hardcoded constant that drifts from the package reports a
75
+ * version nobody is running. npm always includes `package.json` in the published tarball, so
76
+ * this read works from an `npx` install exactly as it does from a checkout. `index.js` derives
77
+ * its handshake version from here too, so the handshake, this tool and the package cannot
78
+ * disagree.
79
+ */
80
+ export function packageVersion(readFile = readFileSync) {
81
+ const here = path.dirname(fileURLToPath(import.meta.url));
82
+ return JSON.parse(readFile(path.join(here, "..", "package.json"), "utf8")).version;
83
+ }
84
+
85
+ /**
86
+ * Compare two dot-separated versions numerically.
87
+ *
88
+ * Returns -1 / 0 / 1, or `null` when either side is not a version this can order. A
89
+ * prerelease suffix (`2.98.0-rc.1`) is compared on its numeric prefix alone and the suffix is
90
+ * ignored: this package has never published one, and inventing a precedence rule for a case
91
+ * that does not occur would be a claim the tests could not check.
92
+ */
93
+ export function compareVersions(a, b) {
94
+ const parse = (v) => {
95
+ if (typeof v !== "string") return null;
96
+ const core = v.trim().split(/[-+]/)[0];
97
+ const parts = core.split(".");
98
+ if (parts.length === 0 || !parts.every((p) => /^\d+$/.test(p))) return null;
99
+ return parts.map(Number);
100
+ };
101
+
102
+ const left = parse(a);
103
+ const right = parse(b);
104
+ if (!left || !right) return null;
105
+
106
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
107
+ const l = left[i] ?? 0;
108
+ const r = right[i] ?? 0;
109
+ if (l !== r) return l < r ? -1 : 1;
110
+ }
111
+ return 0;
112
+ }
113
+
114
+ const REMEDY = {
115
+ current:
116
+ "Nothing to do. Because these agree, a tool you cannot see genuinely does not exist in " +
117
+ "this version — look for it under another name, or file it. Do not wait for it to appear.",
118
+ behind:
119
+ "Your MCP process is older than the tool surface loopctl ships. A tool you cannot see may " +
120
+ "well exist: run /mcp to reconnect, or restart the session. DO NOT RETRY THE CALL — MCP " +
121
+ "binds its tool list at session start, so the same process will answer the same way for " +
122
+ "ever. If the reconnect does not pick it up, npm may not have finished publishing that " +
123
+ "version yet (the deploy and the publish run independently); wait and reconnect again.",
124
+ ahead:
125
+ "Your MCP process is NEWER than the deployment's own tree, which is normal when running " +
126
+ "this package from a checkout. Nothing to do about the client; a tool it declares may " +
127
+ "call an endpoint this deployment does not serve yet.",
128
+ unknown:
129
+ "loopctl's expected version could not be read, so staleness is undecided — this says " +
130
+ "nothing about whether a missing tool exists. Check LOOPCTL_SERVER and the network. The " +
131
+ "running version above is still exact.",
132
+ };
133
+
134
+ /**
135
+ * What `result.body` adds to a failed discovery request, as a sentence to append.
136
+ *
137
+ * `status 0` ALONE IS NOT A DIAGNOSIS, and this tool exists for exactly the session where the
138
+ * diagnosis is the whole answer. `publicApiCall` uses status 0 for every local failure and puts
139
+ * the cause in `body`: `Network error: <message> (<cause>)` — which is where an `ENOTFOUND` or a
140
+ * `ECONNREFUSED` lives — or `Request timed out after 30s` (`index.js`, its `catch` around
141
+ * `fetch`). A caller reading only the 0 learns that no server answered and nothing about why,
142
+ * so a typo'd LOOPCTL_SERVER, a dead network and a slow one are one message. An HTTP failure
143
+ * puts the server's own error body there instead, which is equally the thing to read.
144
+ *
145
+ * Bounded and never trusted to be a string: `apiCall`-shaped bodies are parsed JSON as often as
146
+ * text, and this string goes into a tool result a session reads.
147
+ */
148
+ function cause(body) {
149
+ if (body === undefined || body === null || body === "") return "";
150
+
151
+ const text = typeof body === "string" ? body : safeJson(body);
152
+ if (!text) return "";
153
+
154
+ const trimmed = text.replace(/\s+/g, " ").trim();
155
+ if (trimmed === "") return "";
156
+
157
+ return ` ${trimmed.length > 300 ? `${trimmed.slice(0, 300)}… (truncated)` : trimmed}`;
158
+ }
159
+
160
+ function safeJson(value) {
161
+ try {
162
+ return JSON.stringify(value);
163
+ } catch {
164
+ // A circular or otherwise unserialisable body must not turn a version report into a
165
+ // throw — the whole contract of this tool is that it always answers.
166
+ return "";
167
+ }
168
+ }
169
+
170
+ /**
171
+ * `mcp_version`: what this process is running, what loopctl expects, and what the difference
172
+ * means.
173
+ *
174
+ * Never an error result. A discovery document that cannot be read yields `status: "unknown"`
175
+ * WITH the running version, because half the answer is the half the caller most needs and a
176
+ * version report that fails when the network does would be useless in the situation it is for.
177
+ */
178
+ export async function mcpVersion(_args = {}, { publicApiCall, version, baseUrl } = {}) {
179
+ const running = version ?? packageVersion();
180
+
181
+ let expected = null;
182
+ let detail = null;
183
+
184
+ const result = await publicApiCall("GET", DISCOVERY_PATH, null);
185
+
186
+ if (result && result.error) {
187
+ detail = `loopctl discovery request failed (status ${result.status}).${cause(result.body)}`;
188
+ } else {
189
+ expected = result?.mcp_server?.npm_version ?? null;
190
+ if (!expected) {
191
+ detail =
192
+ "loopctl's discovery document carried no mcp_server.npm_version. That field is part " +
193
+ "of the published schema, so its absence means an older deployment.";
194
+ }
195
+ }
196
+
197
+ const order = expected === null ? null : compareVersions(running, expected);
198
+ const status =
199
+ order === null ? "unknown" : order < 0 ? "behind" : order > 0 ? "ahead" : "current";
200
+
201
+ if (status === "unknown" && expected !== null && detail === null) {
202
+ detail = `Neither "${running}" nor "${expected}" could be ordered as a version.`;
203
+ }
204
+
205
+ return {
206
+ running,
207
+ expected,
208
+ status,
209
+ remedy: REMEDY[status],
210
+ // WHICH deployment answered. `expected` is that deployment's own tree, so a session
211
+ // pointed at a staging LOOPCTL_SERVER is comparing against staging and not production,
212
+ // and a verdict with no server named cannot be acted on.
213
+ server: baseUrl ?? null,
214
+ ...(detail ? { detail } : {}),
215
+ };
216
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * `update_story` — `PATCH /api/v1/stories/:id` (loopctl #846.6).
3
+ *
4
+ * WHY IT EXISTS. The endpoint has been served since before this package's write surface was
5
+ * built (`lib/loopctl_web/router.ex:477`, `StoryController, :update`) and no MCP tool reached
6
+ * it, so a session that filed a story and then found its own severity wrong could not correct
7
+ * it. `create_story`, `import_stories` and `backfill_story` were the whole write surface;
8
+ * `import_stories` with `merge: true` posts a WHOLE EPIC payload, so correcting one field
9
+ * means resending every sibling and risking them. That is the rule in loopctl's CLAUDE.md —
10
+ * an operator-facing endpoint is not done until an MCP tool calls it — failing for the third
11
+ * time on one workflow.
12
+ *
13
+ * WHAT THE ENDPOINT ACTUALLY ACCEPTS, read rather than assumed. `StoryController.update/2`
14
+ * (`lib/loopctl_web/controllers/story_controller.ex:429-455`) builds its attrs from exactly
15
+ * five params — `title`, `description`, `acceptance_criteria`, `estimated_hours`, `metadata`
16
+ * (`:434-440`) — and then DROPS every nil (`Map.reject`, `:443`). `Story.update_changeset/2`
17
+ * casts the same five (`lib/loopctl/work_breakdown/story.ex:189-195`). Nothing else on a story
18
+ * is reachable here: `number` cannot change after creation, and the two status fields have
19
+ * their own custody endpoints.
20
+ *
21
+ * THREE CONSEQUENCES OF THAT `Map.reject`, all of them things a caller would otherwise learn
22
+ * from a 200 that changed nothing:
23
+ *
24
+ * 1. A field cannot be NULLED through this endpoint. `description: null` is dropped before
25
+ * the changeset sees it, so it is a no-op and not an erase.
26
+ * 2. An UNPARSEABLE `estimated_hours` is silently ignored: `parse_decimal/1`
27
+ * (`story_controller.ex:530-538`) answers nil for a string `Decimal.parse/1` rejects, and
28
+ * nil is then dropped. The request answers 200 with the old value. That is the one shape
29
+ * refused locally below — the same reasoning as the UUID check in `lib/delivery-loop.js`:
30
+ * the server's answer cannot be told apart from success, and a client check can.
31
+ * 3. A request naming NO updatable field is a 200 that writes an audit entry and changes
32
+ * nothing (`Stories.update_story/4` logs `action: "updated"` unconditionally,
33
+ * `lib/loopctl/work_breakdown/stories.ex:213-249`). Refused here.
34
+ *
35
+ * ## `metadata` IS REPLACED WHOLE, AND THAT IS THE HAZARD THIS TOOL MUST ANNOUNCE
36
+ *
37
+ * `metadata` is an ordinary `:map` field (`story.ex:84`) in the cast list above, so the map
38
+ * sent REPLACES the stored one — there is no merge anywhere on this path. A caller sending
39
+ * `{"severity": "high"}` to a story whose metadata holds six keys keeps one and loses five,
40
+ * and the response is a 200.
41
+ *
42
+ * That is not merely lossy, it is the laundering hazard loopctl's CLAUDE.md documents. The
43
+ * story field `lifecycle_entered_at` — the marker that says a story once entered the lifecycle,
44
+ * and therefore may not be backfilled straight to `verified` — lived in `metadata` first, and
45
+ * one ordinary PATCH erased it, restoring the claim -> force-unclaim -> backfill-to-verified
46
+ * launder. It is a COLUMN now (`story.ex:102`) precisely so that no changeset casts it, and
47
+ * the comment above it (`story.ex:95-101`) says never to add it to a cast list. Both halves
48
+ * check out: it is absent from `update_changeset/2`'s cast list above, and from
49
+ * `changeset/2`'s. So this endpoint can no longer erase THAT marker — and a partial `metadata`
50
+ * send still silently drops every other key, which is why the tool description leads with
51
+ * "read the story first, then send the whole map".
52
+ *
53
+ * ## KEY
54
+ *
55
+ * ORCHESTRATOR, with the hierarchy applying: the action is mounted `role: :orchestrator`
56
+ * (`story_controller.ex:29-30`), NOT `exact_role:`, so `RequireRole` admits a `:user` or
57
+ * `:superadmin` key too (`require_role.ex`'s hierarchy clause). Nothing is pinned here for
58
+ * that reason — `LOOPCTL_ORCH_KEY` is passed as an ordinary override, so a global
59
+ * `LOOPCTL_API_KEY` of any sufficient role still wins, exactly as it does for `create_story`
60
+ * and `backfill_story`. The tenant must also be human-anchored (`RequireHumanAnchor`,
61
+ * `story_controller.ex:35-36`): an agent-rooted tenant is refused 403 `custody_tier_required`.
62
+ *
63
+ * ## `story_id` IS SHAPE-CHECKED, ON THE SHARED GUARD AND NOT A SECOND COPY
64
+ *
65
+ * This tool shipped without one while all four verbs in `lib/delivery-loop.js` had it, and the
66
+ * omission made this file's own tool description false: it promises "404 for an unknown story"
67
+ * as that status's only meaning. A malformed id gets the SAME 404.
68
+ * `StoryController.update/2` reaches `Stories.get_story/2`
69
+ * (`lib/loopctl/work_breakdown/stories.ex:185-190`), whose `AdminRepo.get_by(Story, id:
70
+ * story_id, …)` puts the value into a `where` against a `:binary_id` column with no cast; the
71
+ * `Ecto.Query.CastError` that raises is mapped to 404 by loopctl's deliberate backstop
72
+ * (`lib/loopctl_web/plugs/cast_error_handler.ex:32-35`), and the body is
73
+ * `LoopctlWeb.ErrorJSON`'s generic `{"error": {"status": 404, "message": "Not found"}}` — byte
74
+ * for byte what `FallbackController` answers for a well-formed id naming no story
75
+ * (`lib/loopctl_web/fallback_controller.ex:74-78`). Two opposite remedies, one answer.
76
+ *
77
+ * The check is `uuid()`, IMPORTED from `lib/delivery-loop.js` rather than reimplemented, so the
78
+ * refusal a caller reads for a bad `story_id` does not depend on which tool they reached for.
79
+ * Its reasoning — why the refusal carries `status: 0` and never echoes the value — is the long
80
+ * comment over it there, and it is not restated here.
81
+ */
82
+
83
+ import { uuid as uuidRefusal } from "./delivery-loop.js";
84
+
85
+ const UPDATABLE = ["title", "description", "acceptance_criteria", "estimated_hours", "metadata"];
86
+
87
+ function refuse(body) {
88
+ return { error: true, status: 0, body };
89
+ }
90
+
91
+ export function storyPath(storyId) {
92
+ return `/api/v1/stories/${encodeURIComponent(storyId)}`;
93
+ }
94
+
95
+ /**
96
+ * Build the PATCH body from the fields the caller actually named.
97
+ *
98
+ * `undefined` means "not named" and is omitted. `null` is NOT silently omitted: it is refused,
99
+ * because a caller writing it means to clear the field and the server would answer 200 having
100
+ * dropped it (`Map.reject`, `story_controller.ex:443`).
101
+ */
102
+ export function updateBody(args = {}) {
103
+ const body = {};
104
+
105
+ for (const field of UPDATABLE) {
106
+ const value = args[field];
107
+ if (value === undefined) continue;
108
+ if (value === null) {
109
+ return {
110
+ error: `\`${field}\` cannot be set to null through this endpoint. The controller drops ` +
111
+ `every nil before the changeset sees it, so the request would answer 200 with the ` +
112
+ `field unchanged. Send the value you want, or leave the field out.`,
113
+ };
114
+ }
115
+ body[field] = value;
116
+ }
117
+
118
+ return { body };
119
+ }
120
+
121
+ /**
122
+ * `PATCH /api/v1/stories/:id` — correct a filed story's title, description, acceptance
123
+ * criteria, estimate or metadata.
124
+ */
125
+ export async function updateStory(args = {}, { apiCall } = {}) {
126
+ const bad = uuidRefusal(args.story_id, "story_id");
127
+ if (bad) return bad;
128
+
129
+ const { story_id } = args;
130
+
131
+ const { body, error } = updateBody(args);
132
+ if (error) return refuse(error);
133
+
134
+ if (Object.keys(body).length === 0) {
135
+ return refuse(
136
+ "Nothing to update. Name at least one of: " +
137
+ UPDATABLE.join(", ") +
138
+ ". A request carrying none of them is a 200 that changes nothing and still writes an " +
139
+ "audit entry.",
140
+ );
141
+ }
142
+
143
+ if (body.estimated_hours !== undefined && !numeric(body.estimated_hours)) {
144
+ return refuse(
145
+ "`estimated_hours` must be a number, or a string a decimal parser accepts. loopctl " +
146
+ "parses it with Decimal.parse/1 and DROPS the field when that fails, so an " +
147
+ "unparseable value answers 200 with the estimate unchanged — indistinguishable from " +
148
+ "success.",
149
+ );
150
+ }
151
+
152
+ if (body.metadata !== undefined && !plainObject(body.metadata)) {
153
+ return refuse(
154
+ "`metadata` must be an object. The server validates the same thing and answers 422 " +
155
+ "(`must be a map`); refusing here says so without a round trip.",
156
+ );
157
+ }
158
+
159
+ return apiCall("PATCH", storyPath(story_id), body);
160
+ }
161
+
162
+ function numeric(value) {
163
+ if (typeof value === "number") return Number.isFinite(value);
164
+ if (typeof value !== "string") return false;
165
+ return /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/.test(value.trim());
166
+ }
167
+
168
+ function plainObject(value) {
169
+ return typeof value === "object" && value !== null && !Array.isArray(value);
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.97.0",
3
+ "version": "2.99.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",