loopctl-mcp-server 2.0.1 → 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.
- package/README.md +3 -1
- package/index.js +233 -6
- 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
|
@@ -208,16 +208,158 @@ async function getProgress({ project_id, include_cost }) {
|
|
|
208
208
|
return toContent(result);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
async function
|
|
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 };
|
|
272
|
+
const result = await apiCall(
|
|
273
|
+
"POST",
|
|
274
|
+
`/api/v1/projects/${project_id}/stories`,
|
|
275
|
+
body,
|
|
276
|
+
process.env.LOOPCTL_ORCH_KEY
|
|
277
|
+
);
|
|
278
|
+
return toContent(result);
|
|
279
|
+
}
|
|
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" : "";
|
|
212
287
|
const result = await apiCall(
|
|
213
288
|
"POST",
|
|
214
|
-
`/api/v1/projects/${project_id}/import`,
|
|
215
|
-
|
|
289
|
+
`/api/v1/projects/${project_id}/import${query}`,
|
|
290
|
+
effectivePayload,
|
|
216
291
|
process.env.LOOPCTL_ORCH_KEY
|
|
217
292
|
);
|
|
218
293
|
return toContent(result);
|
|
219
294
|
}
|
|
220
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
|
+
|
|
221
363
|
// --- Story Tools ---
|
|
222
364
|
|
|
223
365
|
async function listStories({ project_id, agent_status, verified_status, epic_id, limit, offset, include_token_totals }) {
|
|
@@ -822,9 +964,75 @@ const TOOLS = [
|
|
|
822
964
|
required: ["project_id"],
|
|
823
965
|
},
|
|
824
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
|
+
},
|
|
825
1030
|
{
|
|
826
1031
|
name: "import_stories",
|
|
827
|
-
description:
|
|
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.",
|
|
828
1036
|
inputSchema: {
|
|
829
1037
|
type: "object",
|
|
830
1038
|
properties: {
|
|
@@ -834,10 +1042,23 @@ const TOOLS = [
|
|
|
834
1042
|
},
|
|
835
1043
|
payload: {
|
|
836
1044
|
type: "object",
|
|
837
|
-
description:
|
|
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,
|
|
838
1059
|
},
|
|
839
1060
|
},
|
|
840
|
-
required: ["project_id"
|
|
1061
|
+
required: ["project_id"],
|
|
841
1062
|
},
|
|
842
1063
|
},
|
|
843
1064
|
|
|
@@ -1920,6 +2141,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1920
2141
|
case "get_progress":
|
|
1921
2142
|
return await getProgress(args);
|
|
1922
2143
|
|
|
2144
|
+
case "backfill_story":
|
|
2145
|
+
return await backfillStory(args);
|
|
2146
|
+
|
|
2147
|
+
case "create_story":
|
|
2148
|
+
return await createStory(args);
|
|
2149
|
+
|
|
1923
2150
|
case "import_stories":
|
|
1924
2151
|
return await importStories(args);
|
|
1925
2152
|
|