loopctl-mcp-server 2.98.0 → 2.100.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)
@@ -502,11 +504,11 @@ Enroll the dev machines that run the agent delivery loop, see which are connecte
502
504
 
503
505
  | Tool | Description |
504
506
  |---|---|
505
- | `runner_enroll` | Enroll this machine as a runner (`POST /api/v1/runners`). Required: `name` (the machine name the runner declares when it joins) and `token_file` (absolute, or starting with `~/`). Optional: `max_sessions` (1..64, default 2) — how many dispatches loopctl keeps in flight on this machine at once; the tenant's total across its runners is capped separately by the server. The credential is written to `token_file` with mode 0600 and is **never returned**: the result is only `{ runner: {id, name, inserted_at}, token_file }`, because a tool result lands in the transcript and the token lets its holder join as that machine. The file is created exclusively, so an existing path (or a symlink there) is refused before anything is enrolled; missing parent directories are created 0700. If the token cannot be written after enrollment, the runner is revoked before the error returns. Only a 4xx is treated as a refusal: any other failure (timeout, 5xx, a 2xx that did not parse) may have enrolled the runner, so its response body is never echoed, the runner is revoked when the response proves its id, and otherwise the error points at `runner_list` and `runner_revoke`. 422 (name malformed or taken) and 403 (`custody_tier_required`, `api_key_mint_forbidden`) pass through with their code. |
507
+ | `runner_enroll` | Enroll this machine as a runner (`POST /api/v1/runners`). Required: `name` (the machine name the runner declares when it joins) and `token_file` (absolute, or starting with `~/`). Optional: `max_sessions` (1..64, default 2) — the CEILING on how many dispatches loopctl will ever keep in flight on this machine at once, and the value it starts at. Since runner contract 1.13.0 every join re-applies the machine's own declared `max_sessions` bounded by this one, so a machine may take itself down below its grant and cannot raise itself above it: to carry FEWER sessions change `control.max_sessions` in the runner's own config and reconnect, to carry MORE `runner_revoke` it first and then enrol it again (nothing widens the ceiling in place, and enrolling the same name while its runner is active is a 422; the revoke invalidates the credential, so the new token file must reach the machine and the runner be restarted). The tenant's total across its runners is capped separately by the server. The credential is written to `token_file` with mode 0600 and is **never returned**: the result is only `{ runner: {id, name, inserted_at}, token_file }`, because a tool result lands in the transcript and the token lets its holder join as that machine. The file is created exclusively, so an existing path (or a symlink there) is refused before anything is enrolled; missing parent directories are created 0700. If the token cannot be written after enrollment, the runner is revoked before the error returns. Only a 4xx is treated as a refusal: any other failure (timeout, 5xx, a 2xx that did not parse) may have enrolled the runner, so its response body is never echoed, the runner is revoked when the response proves its id, and otherwise the error points at `runner_list` and `runner_revoke`. 422 (name malformed or taken) and 403 (`custody_tier_required`, `api_key_mint_forbidden`) pass through with their code. |
506
508
  | `runner_list` | List enrolled runners (`GET /api/v1/runners`). Optional: `include_revoked`. Enrollment only; connection state is `runner_pool`. |
507
509
  | `runner_revoke` | Revoke a runner (`DELETE /api/v1/runners/:id`): its credential stops authenticating and its live socket is disconnected. The undo for `runner_enroll`. Idempotent. Required: `id`. |
