loopctl-mcp-server 1.6.0 → 2.0.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 +104 -7
  2. package/index.js +296 -21
  3. package/package.json +3 -2
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP (Model Context Protocol) server for [loopctl](https://loopctl.com) -- structural trust for AI development loops.
4
4
 
5
- Wraps the loopctl REST API into 41 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
5
+ Wraps the loopctl REST API into 42 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
6
6
 
7
7
  ## Installation
8
8
 
@@ -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 (41)
69
+ ## Tools (42)
70
70
 
71
71
  ### Project Tools
72
72
 
@@ -75,6 +75,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
75
75
  | `get_tenant` | Get current tenant info. Use to verify connectivity. |
76
76
  | `list_projects` | List all projects in the current tenant. |
77
77
  | `create_project` | Create a new project in the current tenant. |
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. |
78
79
  | `get_progress` | Get progress summary for a project, including story counts by status. Pass `include_cost=true` for cost data. |
79
80
  | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). |
80
81
 
@@ -130,10 +131,10 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
130
131
 
131
132
  | Tool | Description |
132
133
  |---|---|
133
- | `knowledge_index` | Load the knowledge wiki catalog at session start. Returns lightweight article metadata grouped by category. |
134
- | `knowledge_search` | Search the knowledge wiki by topic. Supports keyword, semantic, or combined search modes. Returns snippets. |
135
- | `knowledge_get` | Get full article content by ID. Use after search to read an article in detail. |
136
- | `knowledge_context` | Get relevance-and-recency-ranked full articles for a task query. Best knowledge for your current context. |
134
+ | `knowledge_index` | Load the knowledge wiki catalog at session start. Returns lightweight article metadata grouped by category. Optional: `project_id`, `story_id`. |
135
+ | `knowledge_search` | Search the knowledge wiki by topic. Supports keyword, semantic, or combined search modes. Returns snippets. Optional: `project_id`, `story_id` for attribution. |
136
+ | `knowledge_get` | Get full article content by ID. Use after search to read an article in detail. Optional: `project_id`, `story_id` for attribution. |
137
+ | `knowledge_context` | Get relevance-and-recency-ranked full articles for a task query. Best knowledge for your current context. Optional: `project_id`, `story_id` for attribution. |
137
138
  | `knowledge_create` | Create a new knowledge article. File findings, document patterns, or record decisions. |
138
139
 
139
140
  ### Knowledge Management Tools (orchestrator key)
@@ -155,7 +156,7 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
155
156
  |---|---|
156
157
  | `knowledge_analytics_top` | Top accessed knowledge articles for the tenant. Optional: `limit` (default 20, max 100), `since_days` (default 7), `access_type` (`search`, `get`, `context`, `index`). |
157
158
  | `knowledge_article_stats` | Per-article usage stats: total accesses, unique agents, by-type breakdown, recent events. Required: `article_id`. |
158
- | `knowledge_agent_usage` | Per-agent (api_key) knowledge usage: total reads, unique articles, top read articles. Required: `agent_id`. Optional: `limit`, `since_days`. |
159
+ | `knowledge_agent_usage` | Per-agent knowledge usage: total reads, unique articles, top read articles. Required: exactly one of `api_key_id` (credential) or `agent_id` (logical identity). Optional: `limit`, `since_days`. See Wiki Attribution section. |
159
160
  | `knowledge_unused_articles` | Published articles with zero accesses in the window. Optional: `days_unused` (default 30), `limit` (default 50, max 200). |
160
161
 
161
162
  ### Discovery Tools
@@ -164,6 +165,102 @@ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_K
164
165
  |---|---|
165
166
  | `list_routes` | List all available API routes on the loopctl server. |
166
167
 
