loopctl-mcp-server 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/index.js +260 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -77,7 +77,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
77
77
  | `create_project` | Create a new project in the current tenant. |
78
78
  | `delete_project` | **Requires `LOOPCTL_USER_KEY`.** Delete a project and all of its dependent resources (epics, stories, audit entries). Irreversible — orchestrator role is not sufficient. |
79
79
  | `get_progress` | Get progress summary for a project, including story counts by status. Pass `include_cost=true` for cost data. |
80
- | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). |
80
+ | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). For large payloads, use `payload_path` to read JSON from disk instead of passing it inline. |
81
81
 
82
82
  ### Story Tools
83
83
 
@@ -86,6 +86,8 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
86
86
  | `list_stories` | List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. Pass `include_token_totals=true` for per-story token data. |
87
87
  | `list_ready_stories` | List stories that are ready to be worked on (contracted, dependencies met). |
88
88
  | `get_story` | Get full details for a single story by ID. |
89
+ | `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`). |
90
+ | `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. |
89
91
 
90
92
  ### Workflow Tools (agent key)
91
93
 
package/index.js CHANGED
@@ -12,9 +12,15 @@ import {
12
12
  } from "@modelcontextprotocol/sdk/types.js";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
- // HTTP helper
15
+ // HTTP helper — witness protocol state
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
+ // The witness protocol requires clients to echo back the last-known Signed
19
+ // Tree Head (STH) on every authenticated request. On the very first request
20
+ // we send X-Loopctl-STH-Bootstrap: true to receive the current STH without
21
+ // needing one already. After that we cache and send X-Loopctl-Last-Known-STH.
22
+ let lastKnownSTH = null;
23
+
18
24
  function getBaseUrl() {
19
25
  return (process.env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
20
26
  }
@@ -43,13 +49,22 @@ async function apiCall(method, path, body, keyOverride) {
43
49
  return { error: true, status: 0, body: "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY." };
44
50
  }
45
51
 
52
+ const headers = {
53
+ Authorization: `Bearer ${key}`,
54
+ "Content-Type": "application/json",
55
+ Accept: "application/json",
56
+ };
57
+
58
+ // Witness protocol: send cached STH or request bootstrap
59
+ if (lastKnownSTH) {
60
+ headers["X-Loopctl-Last-Known-STH"] = lastKnownSTH;
61
+ } else {
62
+ headers["X-Loopctl-STH-Bootstrap"] = "true";
63
+ }
64
+
46
65
  const options = {
47
66
  method,
48
- headers: {
49
- Authorization: `Bearer ${key}`,
50
- "Content-Type": "application/json",
51
- Accept: "application/json",
52
- },
67
+ headers,
53
68
  signal: AbortSignal.timeout(30_000),
54
69
  };
55
70
 
@@ -68,6 +83,12 @@ async function apiCall(method, path, body, keyOverride) {
68
83
  return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
69
84
  }
70
85
 
86
+ // Witness protocol: cache the STH from response for subsequent requests
87
+ const sthHeader = response.headers.get("x-loopctl-current-sth");
88
+ if (sthHeader) {
89
+ lastKnownSTH = sthHeader;
90
+ }
91
+
71
92
  if (response.status === 204) {
72
93
  return { ok: true };
73
94
  }
@@ -187,16 +208,158 @@ async function getProgress({ project_id, include_cost }) {
187
208
  return toContent(result);
188
209
  }
189
210
 
190
- async function importStories({ project_id, payload }) {
211
+ async function backfillStory({ story_id, reason, evidence_url, pr_number }) {
212
+ if (!story_id) {
213
+ return toContent({
214
+ error: true,
215
+ status: 0,
216
+ body: "`story_id` is required.",
217
+ });
218
+ }
219
+ if (!reason || typeof reason !== "string" || reason.trim() === "") {
220
+ return toContent({
221
+ error: true,
222
+ status: 0,
223
+ body:
224
+ "`reason` is required. Describe why this story is being marked verified without going through the normal lifecycle (e.g. 'completed before loopctl onboarding, see PR #232').",
225
+ });
226
+ }
227
+
228
+ const body = { reason };
229
+ if (evidence_url) body.evidence_url = evidence_url;
230
+ if (pr_number != null) body.pr_number = pr_number;
231
+
232
+ const result = await apiCall(
233
+ "POST",
234
+ `/api/v1/stories/${story_id}/backfill`,
235
+ body,
236
+ process.env.LOOPCTL_ORCH_KEY
237
+ );
238
+ return toContent(result);
239
+ }
240
+
241
+ async function createStory({ project_id, epic_number, epic_id, story }) {
242
+ if (!story || typeof story !== "object") {
243
+ return toContent({
244
+ error: true,
245
+ status: 0,
246
+ body: "`story` is required and must be an object with at least `number` and `title`.",
247
+ });
248
+ }
249
+
250
+ // Prefer epic_id path if provided, fall back to project_id + epic_number.
251
+ if (epic_id) {
252
+ const result = await apiCall(
253
+ "POST",
254
+ `/api/v1/epics/${epic_id}/stories`,
255
+ story,
256
+ process.env.LOOPCTL_ORCH_KEY
257
+ );
258
+ return toContent(result);
259
+ }
260
+
261
+ if (!project_id || epic_number == null) {
262
+ return toContent({
263
+ error: true,
264
+ status: 0,
265
+ body:
266
+ "Must provide either `epic_id` OR (`project_id` + `epic_number`). " +
267
+ "Use epic_number when you know the epic's human-readable number (e.g. 72) but not its UUID.",
268
+ });
269
+ }
270
+
271
+ const body = { epic_number, ...story };
191
272
  const result = await apiCall(
192
273
  "POST",
193
- `/api/v1/projects/${project_id}/import`,
194
- payload,
274
+ `/api/v1/projects/${project_id}/stories`,
275
+ body,
195
276
  process.env.LOOPCTL_ORCH_KEY
196
277
  );
197
278
  return toContent(result);
198
279
  }
199
280
 
281
+ async function importStories({ project_id, payload, payload_path, merge }) {
282
+ const effectivePayload = await resolvePayload(payload, payload_path);
283
+ if (effectivePayload && effectivePayload.error) {
284
+ return toContent(effectivePayload);
285
+ }
286
+ const query = merge ? "?merge=true" : "";
287
+ const result = await apiCall(
288
+ "POST",
289
+ `/api/v1/projects/${project_id}/import${query}`,
290
+ effectivePayload,
291
+ process.env.LOOPCTL_ORCH_KEY
292
+ );
293
+ return toContent(result);
294
+ }
295
+
296
+ // Reads JSON payload from either an inline object or an absolute file path.
297
+ // Returns the object on success, or an { error, body } shape on failure.
298
+ //
299
+ // Security: `payload_path` is read with the MCP process's filesystem
300
+ // privileges. Because agents can set this argument via prompt injection,
301
+ // we validate aggressively:
302
+ // * require absolute path
303
+ // * reject /proc, /dev, /sys (pseudo-filesystems that could DoS or leak)
304
+ // * stat first and cap at 5 MiB (server also enforces a body size limit)
305
+ async function resolvePayload(inline, payloadPath) {
306
+ if (inline && typeof inline === "object") return inline;
307
+ if (!payloadPath) {
308
+ return {
309
+ error: true,
310
+ status: 0,
311
+ body: "Must provide either `payload` (object) or `payload_path` (absolute JSON file path).",
312
+ };
313
+ }
314
+
315
+ const nodePath = await import("node:path");
316
+ if (!nodePath.isAbsolute(payloadPath)) {
317
+ return {
318
+ error: true,
319
+ status: 0,
320
+ body: `payload_path must be absolute (got '${payloadPath}').`,
321
+ };
322
+ }
323
+
324
+ const blockedPrefixes = ["/proc/", "/dev/", "/sys/", "/proc", "/dev", "/sys"];
325
+ if (blockedPrefixes.some((p) => payloadPath === p.replace(/\/$/, "") || payloadPath.startsWith(p))) {
326
+ return {
327
+ error: true,
328
+ status: 0,
329
+ body: `payload_path refused: '${payloadPath}' targets a pseudo-filesystem path.`,
330
+ };
331
+ }
332
+
333
+ const MAX_PAYLOAD_BYTES = 5 * 1024 * 1024;
334
+ const fs = await import("node:fs/promises");
335
+
336
+ try {
337
+ const stat = await fs.stat(payloadPath);
338
+ if (!stat.isFile()) {
339
+ return {
340
+ error: true,
341
+ status: 0,
342
+ body: `payload_path '${payloadPath}' is not a regular file.`,
343
+ };
344
+ }
345
+ if (stat.size > MAX_PAYLOAD_BYTES) {
346
+ return {
347
+ error: true,
348
+ status: 0,
349
+ body: `payload_path '${payloadPath}' is ${stat.size} bytes, exceeds max ${MAX_PAYLOAD_BYTES}.`,
350
+ };
351
+ }
352
+ const raw = await fs.readFile(payloadPath, "utf8");
353
+ return JSON.parse(raw);
354
+ } catch (err) {
355
+ return {
356
+ error: true,
357
+ status: 0,
358
+ body: `Could not read payload_path '${payloadPath}': ${err.message}`,
359
+ };
360
+ }
361
+ }
362
+
200
363
  // --- Story Tools ---
201
364
 
202
365
  async function listStories({ project_id, agent_status, verified_status, epic_id, limit, offset, include_token_totals }) {
@@ -801,9 +964,75 @@ const TOOLS = [
801
964
  required: ["project_id"],
802
965
  },
803
966
  },
967
+ {
968
+ name: "backfill_story",
969
+ description:
970
+ "Mark a story as verified when the work was completed outside loopctl (e.g. before the project was onboarded). " +
971
+ "REFUSED for stories that have any loopctl dispatch lineage — non-pending agent_status, assigned_agent_id, implementer_dispatch_id, " +
972
+ "or verifier_dispatch_id set. Also refused for stories already `:verified` (idempotent no-op when the same payload is sent) or `:rejected`. " +
973
+ "Records a provenance marker in `metadata.backfill` plus an audit event and a `story.backfilled` webhook. " +
974
+ "REQUIRES `reason`. Strongly recommend passing `evidence_url` (http/https, no credentials in userinfo) and `pr_number`.",
975
+ inputSchema: {
976
+ type: "object",
977
+ properties: {
978
+ story_id: {
979
+ type: "string",
980
+ description: "The UUID of the story to backfill.",
981
+ },
982
+ reason: {
983
+ type: "string",
984
+ description:
985
+ "Why this story is being marked verified without the normal flow (e.g. 'completed before loopctl onboarding, see PR #232').",
986
+ },
987
+ evidence_url: {
988
+ type: "string",
989
+ description: "URL to the evidence (PR, commit, deploy log, etc.).",
990
+ },
991
+ pr_number: {
992
+ type: "integer",
993
+ description: "GitHub/GitLab PR number that delivered the work.",
994
+ },
995
+ },
996
+ required: ["story_id", "reason"],
997
+ },
998
+ },
999
+ {
1000
+ name: "create_story",
1001
+ description:
1002
+ "Create a single story inside an existing epic. " +
1003
+ "Use this for one-off additions instead of wrapping the story in a bulk import payload. " +
1004
+ "Pass either `epic_id` (UUID) or (`project_id` + `epic_number`) -- the latter is friendlier for agents who know the epic number but not its UUID.",
1005
+ inputSchema: {
1006
+ type: "object",
1007
+ properties: {
1008
+ project_id: {
1009
+ type: "string",
1010
+ description: "The UUID of the project (required if using epic_number).",
1011
+ },
1012
+ epic_number: {
1013
+ type: "integer",
1014
+ description:
1015
+ "The human-readable epic number (e.g. 72). Used together with project_id to locate the epic.",
1016
+ },
1017
+ epic_id: {
1018
+ type: "string",
1019
+ description: "The epic UUID. Alternative to project_id+epic_number.",
1020
+ },
1021
+ story: {
1022
+ type: "object",
1023
+ description:
1024
+ "The full story payload: { number, title, description?, acceptance_criteria?, estimated_hours?, metadata? }. `number` is a string like '72.3'; `title` is required.",
1025
+ },
1026
+ },
1027
+ required: ["story"],
1028
+ },
1029
+ },
804
1030
  {
805
1031
  name: "import_stories",
806
- description: "Import stories into a project from a structured payload (Epic 12 import format).",
1032
+ description:
1033
+ "Import stories into a project from a structured payload (Epic 12 import format). " +
1034
+ "Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). " +
1035
+ "For large payloads, use `payload_path` to read JSON from disk instead of passing it inline.",
807
1036
  inputSchema: {
808
1037
  type: "object",
809
1038
  properties: {
@@ -813,10 +1042,23 @@ const TOOLS = [
813
1042
  },
814
1043
  payload: {
815
1044
  type: "object",
816
- description: "The import payload object (epics + stories structure).",
1045
+ description:
1046
+ "The import payload object (epics + stories structure). Either this or `payload_path` is required. If BOTH are passed, `payload` wins.",
1047
+ },
1048
+ payload_path: {
1049
+ type: "string",
1050
+ description:
1051
+ "Absolute path to a JSON file with the import payload. Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
1052
+ },
1053
+ merge: {
1054
+ type: "boolean",
1055
+ description:
1056
+ "When true, existing epics/stories are updated and new ones added. " +
1057
+ "When false or omitted, duplicates return 409.",
1058
+ default: false,
817
1059
  },
818
1060
  },
819
- required: ["project_id", "payload"],
1061
+ required: ["project_id"],
820
1062
  },
821
1063
  },
822
1064
 
@@ -1899,6 +2141,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1899
2141
  case "get_progress":
1900
2142
  return await getProgress(args);
1901
2143
 
2144
+ case "backfill_story":
2145
+ return await backfillStory(args);
2146
+
2147
+ case "create_story":
2148
+ return await createStory(args);
2149
+
1902
2150
  case "import_stories":
1903
2151
  return await importStories(args);
1904
2152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",