508
- | `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report, a hint. Presence converges only within a cluster. |
509
- | `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `branch`, `base_branch`, `wall_clock_seconds`, `max_turns`. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
510
+ | `runner_pool` | The tenant's connected runners from Presence (`GET /api/v1/runners/pool`): per machine name, `runner_id`, `joined_at`, `draining`, the latest `sample`, `live_sockets` (above 1 means more than one process holds the credential), and the `node` and `machine_id` (Fly Machine) holding the socket. `in_flight` and `max_sessions` are the capacity loopctl holds in Postgres — what dispatch reserves against — and are null only for a runner revoked while its socket drains; `reported_in_flight` and `reported_max_sessions` are the runner's own last report: `reported_in_flight` is a hint (the runner counts sessions, loopctl counts reservations), while `reported_max_sessions` is what `max_sessions` is copied from on every join (contract 1.13.0), capped at `enrolled_max_sessions` which is also returned. The two can differ in EITHER direction. `max_sessions` BELOW it happens when the machine is declaring above its enrolled ceiling (`enrolled_max_sessions` is what makes that one visible in the payload; the rest are not), when the declaration write has not LANDED — it can fail under lock contention on the runner row and is retried on that socket's 30-second recheck, downward only — or when the socket joined a node older than contract 1.13.0 and has not reconnected since. `max_sessions` ABOVE it happens when the machine declared `0`, held as `1` because the column is 1..64 while this field shows the raw `0`, and again when a declaration write has not landed and the row keeps an older, larger number. A `reported_max_sessions` of 0 means the machine is taking no work and `place_dispatch` refuses a new placement on it, like `draining`. Presence converges only within a cluster. |
511
+ | `place_dispatch` | **Place a queued story on a runner** (`POST /api/v1/runners/:runner_id/dispatches`): claims it under a freshly minted custody dispatch and pushes the work. The control-side trigger of the delivery loop. 409 `runner_declines_work` when the machine declares `draining` or `max_sessions` 0 — refused before the mint, so nothing was claimed; a retry carrying a `dispatch_id` loopctl already holds is re-sent anyway, because its claim is already standing. Required: `story_id`, `runner_id` (from `runner_pool`). Optional: `kind` (`implement`, the only kind this tool sends — `triage` is dispatchable on the contract but placement CLAIMS the story, which triage must not do), `dispatch_id` (the idempotency key — generated when absent; pass the SAME one to retry rather than start a second session), `branch`, `base_branch`, `wall_clock_seconds`, `max_turns`. The story object is NOT a parameter: loopctl builds it from its own rows and refuses a caller-supplied one. The story must be `contracted` with its stage at `queued`. Requires `LOOPCTL_USER_KEY`. |
510
512
  | `story_stage` | **Where a story is in the delivery machine** (`GET /api/v1/stories/:id/stage`): stage, `claim_epoch`, `lock_version`, `attempts`, the runner holding it, and the escalation reason when parked. `stage: null` means the loop has never touched it. This is how you watch a run, and the only way to see why a stage report was refused `stale_stage`. `escalation_reason` is untrusted session text. Required: `story_id`. |
511
513
  | `resolve_escalation` | **Move an escalated story off `escalated`, as a human** (`POST /api/v1/stories/:id/stage/resolve`): `to` is `queued` (work it again), `done` or `failed`. The other half of `escalate_story` — without it a parked story stays parked for ever. Requires `LOOPCTL_USER_KEY` on a key no dispatch minted: a session cannot resolve the escalation it raised. Optional: `reason`. |
512
514
  | `force_unclaim_story` | **Take a story back from the agent holding it** (`POST /api/v1/stories/:id/force-unclaim`). Resets it to `agent_status: pending` with `assigned_agent_id` cleared AND makes the delivery stage row follow the release back to `queued`. **It frees the STAGE; it does not make the story placeable.** `place_dispatch` wants `contracted` + `queued` (see its own row in this table), and the release leaves the story at `pending`, whose only transition is `pending -> contracted` — so running `place_dispatch` next answers the identical 409 `invalid_transition`. The remedy is three calls, in this order: `force_unclaim_story`, then `contract_story`, then `place_dispatch`. A story parked at `claimed` with nobody on it is the residue of a compensation that did not complete, not what a refused dispatch normally leaves: placement releases the claim inline when a runner refuses, and the claim lease releases it unattended once `claimed_until` passes — reach for this to get it back now, or when both of those left it held. Requeues from any stage a claim holds; a stage no claim holds keeps its stage and is rebound to the new epoch; `done` and `failed` are untouched. For an ESCALATED story use `resolve_escalation` instead — that one releases AND re-contracts, so its `queued` really is placeable. Requires an orchestrator-ROLE key: the action is `exact_role: :orchestrator`, so a user or superadmin key is 403'd like any other non-member. Put it in `LOOPCTL_ORCH_KEY` (which is then the key sent, and a global `LOOPCTL_API_KEY` does not displace it) or, with no orchestrator key set, in `LOOPCTL_API_KEY`. The orchestrator key must be linked to a registered agent (400 otherwise) and the tenant must be human-anchored (403 `custody_tier_required` otherwise). Does not touch `verified_status`. Safe to run twice: on an already-pending story the stage row is written only if it is stranded behind the story's claim epoch or still in flight. No request body. Required: `story_id` (refused locally if it is not a UUID). |
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,15 +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";
57
58
  import { revokeDispatch as revokeDispatchRequest } from "./lib/dispatch-revoke.js";