168
+ ## Wiki Attribution
169
+
170
+ ### Passing context parameters on wiki reads
171
+
172
+ Four wiki read tools (`knowledge_search`, `knowledge_get`, `knowledge_context`, `knowledge_index`) accept two optional attribution parameters:
173
+
174
+ | Parameter | Description |
175
+ |---|---|
176
+ | `project_id` | UUID of the loopctl project the agent is working on |
177
+ | `story_id` | UUID of the loopctl story the agent is currently implementing |
178
+
179
+ Passing these parameters lets loopctl record which project and story triggered each wiki read. The analytics endpoints (`knowledge_analytics_top`, `knowledge_agent_usage`, etc.) can then slice usage by project, showing which knowledge articles are most valuable per project.
180
+
181
+ The server silently drops attribution params that belong to a different tenant or are malformed UUIDs — you will not receive an error for invalid values.
182
+
183
+ **Always pass `story_id` when you are working on a loopctl story.** This is the primary mechanism by which wiki reads are attributed to development work.
184
+
185
+ #### Example: typical implementation agent workflow
186
+
187
+ Step 1 — fetch the story to pick up its UUID and context:
188
+
189
+ ```json
190
+ {
191
+ "tool": "get_story",
192
+ "arguments": { "story_id": "89aa0c48-5cf5-4925-b164-21684ef79c4d" }
193
+ }
194
+ ```
195
+
196
+ Step 2 — call `knowledge_search`, passing `story_id` so the read is attributed:
197
+
198
+ ```json
199
+ {
200
+ "tool": "knowledge_search",
201
+ "arguments": {
202
+ "q": "csv import bulk validation",
203
+ "project_id": "b50c9e38-aebe-4bbe-b8e6-bf2cb2b8afd0",
204
+ "story_id": "89aa0c48-5cf5-4925-b164-21684ef79c4d"
205
+ }
206
+ }
207
+ ```
208
+
209
+ Step 3 — call `knowledge_get`, reusing the same `story_id`:
210
+
211
+ ```json
212
+ {
213
+ "tool": "knowledge_get",
214
+ "arguments": {
215
+ "article_id": "c3d2e1f0-1234-5678-abcd-ef0123456789",
216
+ "project_id": "b50c9e38-aebe-4bbe-b8e6-bf2cb2b8afd0",
217
+ "story_id": "89aa0c48-5cf5-4925-b164-21684ef79c4d"
218
+ }
219
+ }
220
+ ```
221
+
222
+ ### `knowledge_agent_usage`: api_key_id vs agent_id
223
+
224
+ The `knowledge_agent_usage` tool accepts **exactly one** of two identifier parameters:
225
+
226
+ | Parameter | Meaning | When to use |
227
+ |---|---|---|
228
+ | `api_key_id` | The `api_keys.id` credential UUID — the raw API key identity | You have the credential ID from `GET /api/v1/api_keys` or a loopctl admin page |
229
+ | `agent_id` | The `agents.id` logical identity UUID — the agent registry entry | You have the agent registry ID from `GET /api/v1/agents` or a story's `assigned_agent_id` |
230
+
231
+ The server's analytics endpoint performs dual-resolution: it tries both interpretations automatically. However, using the explicit parameter makes your intent clear and avoids ambiguity in the response's `resolved_as` field.
232
+
233
+ Passing both parameters returns a validation error. Passing neither also returns a validation error.
234
+
235
+ ```json
236
+ // Query by credential (api_key_id)
237
+ {
238
+ "tool": "knowledge_agent_usage",
239
+ "arguments": {
240
+ "api_key_id": "b977c90c-061b-4e42-8afa-26a5efde51ad",
241
+ "since_days": 7
242
+ }
243
+ }
244
+
245
+ // Query by logical agent identity (agent_id)
246
+ {
247
+ "tool": "knowledge_agent_usage",
248
+ "arguments": {
249
+ "agent_id": "09429bc4-328f-42f4-acec-db48b40849b2",
250
+ "since_days": 30
251
+ }
252
+ }
253
+ ```
254
+
255
+ #### Deprecated: old `agent_id` behavior
256
+
257
+ In versions before 1.2.0, `knowledge_agent_usage` accepted a single `agent_id` parameter that actually meant the `api_keys.id` credential (not the logical agent). This was confusing and caused silent zero-result responses when callers passed a logical `agents.id` value.
258
+
259
+ Starting with 1.2.0:
260
+ - `agent_id` means the **logical** `agents.id` (the agent registry entry).
261
+ - `api_key_id` means the **credential** `api_keys.id` (the raw API key).
262
+ - The old behavior (passing `agent_id` meaning credential) is Deprecated and will be removed in a future release. When you call with `agent_id` alone, the response includes a `_meta.deprecation_hint` nudging you toward explicit parameters.
263
+
167
264
  ## Chain-of-Custody Enforcement
168
265
 
169
266
  loopctl enforces that nobody marks their own work as done. The API returns `409` if the caller's identity matches the story's assigned agent:
