loopctl-mcp-server 2.0.1 → 2.2.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 +7 -2
  2. package/index.js +326 -6
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -66,7 +66,7 @@ Or if installed locally:
66
66
 
67
67
  Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
68
68
 
69
- ## Tools (42)
69
+ ## Tools (45)
70
70
 
71
71
  ### Project Tools
72
72
 
@@ -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
 
@@ -143,6 +145,9 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
143
145
  |---|---|
144
146
  | `knowledge_publish` | Publish a draft article, making it visible to all agents. Required: `article_id`. |
145
147
  | `knowledge_bulk_publish` | **Requires `LOOPCTL_USER_KEY`.** Atomically publish up to 100 drafts in a single call. Required: `article_ids` (array). |
148
+ | `knowledge_unpublish` | **Requires `LOOPCTL_USER_KEY`.** Revert a published article back to draft (hidden from search/context, not deleted). Required: `article_id`. |
149
+ | `knowledge_archive` | **Requires `LOOPCTL_USER_KEY`.** Soft-delete an article (draft or published). Row retained for audit; hidden from all reads. Required: `article_id`. |
150
+ | `knowledge_delete` | **Requires `LOOPCTL_USER_KEY`.** Alias for `knowledge_archive` — DELETE verb on the REST API archives under the hood. Required: `article_id`. |
146
151
  | `knowledge_drafts` | List draft (unpublished) knowledge articles with pagination. Optional: `limit` (default 20, max 20), `offset` (default 0), `project_id`. Returns `meta.total_count`. |
147
152
  | `knowledge_lint` | Run a lint check on the knowledge wiki to identify stale or low-coverage articles. Optional: `project_id`, `stale_days`, `min_coverage`, `max_per_category` (default 50, max 500). True totals returned in `summary.total_per_category`. |
148
153
  | `knowledge_export` | Export all knowledge articles as a ZIP archive. Returns a curl command for direct download (ZIP binary cannot be returned as MCP content). Optional: `project_id`. |
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 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
+
212
232
  const result = await apiCall(
213
233
  "POST",
214
- `/api/v1/projects/${project_id}/import`,
215
- payload,
234
+ `/api/v1/stories/${story_id}/backfill`,
235
+ body,
216
236
  process.env.LOOPCTL_ORCH_KEY
217
237
  );
218
238
  return toContent(result);
219
239
  }
220
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" : "";
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
+
221
363
  // --- Story Tools ---
222
364
 