58
59
 
59
60
  // Single source of truth for the server version: the package.json this file
60
61
  // ships with (npm always includes package.json in the published tarball).
61
62
  // Keeping it derived prevents the handshake version from drifting from the
62
- // published package version.
63
- const SERVER_VERSION = JSON.parse(
64
- readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")
65
- ).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();
66
66
 
67
67
  // ---------------------------------------------------------------------------
68
68
  // HTTP helper — witness protocol state
@@ -3289,6 +3289,36 @@ async function forceUnclaimStory(args) {
3289
3289
  );
3290
3290
  }
3291
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
+
3292
3322
  // US-26: Signed Tree Head retrieval
3293
3323
  async function getSth({ tenant_id }) {
3294
3324
  const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
@@ -4047,6 +4077,69 @@ const TOOLS = [
4047
4077
  required: ["story"],
4048
4078
  },
4049
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
+ },
4050
4143
  {
4051
4144
  name: "import_stories",
4052
4145
  description:
@@ -7593,6 +7686,35 @@ const TOOLS = [
7593
7686
  required: [],
7594
7687
  },
7595
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
+ },
7596
7718
 
7597
7719
  // Dispatch Tool (US-26.2.3)
7598
7720
  {
@@ -7755,8 +7877,18 @@ const TOOLS = [
7755
7877
  minimum: 1,
7756
7878
  maximum: 64,
7757
7879
  description:
7758
- "How many dispatches loopctl keeps in flight on this machine at once (default 2). " +
7759
- "The tenant's total across all its runners is capped separately by the server.",
7880
+ "The CEILING on how many dispatches loopctl will ever keep in flight on this " +
7881
+ "machine at once (default 2), and the value it starts at. Since runner contract " +
7882
+ "1.13.0 every join re-applies the machine's own declared max_sessions BOUNDED BY " +
7883
+ "this one, so a machine may take itself down below its grant and cannot raise " +
7884
+ "itself above it. To make a machine carry FEWER sessions, change " +
7885
+ "control.max_sessions in the runner's own config and reconnect it — no call here " +
7886
+ "is needed. To let it carry MORE than its grant, runner_revoke it FIRST and then " +
7887
+ "enrol it again: nothing widens this in place, and enrolling the same machine name " +
7888
+ "while its current runner is active is a 422. The revoke invalidates the " +
7889
+ "credential, so the new token_file must reach the machine and the runner be " +
7890
+ "restarted. The tenant's total across all its runners is capped separately by " +
7891
+ "the server.",
7760
7892
  },
7761
7893
  },
7762
7894
  required: ["name", "token_file"],