package/index.js CHANGED
@@ -169,6 +169,16 @@ async function createProject({ name, slug, repo_url, description, tech_stack, mi
169
169
  return toContent(result);
170
170
  }
171
171
 
172
+ async function deleteProject({ project_id }) {
173
+ const result = await apiCall(
174
+ "DELETE",
175
+ `/api/v1/projects/${project_id}`,
176
+ null,
177
+ process.env.LOOPCTL_USER_KEY
178
+ );
179
+ return toContent(result);
180
+ }
181
+
172
182
  async function getProgress({ project_id, include_cost }) {
173
183
  const params = new URLSearchParams();
174
184
  if (include_cost) params.set("include_cost", "true");
@@ -406,17 +416,22 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
406
416
 
407
417
  // --- Knowledge Wiki Tools (agent key) ---
408
418
 
409
- async function knowledgeIndex({ project_id }) {
410
- const path = project_id
419
+ async function knowledgeIndex({ project_id, story_id }) {
420
+ const basePath = project_id
411
421
  ? `/api/v1/projects/${project_id}/knowledge/index`
412
422
  : "/api/v1/knowledge/index";
423
+ const params = new URLSearchParams();
424
+ if (story_id) params.set("story_id", story_id);
425
+ const qs = params.toString();
426
+ const path = qs ? `${basePath}?${qs}` : basePath;
413
427
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
414
428
  return toContent(result);
415
429
  }
416
430
 
417
- async function knowledgeSearch({ q, project_id, category, tags, mode, limit }) {
431
+ async function knowledgeSearch({ q, project_id, story_id, category, tags, mode, limit }) {
418
432
  const params = new URLSearchParams({ q });
419
433
  if (project_id) params.set("project_id", project_id);
434
+ if (story_id) params.set("story_id", story_id);
420
435
  if (category) params.set("category", category);
421
436
  if (tags) params.set("tags", tags);
422
437
  if (mode) params.set("mode", mode);
@@ -426,14 +441,20 @@ async function knowledgeSearch({ q, project_id, category, tags, mode, limit }) {
426
441
  return toContent(result);
427
442
  }
428
443
 
429
- async function knowledgeGet({ article_id }) {
430
- const result = await apiCall("GET", `/api/v1/articles/${article_id}`, null, process.env.LOOPCTL_AGENT_KEY);
444
+ async function knowledgeGet({ article_id, project_id, story_id }) {
445
+ const params = new URLSearchParams();
446
+ if (project_id) params.set("project_id", project_id);
447
+ if (story_id) params.set("story_id", story_id);
448
+ const qs = params.toString();
449
+ const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
450
+ const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
431
451
  return toContent(result);
432
452
  }
433
453
 
434
- async function knowledgeContext({ query, project_id, limit, recency_weight }) {
454
+ async function knowledgeContext({ query, project_id, story_id, limit, recency_weight }) {
435
455
  const params = new URLSearchParams({ query });
436
456
  if (project_id) params.set("project_id", project_id);
457
+ if (story_id) params.set("story_id", story_id);
437
458
  if (limit != null) params.set("limit", String(limit));
438
459
  if (recency_weight != null) params.set("recency_weight", String(recency_weight));
439
460
 
@@ -554,15 +575,70 @@ async function knowledgeArticleStats({ article_id }) {
554
575
  return toContent(result);
555
576
  }
556
577
 
557
- async function knowledgeAgentUsage({ agent_id, limit, since_days } = {}) {
578
+ // Canonical 8-4-4-4-12 UUID shape. Used to reject path-injection attempts
579
+ // in tools that interpolate user-supplied IDs into URL path segments.
580
+ const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
581
+
582
+ async function knowledgeAgentUsage({ api_key_id, agent_id, limit, since_days } = {}) {
583
+ // Normalize: treat empty strings / whitespace-only strings as missing so the
584
+ // validation below catches them. Otherwise an empty string would slip past
585
+ // the `!= null` checks and produce a malformed URL like /agents/.
586
+ const normalizedApiKeyId =
587
+ typeof api_key_id === "string" && api_key_id.trim() === "" ? null : api_key_id;
588
+ const normalizedAgentId =
589
+ typeof agent_id === "string" && agent_id.trim() === "" ? null : agent_id;
590
+
591
+ // Validate: exactly one of api_key_id or agent_id must be provided.
592
+ if (normalizedApiKeyId != null && normalizedAgentId != null) {
593
+ return {
594
+ content: [{ type: "text", text: "Error: pass exactly one of api_key_id or agent_id, not both. Use api_key_id for the api_keys.id credential; use agent_id for the agents.id logical identity." }],
595
+ isError: true,
596
+ };
597
+ }
598
+ if (normalizedApiKeyId == null && normalizedAgentId == null) {
599
+ return {
600
+ content: [{ type: "text", text: "Error: pass exactly one of api_key_id or agent_id. Use api_key_id for the api_keys.id credential; use agent_id for the agents.id logical identity." }],
601
+ isError: true,
602
+ };
603
+ }
604
+
605
+ const resolvedId = normalizedApiKeyId ?? normalizedAgentId;
606
+
607
+ // Defense-in-depth: the MCP SDK declares `format: "uuid"` on these schemas
608
+ // but does not enforce it for tool arguments. Because `resolvedId` is
609
+ // interpolated directly into a URL path segment, a value containing `/`
610
+ // or `..` would let `fetch()` normalize the request to a different
611
+ // endpoint. Reject anything that isn't a canonical UUID before we touch
612
+ // the network.
613
+ if (typeof resolvedId !== "string" || !UUID_RE.test(resolvedId)) {
614
+ const which = normalizedApiKeyId != null ? "api_key_id" : "agent_id";
615
+ return {
616
+ content: [{ type: "text", text: `Error: ${which} must be a canonical UUID (8-4-4-4-12 hex).` }],
617
+ isError: true,
618
+ };
619
+ }
558
620
  const params = new URLSearchParams();
559
621
  if (limit != null) params.set("limit", String(limit));
560
622
  if (since_days != null) params.set("since_days", String(since_days));
561
623
  const qs = params.toString();
562
624
  const path = qs
563
- ? `/api/v1/knowledge/analytics/agents/${agent_id}?${qs}`
564
- : `/api/v1/knowledge/analytics/agents/${agent_id}`;
625
+ ? `/api/v1/knowledge/analytics/agents/${resolvedId}?${qs}`
626
+ : `/api/v1/knowledge/analytics/agents/${resolvedId}`;
565
627
  const result = await apiCall("GET", path, null, process.env.LOOPCTL_ORCH_KEY);
628
+
629
+ // When agent_id alone is passed (new semantic: logical agents.id), emit a
630
+ // one-release-cycle nudge so callers can be explicit about their intent.
631
+ if (normalizedAgentId != null && normalizedApiKeyId == null) {
632
+ const base = toContent(result);
633
+ return {
634
+ ...base,
635
+ _meta: {
636
+ deprecation_hint:
637
+ "knowledge_agent_usage: if you meant the api_keys.id credential, pass it as api_key_id explicitly. The agent_id parameter now refers to the logical agents.id only.",
638
+ },
639
+ };
640
+ }
641
+
566
642
  return toContent(result);
567
643
  }
568
644
 
@@ -603,6 +679,51 @@ async function listRoutes() {
603
679
  return toContent(result);
604
680
  }
605
681
 
682
+ // US-26.2.3: Dispatch lineage tool
683
+ async function createDispatch({
684
+ parent_dispatch_id,
685
+ role,
686
+ story_id,
687
+ agent_id,
688
+ expires_in_seconds = 3600,
689
+ }) {
690
+ const body = { role, agent_id, expires_in_seconds };
691
+ if (parent_dispatch_id) body.parent_dispatch_id = parent_dispatch_id;
692
+ if (story_id) body.story_id = story_id;
693
+
694
+ const result = await apiCall("POST", "/api/v1/dispatches", body);
695
+ return toContent(result);
696
+ }
697
+
698
+ // US-26: Signed Tree Head retrieval
699
+ async function getSth({ tenant_id }) {
700
+ const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
701
+ return toContent(result);
702
+ }
703
+
704
+ // US-26: System article retrieval
705
+ async function getSystemArticles({ slug, category } = {}) {
706
+ const params = new URLSearchParams();
707
+ if (slug) params.set("slug", slug);
708
+ if (category) params.set("category", category);
709
+ const qs = params.toString();
710
+ const result = await apiCall("GET", `/api/v1/articles/system${qs ? "?" + qs : ""}`);
711
+ return toContent(result);
712
+ }
713
+
714
+ // US-26: Cap recovery after session crash
715
+ async function recoverCap({ story_id, cap_type, lineage }) {
716
+ const body = { cap_type: cap_type || "start_cap", lineage: lineage || [] };
717
+ const result = await apiCall("POST", `/api/v1/stories/${story_id}/recover-cap`, body);
718
+ return toContent(result);
719
+ }
720
+
721
+ // US-26: Acceptance criteria for a story
722
+ async function getAcceptanceCriteria({ story_id }) {
723
+ const result = await apiCall("GET", `/api/v1/stories/${story_id}/acceptance_criteria`);
724
+ return toContent(result);
725
+ }
726
+
606
727
  // ---------------------------------------------------------------------------
607
728
  // Tool definitions
608
729
  // ---------------------------------------------------------------------------
@@ -647,6 +768,21 @@ const TOOLS = [
647
768
  required: ["name", "slug"],
648
769
  },
649
770
  },
771
+ {
772
+ name: "delete_project",
773
+ description:
774
+ "Delete a project and all of its dependent resources (epics, stories, audit entries scoped to it). REQUIRES LOOPCTL_USER_KEY to be set in the MCP server env (user role — orchestrator role is NOT sufficient for this destructive operation). The deletion is irreversible.",
775
+ inputSchema: {
776
+ type: "object",
777
+ properties: {
778
+ project_id: {
779
+ type: "string",
780
+ description: "The UUID of the project to delete.",
781
+ },
782
+ },
783
+ required: ["project_id"],
784
+ },
785
+ },
650
786
  {
651
787
  name: "get_progress",
652
788
  description: "Get progress summary for a project, including story counts by status. Pass include_cost=true to include cost data when available.",
@@ -1152,15 +1288,21 @@ const TOOLS = [
1152
1288
  {
1153
1289
  name: "knowledge_index",
1154
1290
  description:
1155
- "Load the knowledge wiki catalog at session start. Returns lightweight article metadata " +
1156
- "(titles, categories, tags) grouped by category. Use this to discover available knowledge before searching.",
1291
+ "Load the knowledge wiki catalog at session start. Returns article metadata grouped by category. " +
1292
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
1157
1293
  inputSchema: {
1158
1294
  type: "object",
1159
1295
  properties: {
1160
1296
  project_id: {
1161
1297
  type: "string",
1298
+ format: "uuid",
1162
1299
  description: "Optional: scope the index to a specific project UUID.",
1163
1300
  },
1301
+ story_id: {
1302
+ type: "string",
1303
+ format: "uuid",
1304
+ description: "Optional: loopctl story UUID for attribution tracking.",
1305
+ },
1164
1306
  },
1165
1307
  required: [],
1166
1308
  },
@@ -1168,8 +1310,8 @@ const TOOLS = [
1168
1310
  {
1169
1311
  name: "knowledge_search",
1170
1312
  description:
1171
- "Search the knowledge wiki by topic. Supports keyword, semantic, or combined search modes. " +
1172
- "Returns snippets, not full bodies. Use after index to find specific articles.",
1313
+ "Search the knowledge wiki by topic. Returns snippets. " +
1314
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
1173
1315
  inputSchema: {
1174
1316
  type: "object",
1175
1317
  properties: {
@@ -1179,8 +1321,14 @@ const TOOLS = [
1179
1321
  },
1180
1322
  project_id: {
1181
1323
  type: "string",
1324
+ format: "uuid",
1182
1325
  description: "Optional: scope search to a specific project UUID.",
1183
1326
  },
1327
+ story_id: {
1328
+ type: "string",
1329
+ format: "uuid",
1330
+ description: "Optional: loopctl story UUID for attribution tracking.",
1331
+ },
1184
1332
  category: {
1185
1333
  type: "string",
1186
1334
  description: "Optional: filter results by category.",
@@ -1205,14 +1353,26 @@ const TOOLS = [
1205
1353
  {
1206
1354
  name: "knowledge_get",
1207
1355
  description:
1208
- "Get full article content by ID. Use after search to read an article in detail.",
1356
+ "Get full article content by ID. Use after search to read an article in detail. " +
1357
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
1209
1358
  inputSchema: {
1210
1359
  type: "object",
1211
1360
  properties: {
1212
1361
  article_id: {
1213
1362
  type: "string",
1363
+ format: "uuid",
1214
1364
  description: "The UUID of the article.",
1215
1365
  },
1366
+ project_id: {
1367
+ type: "string",
1368
+ format: "uuid",
1369
+ description: "Optional: project UUID for attribution tracking.",
1370
+ },
1371
+ story_id: {
1372
+ type: "string",
1373
+ format: "uuid",
1374
+ description: "Optional: loopctl story UUID for attribution tracking.",
1375
+ },
1216
1376
  },
1217
1377
  required: ["article_id"],
1218
1378
  },
@@ -1220,8 +1380,8 @@ const TOOLS = [
1220
1380
  {
1221
1381
  name: "knowledge_context",
1222
1382
  description:
1223
- "Get relevance-and-recency-ranked full articles for a task query. Returns the best knowledge " +
1224
- "for your current context with linked references. Use when starting a task that needs domain knowledge.",
1383
+ "Get ranked full articles for a task query. Returns best knowledge with linked references. " +
1384
+ "Pass story_id when working on a loopctl story so reads attribute correctly.",
1225
1385
  inputSchema: {
1226
1386
  type: "object",
1227
1387
  properties: {
@@ -1231,8 +1391,14 @@ const TOOLS = [
1231
1391
  },
1232
1392
  project_id: {
1233
1393
  type: "string",
1394
+ format: "uuid",
1234
1395
  description: "Optional: scope context to a specific project UUID.",
1235
1396
  },
1397
+ story_id: {
1398
+ type: "string",
1399
+ format: "uuid",
1400
+ description: "Optional: loopctl story UUID for attribution tracking.",
1401
+ },
1236
1402
  limit: {
1237
1403
  type: "integer",
1238
1404
  description: "Optional: maximum number of articles to return.",
@@ -1541,15 +1707,21 @@ const TOOLS = [
1541
1707
  {
1542
1708
  name: "knowledge_agent_usage",
1543
1709
  description:
1544
- "Return knowledge usage for a specific agent (api_key): total reads, " +
1545
- "unique articles, access type breakdown, and top read articles. " +
1710
+ "Return knowledge usage for an agent: total reads, unique articles, top read articles. " +
1711
+ "Pass api_key_id (api_keys.id credential) OR agent_id (agents.id logical identity) — not both. " +
1546
1712
  "Requires orchestrator role.",
1547
1713
  inputSchema: {
1548
1714
  type: "object",
1549
1715
  properties: {
1716
+ api_key_id: {
1717
+ type: "string",
1718
+ format: "uuid",
1719
+ description: "The api_keys.id credential UUID. Use this when you have the raw API key ID.",
1720
+ },
1550
1721
  agent_id: {
1551
1722
  type: "string",
1552
- description: "API key UUID identifying the agent identity.",
1723
+ format: "uuid",
1724
+ description: "The agents.id logical identity UUID. Use this when you have the agent registry ID.",
1553
1725
  },
1554
1726
  limit: {
1555
1727
  type: "integer",
@@ -1564,7 +1736,7 @@ const TOOLS = [
1564
1736
  maximum: 365,
1565
1737
  },
1566
1738
  },
1567
- required: ["agent_id"],
1739
+ required: [],
1568
1740
  },
1569
1741
  },
1570
1742
  {
@@ -1602,6 +1774,91 @@ const TOOLS = [
1602
1774
  required: [],
1603
1775
  },
1604
1776
  },
1777
+
1778
+ // Dispatch Tool (US-26.2.3)
1779
+ {
1780
+ name: "dispatch",
1781
+ description:
1782
+ "Mint an ephemeral api_key for a sub-agent dispatch. " +
1783
+ "The raw_key is returned ONCE — pass it to the sub-agent via its launch arguments, " +
1784
+ "never store it in env vars. The key expires after expires_in_seconds.",
1785
+ inputSchema: {
1786
+ type: "object",
1787
+ properties: {
1788
+ parent_dispatch_id: {
1789
+ type: "string",
1790
+ description: "UUID of the parent dispatch (omit for root dispatch).",
1791
+ },
1792
+ role: {
1793
+ type: "string",
1794
+ enum: ["agent", "orchestrator"],
1795
+ description: "Role for the sub-agent.",
1796
+ },
1797
+ story_id: {
1798
+ type: "string",
1799
+ description: "Optional: UUID of the story this dispatch is for.",
1800
+ },
1801
+ agent_id: {
1802
+ type: "string",
1803
+ description: "UUID of the agent being dispatched.",
1804
+ },
1805
+ expires_in_seconds: {
1806
+ type: "integer",
1807
+ description: "Key lifetime in seconds (default 3600, max 14400).",
1808
+ default: 3600,
1809
+ },
1810
+ },
1811
+ required: ["role", "agent_id"],
1812
+ },
1813
+ },
1814
+
1815
+ // Chain of Custody v2 tools
1816
+ {
1817
+ name: "get_sth",
1818
+ description: "Get the latest Signed Tree Head for a tenant's audit chain. Public — no auth required.",
1819
+ inputSchema: {
1820
+ type: "object",
1821
+ properties: {
1822
+ tenant_id: { type: "string", description: "Tenant UUID." },
1823
+ },
1824
+ required: ["tenant_id"],
1825
+ },
1826
+ },
1827
+ {
1828
+ name: "get_system_articles",
1829
+ description: "List or retrieve system-scoped wiki articles. Public — no auth required.",
1830
+ inputSchema: {
1831
+ type: "object",
1832
+ properties: {
1833
+ slug: { type: "string", description: "Optional: fetch a single article by slug." },
1834
+ category: { type: "string", description: "Optional: filter by category (pattern, convention, decision, finding, reference)." },
1835
+ },
1836
+ },
1837
+ },
1838
+ {
1839
+ name: "recover_cap",
1840
+ description: "Re-mint a capability token for a story you're assigned to. Use after a session crash when you've lost your cap.",
1841
+ inputSchema: {
1842
+ type: "object",
1843
+ properties: {
1844
+ story_id: { type: "string", description: "Story UUID." },
1845
+ cap_type: { type: "string", enum: ["start_cap", "report_cap"], description: "Which cap to recover (default: start_cap)." },
1846
+ lineage: { type: "array", items: { type: "string" }, description: "Your dispatch lineage path." },
1847
+ },
1848
+ required: ["story_id"],
1849
+ },
1850
+ },
1851
+ {
1852
+ name: "get_acceptance_criteria",
1853
+ description: "List acceptance criteria for a story with their verification status.",
1854
+ inputSchema: {
1855
+ type: "object",
1856
+ properties: {
1857
+ story_id: { type: "string", description: "Story UUID." },
1858
+ },
1859
+ required: ["story_id"],
1860
+ },
1861
+ },
1605
1862
  ];
1606
1863
 
1607
1864
  // ---------------------------------------------------------------------------
@@ -1611,7 +1868,7 @@ const TOOLS = [
1611
1868
  const server = new Server(
1612
1869
  {
1613
1870
  name: "loopctl",
1614
- version: "1.1.2",
1871
+ version: "1.2.0",
1615
1872
  },
1616
1873
  {
1617
1874
  capabilities: { tools: {} },
@@ -1636,6 +1893,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1636
1893
  case "create_project":
1637
1894
  return await createProject(args);
1638
1895
 
1896
+ case "delete_project":
1897
+ return await deleteProject(args);
1898
+
1639
1899
  case "get_progress":
1640
1900
  return await getProgress(args);
1641
1901
 
@@ -1761,6 +2021,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1761
2021
  case "list_routes":
1762
2022
  return await listRoutes();
1763
2023
 
2024
+ case "dispatch":
2025
+ return await createDispatch(args);
2026
+
2027
+ case "get_sth":
2028
+ return await getSth(args);
2029
+
2030
+ case "get_system_articles":
2031
+ return await getSystemArticles(args);
2032
+
2033
+ case "recover_cap":
2034
+ return await recoverCap(args);
2035
+
2036
+ case "get_acceptance_criteria":
2037
+ return await getAcceptanceCriteria(args);
2038
+
1764
2039
  default:
1765
2040
  throw new Error(`Unknown tool: ${name}`);
1766
2041
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopctl-mcp-server",
3
- "version": "1.6.0",
3
+ "version": "2.0.0",
4
4
  "description": "MCP server for loopctl — structural trust for AI development loops",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,8 @@
8
8
  "loopctl-mcp-server": "index.js"
9
9
  },
10
10
  "scripts": {
11
- "start": "node index.js"
11
+ "start": "node index.js",
12
+ "test": "node --test"
12
13
  },
13
14
  "keywords": [
14
15
  "mcp",