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.
- package/README.md +3 -1
- package/index.js +260 -12
- 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
|
|
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}/
|
|
194
|
-
|
|
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:
|
|
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:
|
|
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"
|
|
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
|
|