@@ -7800,7 +7932,23 @@ const TOOLS = [
7800
7932
  "and machine_id (Fly Machine) holding the socket. in_flight and max_sessions are the " +
7801
7933
  "CAPACITY loopctl holds in Postgres — the slots dispatch reserves against — and are null " +
7802
7934
  "only for a runner revoked while its socket drains; reported_in_flight and " +
7803
- "reported_max_sessions are what the runner itself last reported, a hint. live_sockets " +
7935
+ "reported_max_sessions are what the runner itself last reported. reported_in_flight is a " +
7936
+ "hint (the runner counts sessions, loopctl counts reservations); reported_max_sessions is " +
7937
+ "NOT — since contract 1.13.0 loopctl copies it into max_sessions on every join, capped at " +
7938
+ "the runner's enrolled_max_sessions, which is also returned. The two can differ in EITHER " +
7939
+ "direction, so read a difference rather than assuming it. max_sessions BELOW " +
7940
+ "reported_max_sessions happens when the machine is declaring above the ceiling it was " +
7941
+ "enrolled with (enrolled_max_sessions is what makes that one visible in the payload; the " +
7942
+ "rest are not), when the declaration write has not LANDED — it can fail under lock " +
7943
+ "contention " +
7944
+ "on the runner row and is retried on that socket's 30-second recheck, downward only — or " +
7945
+ "when the socket joined a node older than contract 1.13.0 and has not reconnected since. " +
7946
+ "max_sessions ABOVE reported_max_sessions happens when the machine declared 0, which the " +
7947
+ "1..64 column holds as 1 while this field shows the raw 0, and again when a declaration " +
7948
+ "write has not landed and the row keeps an older, larger number. A reported_max_sessions " +
7949
+ "of 0 means the machine is taking no work and place_dispatch refuses a new placement on " +
7950
+ "it, like draining. " +
7951
+ "live_sockets " +
7804
7952
  "above 1 means more than one process holds that runner's credential. A killed runner " +
7805
7953
  "disappears once its socket closes. Presence converges only " +
7806
7954
  "within a cluster, so on an unclustered multi-node deployment a runner on another node is " +
@@ -7826,7 +7974,14 @@ const TOOLS = [
7826
7974
  "IDEMPOTENT on `dispatch_id`, which is generated for you unless you pass one — pass the " +
7827
7975
  "SAME id to retry a call that timed out, or you start a second session on the same " +
7828
7976
  "story. Requires LOOPCTL_USER_KEY: only an unlineaged user key may root the custody " +
7829
- "lineage this mints.",
7977
+ "lineage this mints.\n\n" +
7978
+ "409 `runner_declines_work` means the machine's live socket declares `draining` (or a " +
7979
+ "max_sessions of 0, which says the same thing). NOTHING WAS CLAIMED — it is refused " +
7980
+ "before the mint — so place on another runner rather than repairing anything, with a " +
7981
+ "NEW dispatch_id. Read `draining` and `reported_max_sessions` from runner_pool before " +
7982
+ "choosing a machine. It refuses a NEW placement only: retrying with a dispatch_id " +
7983
+ "loopctl already holds still re-sends that dispatch, because its claim is already " +
7984
+ "standing and a draining machine is asked to finish what it holds, not to take more.",
7830
7985
  inputSchema: {
7831
7986
  type: "object",
7832
7987
  properties: {
@@ -7851,6 +8006,17 @@ const TOOLS = [
7851
8006
  "Optional. The idempotency key. Generated when absent; pass the id you used " +
7852
8007
  "before to RETRY rather than start a second session.",
7853
8008
  },
8009
+ repo: {
8010
+ type: "string",
8011
+ description:
8012
+ "Optional. `owner/name` of the repository the session works in, OVERRIDING the " +
8013
+ "repository loopctl would derive from the project's intake source. Pass " +
8014
+ "`base_branch` alongside it: the derivation supplies both from one intake " +
8015
+ "source, so naming a repository whose base branch loopctl still has to guess " +
8016
+ "is how a session gets cut from the wrong trunk. Required in practice on a " +
8017
+ "project bound to NO intake source (409 `no_intake_source`) or to TWO (409 " +
8018
+ "`ambiguous_intake_source`) — both refusals name these two parameters.",
8019
+ },
7854
8020
  branch: { type: "string", description: "Optional. The branch the session works on." },
7855
8021
  base_branch: { type: "string", description: "Optional. The branch it is cut from." },
7856
8022
  wall_clock_seconds: { type: "integer", description: "Optional. Overrides the default." },
@@ -8669,6 +8835,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8669
8835
  case "create_story":
8670
8836
  return await createStory(args);
8671
8837
 
8838
+ case "update_story":
8839
+ return await updateStory(args);
8840
+
8672
8841
  case "import_stories":
8673
8842
  return await importStories(args);
8674
8843
 
@@ -8957,6 +9126,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
8957
9126
  case "list_routes":
8958
9127
  return await listRoutes();
8959
9128
 
9129
+ case "mcp_version":
9130
+ return await mcpVersion(args);
9131
+
8960
9132
  case "dispatch":
8961
9133
  return await createDispatch(args);
8962
9134
 
@@ -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,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.98.0",
3
+ "version": "2.100.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",