223
365
  async function listStories({ project_id, agent_status, verified_status, epic_id, limit, offset, include_token_totals }) {
@@ -510,6 +652,36 @@ async function knowledgeBulkPublish({ article_ids }) {
510
652
  return toContent(result);
511
653
  }
512
654
 
655
+ async function knowledgeUnpublish({ article_id }) {
656
+ const result = await apiCall(
657
+ "POST",
658
+ `/api/v1/articles/${article_id}/unpublish`,
659
+ null,
660
+ process.env.LOOPCTL_USER_KEY
661
+ );
662
+ return toContent(result);
663
+ }
664
+
665
+ async function knowledgeArchive({ article_id }) {
666
+ const result = await apiCall(
667
+ "POST",
668
+ `/api/v1/articles/${article_id}/archive`,
669
+ null,
670
+ process.env.LOOPCTL_USER_KEY
671
+ );
672
+ return toContent(result);
673
+ }
674
+
675
+ async function knowledgeDelete({ article_id }) {
676
+ const result = await apiCall(
677
+ "DELETE",
678
+ `/api/v1/articles/${article_id}`,
679
+ null,
680
+ process.env.LOOPCTL_USER_KEY
681
+ );
682
+ return toContent(result);
683
+ }
684
+
513
685
  async function knowledgeDrafts({ limit, offset, project_id }) {
514
686
  const params = new URLSearchParams();
515
687
  params.set(
@@ -822,9 +994,75 @@ const TOOLS = [
822
994
  required: ["project_id"],
823
995
  },
824
996
  },
997
+ {
998
+ name: "backfill_story",
999
+ description:
1000
+ "Mark a story as verified when the work was completed outside loopctl (e.g. before the project was onboarded). " +
1001
+ "REFUSED for stories that have any loopctl dispatch lineage — non-pending agent_status, assigned_agent_id, implementer_dispatch_id, " +
1002
+ "or verifier_dispatch_id set. Also refused for stories already `:verified` (idempotent no-op when the same payload is sent) or `:rejected`. " +
1003
+ "Records a provenance marker in `metadata.backfill` plus an audit event and a `story.backfilled` webhook. " +
1004
+ "REQUIRES `reason`. Strongly recommend passing `evidence_url` (http/https, no credentials in userinfo) and `pr_number`.",
1005
+ inputSchema: {
1006
+ type: "object",
1007
+ properties: {
1008
+ story_id: {
1009
+ type: "string",
1010
+ description: "The UUID of the story to backfill.",
1011
+ },
1012
+ reason: {
1013
+ type: "string",
1014
+ description:
1015
+ "Why this story is being marked verified without the normal flow (e.g. 'completed before loopctl onboarding, see PR #232').",
1016
+ },
1017
+ evidence_url: {
1018
+ type: "string",
1019
+ description: "URL to the evidence (PR, commit, deploy log, etc.).",
1020
+ },
1021
+ pr_number: {
1022
+ type: "integer",
1023
+ description: "GitHub/GitLab PR number that delivered the work.",
1024
+ },
1025
+ },
1026
+ required: ["story_id", "reason"],
1027
+ },
1028
+ },
1029
+ {
1030
+ name: "create_story",
1031
+ description:
1032
+ "Create a single story inside an existing epic. " +
1033
+ "Use this for one-off additions instead of wrapping the story in a bulk import payload. " +
1034
+ "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.",
1035
+ inputSchema: {
1036
+ type: "object",
1037
+ properties: {
1038
+ project_id: {
1039
+ type: "string",
1040
+ description: "The UUID of the project (required if using epic_number).",
1041
+ },
1042
+ epic_number: {
1043
+ type: "integer",
1044
+ description:
1045
+ "The human-readable epic number (e.g. 72). Used together with project_id to locate the epic.",
1046
+ },
1047
+ epic_id: {
1048
+ type: "string",
1049
+ description: "The epic UUID. Alternative to project_id+epic_number.",
1050
+ },
1051
+ story: {
1052
+ type: "object",
1053
+ description:
1054
+ "The full story payload: { number, title, description?, acceptance_criteria?, estimated_hours?, metadata? }. `number` is a string like '72.3'; `title` is required.",
1055
+ },
1056
+ },
1057
+ required: ["story"],
1058
+ },
1059
+ },
825
1060
  {
826
1061
  name: "import_stories",
827
- description: "Import stories into a project from a structured payload (Epic 12 import format).",
1062
+ description:
1063
+ "Import stories into a project from a structured payload (Epic 12 import format). " +
1064
+ "Pass `merge: true` to add stories to epics that already exist (otherwise duplicates return 409). " +
1065
+ "For large payloads, use `payload_path` to read JSON from disk instead of passing it inline.",
828
1066
  inputSchema: {
829
1067
  type: "object",
830
1068
  properties: {
@@ -834,10 +1072,23 @@ const TOOLS = [
834
1072
  },
835
1073
  payload: {
836
1074
  type: "object",
837
- description: "The import payload object (epics + stories structure).",
1075
+ description:
1076
+ "The import payload object (epics + stories structure). Either this or `payload_path` is required. If BOTH are passed, `payload` wins.",
1077
+ },
1078
+ payload_path: {
1079
+ type: "string",
1080
+ description:
1081
+ "Absolute path to a JSON file with the import payload. Avoids inline size limits for large epics. Ignored if `payload` is also passed.",
1082
+ },
1083
+ merge: {
1084
+ type: "boolean",
1085
+ description:
1086
+ "When true, existing epics/stories are updated and new ones added. " +
1087
+ "When false or omitted, duplicates return 409.",
1088
+ default: false,
838
1089
  },
839
1090
  },
840
- required: ["project_id", "payload"],
1091
+ required: ["project_id"],
841
1092
  },
842
1093
  },
843
1094
 
@@ -1505,6 +1756,60 @@ const TOOLS = [
1505
1756
  required: ["article_ids"],
1506
1757
  },
1507
1758
  },
1759
+ {
1760
+ name: "knowledge_unpublish",
1761
+ description:
1762
+ "Revert a published article back to draft state. The article stops being visible " +
1763
+ "in agent search/context but is not deleted — re-publish with knowledge_publish. " +
1764
+ "REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is NOT sufficient for " +
1765
+ "this destructive operation).",
1766
+ inputSchema: {
1767
+ type: "object",
1768
+ properties: {
1769
+ article_id: {
1770
+ type: "string",
1771
+ description: "The UUID of the published article to unpublish.",
1772
+ },
1773
+ },
1774
+ required: ["article_id"],
1775
+ },
1776
+ },
1777
+ {
1778
+ name: "knowledge_archive",
1779
+ description:
1780
+ "Archive an article (soft delete). The article is hidden from search, context, " +
1781
+ "and the index but the row is retained for audit/history. Works for drafts and " +
1782
+ "published articles. REQUIRES LOOPCTL_USER_KEY (user role — orchestrator role is " +
1783
+ "NOT sufficient for this destructive operation).",
1784
+ inputSchema: {
1785
+ type: "object",
1786
+ properties: {
1787
+ article_id: {
1788
+ type: "string",
1789
+ description: "The UUID of the article to archive.",
1790
+ },
1791
+ },
1792
+ required: ["article_id"],
1793
+ },
1794
+ },
1795
+ {
1796
+ name: "knowledge_delete",
1797
+ description:
1798
+ "Delete an article. Under the hood this performs the same soft-delete (archive) " +
1799
+ "as knowledge_archive — use whichever name is clearer at the call site. The row " +
1800
+ "is retained for audit; there is no hard delete. REQUIRES LOOPCTL_USER_KEY (user " +
1801
+ "role — orchestrator role is NOT sufficient for this destructive operation).",
1802
+ inputSchema: {
1803
+ type: "object",
1804
+ properties: {
1805
+ article_id: {
1806
+ type: "string",
1807
+ description: "The UUID of the article to delete.",
1808
+ },
1809
+ },
1810
+ required: ["article_id"],
1811
+ },
1812
+ },
1508
1813
  {
1509
1814
  name: "knowledge_drafts",
1510
1815
  description:
@@ -1920,6 +2225,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1920
2225
  case "get_progress":
1921
2226
  return await getProgress(args);
1922
2227
 
2228
+ case "backfill_story":
2229
+ return await backfillStory(args);
2230
+
2231
+ case "create_story":
2232
+ return await createStory(args);
2233
+
1923
2234
  case "import_stories":
1924
2235
  return await importStories(args);
1925
2236
 
@@ -2006,6 +2317,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2006
2317
  case "knowledge_bulk_publish":
2007
2318
  return await knowledgeBulkPublish(args);
2008
2319
 
2320
+ case "knowledge_unpublish":
2321
+ return await knowledgeUnpublish(args);
2322
+
2323
+ case "knowledge_archive":
2324
+ return await knowledgeArchive(args);
2325
+
2326
+ case "knowledge_delete":
2327
+ return await knowledgeDelete(args);
2328
+
2009
2329
  case "knowledge_drafts":
2010
2330
  return await knowledgeDrafts(args);
2011
2331
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",