loopctl-mcp-server 1.6.1 → 2.0.1
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 +104 -7
- package/index.js +295 -27
- 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
|
|
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 (
|
|
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
|
|
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
|
@@ -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
|
}
|
|
@@ -416,17 +437,22 @@ async function setTokenBudget({ scope_type, scope_id, budget_millicents, alert_t
|
|
|
416
437
|
|
|
417
438
|
// --- Knowledge Wiki Tools (agent key) ---
|
|
418
439
|
|
|
419
|
-
async function knowledgeIndex({ project_id }) {
|
|
420
|
-
const
|
|
440
|
+
async function knowledgeIndex({ project_id, story_id }) {
|
|
441
|
+
const basePath = project_id
|
|
421
442
|
? `/api/v1/projects/${project_id}/knowledge/index`
|
|
422
443
|
: "/api/v1/knowledge/index";
|
|
444
|
+
const params = new URLSearchParams();
|
|
445
|
+
if (story_id) params.set("story_id", story_id);
|
|
446
|
+
const qs = params.toString();
|
|
447
|
+
const path = qs ? `${basePath}?${qs}` : basePath;
|
|
423
448
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
424
449
|
return toContent(result);
|
|
425
450
|
}
|
|
426
451
|
|
|
427
|
-
async function knowledgeSearch({ q, project_id, category, tags, mode, limit }) {
|
|
452
|
+
async function knowledgeSearch({ q, project_id, story_id, category, tags, mode, limit }) {
|
|
428
453
|
const params = new URLSearchParams({ q });
|
|
429
454
|
if (project_id) params.set("project_id", project_id);
|
|
455
|
+
if (story_id) params.set("story_id", story_id);
|
|
430
456
|
if (category) params.set("category", category);
|
|
431
457
|
if (tags) params.set("tags", tags);
|
|
432
458
|
if (mode) params.set("mode", mode);
|
|
@@ -436,14 +462,20 @@ async function knowledgeSearch({ q, project_id, category, tags, mode, limit }) {
|
|
|
436
462
|
return toContent(result);
|
|
437
463
|
}
|
|
438
464
|
|
|
439
|
-
async function knowledgeGet({ article_id }) {
|
|
440
|
-
const
|
|
465
|
+
async function knowledgeGet({ article_id, project_id, story_id }) {
|
|
466
|
+
const params = new URLSearchParams();
|
|
467
|
+
if (project_id) params.set("project_id", project_id);
|
|
468
|
+
if (story_id) params.set("story_id", story_id);
|
|
469
|
+
const qs = params.toString();
|
|
470
|
+
const path = qs ? `/api/v1/articles/${article_id}?${qs}` : `/api/v1/articles/${article_id}`;
|
|
471
|
+
const result = await apiCall("GET", path, null, process.env.LOOPCTL_AGENT_KEY);
|
|
441
472
|
return toContent(result);
|
|
442
473
|
}
|
|
443
474
|
|
|
444
|
-
async function knowledgeContext({ query, project_id, limit, recency_weight }) {
|
|
475
|
+
async function knowledgeContext({ query, project_id, story_id, limit, recency_weight }) {
|
|
445
476
|
const params = new URLSearchParams({ query });
|
|
446
477
|
if (project_id) params.set("project_id", project_id);
|
|
478
|
+
if (story_id) params.set("story_id", story_id);
|
|
447
479
|
if (limit != null) params.set("limit", String(limit));
|
|
448
480
|
if (recency_weight != null) params.set("recency_weight", String(recency_weight));
|
|
449
481
|
|
|
@@ -564,15 +596,70 @@ async function knowledgeArticleStats({ article_id }) {
|
|
|
564
596
|
return toContent(result);
|
|
565
597
|
}
|
|
566
598
|
|
|
567
|
-
|
|
599
|
+
// Canonical 8-4-4-4-12 UUID shape. Used to reject path-injection attempts
|
|
600
|
+
// in tools that interpolate user-supplied IDs into URL path segments.
|
|
601
|
+
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}$/;
|
|
602
|
+
|
|
603
|
+
async function knowledgeAgentUsage({ api_key_id, agent_id, limit, since_days } = {}) {
|
|
604
|
+
// Normalize: treat empty strings / whitespace-only strings as missing so the
|
|
605
|
+
// validation below catches them. Otherwise an empty string would slip past
|
|
606
|
+
// the `!= null` checks and produce a malformed URL like /agents/.
|
|
607
|
+
const normalizedApiKeyId =
|
|
608
|
+
typeof api_key_id === "string" && api_key_id.trim() === "" ? null : api_key_id;
|
|
609
|
+
const normalizedAgentId =
|
|
610
|
+
typeof agent_id === "string" && agent_id.trim() === "" ? null : agent_id;
|
|
611
|
+
|
|
612
|
+
// Validate: exactly one of api_key_id or agent_id must be provided.
|
|
613
|
+
if (normalizedApiKeyId != null && normalizedAgentId != null) {
|
|
614
|
+
return {
|
|
615
|
+
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." }],
|
|
616
|
+
isError: true,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (normalizedApiKeyId == null && normalizedAgentId == null) {
|
|
620
|
+
return {
|
|
621
|
+
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." }],
|
|
622
|
+
isError: true,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const resolvedId = normalizedApiKeyId ?? normalizedAgentId;
|
|
627
|
+
|
|
628
|
+
// Defense-in-depth: the MCP SDK declares `format: "uuid"` on these schemas
|
|
629
|
+
// but does not enforce it for tool arguments. Because `resolvedId` is
|
|
630
|
+
// interpolated directly into a URL path segment, a value containing `/`
|
|
631
|
+
// or `..` would let `fetch()` normalize the request to a different
|
|
632
|
+
// endpoint. Reject anything that isn't a canonical UUID before we touch
|
|
633
|
+
// the network.
|
|
634
|
+
if (typeof resolvedId !== "string" || !UUID_RE.test(resolvedId)) {
|
|
635
|
+
const which = normalizedApiKeyId != null ? "api_key_id" : "agent_id";
|
|
636
|
+
return {
|
|
637
|
+
content: [{ type: "text", text: `Error: ${which} must be a canonical UUID (8-4-4-4-12 hex).` }],
|
|
638
|
+
isError: true,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
568
641
|
const params = new URLSearchParams();
|
|
569
642
|
if (limit != null) params.set("limit", String(limit));
|
|
570
643
|
if (since_days != null) params.set("since_days", String(since_days));
|
|
571
644
|
const qs = params.toString();
|
|
572
645
|
const path = qs
|
|
573
|
-
? `/api/v1/knowledge/analytics/agents/${
|
|
574
|
-
: `/api/v1/knowledge/analytics/agents/${
|
|
646
|
+
? `/api/v1/knowledge/analytics/agents/${resolvedId}?${qs}`
|
|
647
|
+
: `/api/v1/knowledge/analytics/agents/${resolvedId}`;
|
|
575
648
|
const result = await apiCall("GET", path, null, process.env.LOOPCTL_ORCH_KEY);
|
|
649
|
+
|
|
650
|
+
// When agent_id alone is passed (new semantic: logical agents.id), emit a
|
|
651
|
+
// one-release-cycle nudge so callers can be explicit about their intent.
|
|
652
|
+
if (normalizedAgentId != null && normalizedApiKeyId == null) {
|
|
653
|
+
const base = toContent(result);
|
|
654
|
+
return {
|
|
655
|
+
...base,
|
|
656
|
+
_meta: {
|
|
657
|
+
deprecation_hint:
|
|
658
|
+
"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.",
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
576
663
|
return toContent(result);
|
|
577
664
|
}
|
|
578
665
|
|
|
@@ -613,6 +700,51 @@ async function listRoutes() {
|
|
|
613
700
|
return toContent(result);
|
|
614
701
|
}
|
|
615
702
|
|
|
703
|
+
// US-26.2.3: Dispatch lineage tool
|
|
704
|
+
async function createDispatch({
|
|
705
|
+
parent_dispatch_id,
|
|
706
|
+
role,
|
|
707
|
+
story_id,
|
|
708
|
+
agent_id,
|
|
709
|
+
expires_in_seconds = 3600,
|
|
710
|
+
}) {
|
|
711
|
+
const body = { role, agent_id, expires_in_seconds };
|
|
712
|
+
if (parent_dispatch_id) body.parent_dispatch_id = parent_dispatch_id;
|
|
713
|
+
if (story_id) body.story_id = story_id;
|
|
714
|
+
|
|
715
|
+
const result = await apiCall("POST", "/api/v1/dispatches", body);
|
|
716
|
+
return toContent(result);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// US-26: Signed Tree Head retrieval
|
|
720
|
+
async function getSth({ tenant_id }) {
|
|
721
|
+
const result = await apiCall("GET", `/api/v1/audit/sth/${tenant_id}`);
|
|
722
|
+
return toContent(result);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// US-26: System article retrieval
|
|
726
|
+
async function getSystemArticles({ slug, category } = {}) {
|
|
727
|
+
const params = new URLSearchParams();
|
|
728
|
+
if (slug) params.set("slug", slug);
|
|
729
|
+
if (category) params.set("category", category);
|
|
730
|
+
const qs = params.toString();
|
|
731
|
+
const result = await apiCall("GET", `/api/v1/articles/system${qs ? "?" + qs : ""}`);
|
|
732
|
+
return toContent(result);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// US-26: Cap recovery after session crash
|
|
736
|
+
async function recoverCap({ story_id, cap_type, lineage }) {
|
|
737
|
+
const body = { cap_type: cap_type || "start_cap", lineage: lineage || [] };
|
|
738
|
+
const result = await apiCall("POST", `/api/v1/stories/${story_id}/recover-cap`, body);
|
|
739
|
+
return toContent(result);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// US-26: Acceptance criteria for a story
|
|
743
|
+
async function getAcceptanceCriteria({ story_id }) {
|
|
744
|
+
const result = await apiCall("GET", `/api/v1/stories/${story_id}/acceptance_criteria`);
|
|
745
|
+
return toContent(result);
|
|
746
|
+
}
|
|
747
|
+
|
|
616
748
|
// ---------------------------------------------------------------------------
|
|
617
749
|
// Tool definitions
|
|
618
750
|
// ---------------------------------------------------------------------------
|
|
@@ -1177,15 +1309,21 @@ const TOOLS = [
|
|
|
1177
1309
|
{
|
|
1178
1310
|
name: "knowledge_index",
|
|
1179
1311
|
description:
|
|
1180
|
-
"Load the knowledge wiki catalog at session start. Returns
|
|
1181
|
-
"
|
|
1312
|
+
"Load the knowledge wiki catalog at session start. Returns article metadata grouped by category. " +
|
|
1313
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1182
1314
|
inputSchema: {
|
|
1183
1315
|
type: "object",
|
|
1184
1316
|
properties: {
|
|
1185
1317
|
project_id: {
|
|
1186
1318
|
type: "string",
|
|
1319
|
+
format: "uuid",
|
|
1187
1320
|
description: "Optional: scope the index to a specific project UUID.",
|
|
1188
1321
|
},
|
|
1322
|
+
story_id: {
|
|
1323
|
+
type: "string",
|
|
1324
|
+
format: "uuid",
|
|
1325
|
+
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1326
|
+
},
|
|
1189
1327
|
},
|
|
1190
1328
|
required: [],
|
|
1191
1329
|
},
|
|
@@ -1193,8 +1331,8 @@ const TOOLS = [
|
|
|
1193
1331
|
{
|
|
1194
1332
|
name: "knowledge_search",
|
|
1195
1333
|
description:
|
|
1196
|
-
"Search the knowledge wiki by topic.
|
|
1197
|
-
"
|
|
1334
|
+
"Search the knowledge wiki by topic. Returns snippets. " +
|
|
1335
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1198
1336
|
inputSchema: {
|
|
1199
1337
|
type: "object",
|
|
1200
1338
|
properties: {
|
|
@@ -1204,8 +1342,14 @@ const TOOLS = [
|
|
|
1204
1342
|
},
|
|
1205
1343
|
project_id: {
|
|
1206
1344
|
type: "string",
|
|
1345
|
+
format: "uuid",
|
|
1207
1346
|
description: "Optional: scope search to a specific project UUID.",
|
|
1208
1347
|
},
|
|
1348
|
+
story_id: {
|
|
1349
|
+
type: "string",
|
|
1350
|
+
format: "uuid",
|
|
1351
|
+
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1352
|
+
},
|
|
1209
1353
|
category: {
|
|
1210
1354
|
type: "string",
|
|
1211
1355
|
description: "Optional: filter results by category.",
|
|
@@ -1230,14 +1374,26 @@ const TOOLS = [
|
|
|
1230
1374
|
{
|
|
1231
1375
|
name: "knowledge_get",
|
|
1232
1376
|
description:
|
|
1233
|
-
"Get full article content by ID. Use after search to read an article in detail."
|
|
1377
|
+
"Get full article content by ID. Use after search to read an article in detail. " +
|
|
1378
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1234
1379
|
inputSchema: {
|
|
1235
1380
|
type: "object",
|
|
1236
1381
|
properties: {
|
|
1237
1382
|
article_id: {
|
|
1238
1383
|
type: "string",
|
|
1384
|
+
format: "uuid",
|
|
1239
1385
|
description: "The UUID of the article.",
|
|
1240
1386
|
},
|
|
1387
|
+
project_id: {
|
|
1388
|
+
type: "string",
|
|
1389
|
+
format: "uuid",
|
|
1390
|
+
description: "Optional: project UUID for attribution tracking.",
|
|
1391
|
+
},
|
|
1392
|
+
story_id: {
|
|
1393
|
+
type: "string",
|
|
1394
|
+
format: "uuid",
|
|
1395
|
+
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1396
|
+
},
|
|
1241
1397
|
},
|
|
1242
1398
|
required: ["article_id"],
|
|
1243
1399
|
},
|
|
@@ -1245,8 +1401,8 @@ const TOOLS = [
|
|
|
1245
1401
|
{
|
|
1246
1402
|
name: "knowledge_context",
|
|
1247
1403
|
description:
|
|
1248
|
-
"Get
|
|
1249
|
-
"
|
|
1404
|
+
"Get ranked full articles for a task query. Returns best knowledge with linked references. " +
|
|
1405
|
+
"Pass story_id when working on a loopctl story so reads attribute correctly.",
|
|
1250
1406
|
inputSchema: {
|
|
1251
1407
|
type: "object",
|
|
1252
1408
|
properties: {
|
|
@@ -1256,8 +1412,14 @@ const TOOLS = [
|
|
|
1256
1412
|
},
|
|
1257
1413
|
project_id: {
|
|
1258
1414
|
type: "string",
|
|
1415
|
+
format: "uuid",
|
|
1259
1416
|
description: "Optional: scope context to a specific project UUID.",
|
|
1260
1417
|
},
|
|
1418
|
+
story_id: {
|
|
1419
|
+
type: "string",
|
|
1420
|
+
format: "uuid",
|
|
1421
|
+
description: "Optional: loopctl story UUID for attribution tracking.",
|
|
1422
|
+
},
|
|
1261
1423
|
limit: {
|
|
1262
1424
|
type: "integer",
|
|
1263
1425
|
description: "Optional: maximum number of articles to return.",
|
|
@@ -1566,15 +1728,21 @@ const TOOLS = [
|
|
|
1566
1728
|
{
|
|
1567
1729
|
name: "knowledge_agent_usage",
|
|
1568
1730
|
description:
|
|
1569
|
-
"Return knowledge usage for
|
|
1570
|
-
"
|
|
1731
|
+
"Return knowledge usage for an agent: total reads, unique articles, top read articles. " +
|
|
1732
|
+
"Pass api_key_id (api_keys.id credential) OR agent_id (agents.id logical identity) — not both. " +
|
|
1571
1733
|
"Requires orchestrator role.",
|
|
1572
1734
|
inputSchema: {
|
|
1573
1735
|
type: "object",
|
|
1574
1736
|
properties: {
|
|
1737
|
+
api_key_id: {
|
|
1738
|
+
type: "string",
|
|
1739
|
+
format: "uuid",
|
|
1740
|
+
description: "The api_keys.id credential UUID. Use this when you have the raw API key ID.",
|
|
1741
|
+
},
|
|
1575
1742
|
agent_id: {
|
|
1576
1743
|
type: "string",
|
|
1577
|
-
|
|
1744
|
+
format: "uuid",
|
|
1745
|
+
description: "The agents.id logical identity UUID. Use this when you have the agent registry ID.",
|
|
1578
1746
|
},
|
|
1579
1747
|
limit: {
|
|
1580
1748
|
type: "integer",
|
|
@@ -1589,7 +1757,7 @@ const TOOLS = [
|
|
|
1589
1757
|
maximum: 365,
|
|
1590
1758
|
},
|
|
1591
1759
|
},
|
|
1592
|
-
required: [
|
|
1760
|
+
required: [],
|
|
1593
1761
|
},
|
|
1594
1762
|
},
|
|
1595
1763
|
{
|
|
@@ -1627,6 +1795,91 @@ const TOOLS = [
|
|
|
1627
1795
|
required: [],
|
|
1628
1796
|
},
|
|
1629
1797
|
},
|
|
1798
|
+
|
|
1799
|
+
// Dispatch Tool (US-26.2.3)
|
|
1800
|
+
{
|
|
1801
|
+
name: "dispatch",
|
|
1802
|
+
description:
|
|
1803
|
+
"Mint an ephemeral api_key for a sub-agent dispatch. " +
|
|
1804
|
+
"The raw_key is returned ONCE — pass it to the sub-agent via its launch arguments, " +
|
|
1805
|
+
"never store it in env vars. The key expires after expires_in_seconds.",
|
|
1806
|
+
inputSchema: {
|
|
1807
|
+
type: "object",
|
|
1808
|
+
properties: {
|
|
1809
|
+
parent_dispatch_id: {
|
|
1810
|
+
type: "string",
|
|
1811
|
+
description: "UUID of the parent dispatch (omit for root dispatch).",
|
|
1812
|
+
},
|
|
1813
|
+
role: {
|
|
1814
|
+
type: "string",
|
|
1815
|
+
enum: ["agent", "orchestrator"],
|
|
1816
|
+
description: "Role for the sub-agent.",
|
|
1817
|
+
},
|
|
1818
|
+
story_id: {
|
|
1819
|
+
type: "string",
|
|
1820
|
+
description: "Optional: UUID of the story this dispatch is for.",
|
|
1821
|
+
},
|
|
1822
|
+
agent_id: {
|
|
1823
|
+
type: "string",
|
|
1824
|
+
description: "UUID of the agent being dispatched.",
|
|
1825
|
+
},
|
|
1826
|
+
expires_in_seconds: {
|
|
1827
|
+
type: "integer",
|
|
1828
|
+
description: "Key lifetime in seconds (default 3600, max 14400).",
|
|
1829
|
+
default: 3600,
|
|
1830
|
+
},
|
|
1831
|
+
},
|
|
1832
|
+
required: ["role", "agent_id"],
|
|
1833
|
+
},
|
|
1834
|
+
},
|
|
1835
|
+
|
|
1836
|
+
// Chain of Custody v2 tools
|
|
1837
|
+
{
|
|
1838
|
+
name: "get_sth",
|
|
1839
|
+
description: "Get the latest Signed Tree Head for a tenant's audit chain. Public — no auth required.",
|
|
1840
|
+
inputSchema: {
|
|
1841
|
+
type: "object",
|
|
1842
|
+
properties: {
|
|
1843
|
+
tenant_id: { type: "string", description: "Tenant UUID." },
|
|
1844
|
+
},
|
|
1845
|
+
required: ["tenant_id"],
|
|
1846
|
+
},
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
name: "get_system_articles",
|
|
1850
|
+
description: "List or retrieve system-scoped wiki articles. Public — no auth required.",
|
|
1851
|
+
inputSchema: {
|
|
1852
|
+
type: "object",
|
|
1853
|
+
properties: {
|
|
1854
|
+
slug: { type: "string", description: "Optional: fetch a single article by slug." },
|
|
1855
|
+
category: { type: "string", description: "Optional: filter by category (pattern, convention, decision, finding, reference)." },
|
|
1856
|
+
},
|
|
1857
|
+
},
|
|
1858
|
+
},
|
|
1859
|
+
{
|
|
1860
|
+
name: "recover_cap",
|
|
1861
|
+
description: "Re-mint a capability token for a story you're assigned to. Use after a session crash when you've lost your cap.",
|
|
1862
|
+
inputSchema: {
|
|
1863
|
+
type: "object",
|
|
1864
|
+
properties: {
|
|
1865
|
+
story_id: { type: "string", description: "Story UUID." },
|
|
1866
|
+
cap_type: { type: "string", enum: ["start_cap", "report_cap"], description: "Which cap to recover (default: start_cap)." },
|
|
1867
|
+
lineage: { type: "array", items: { type: "string" }, description: "Your dispatch lineage path." },
|
|
1868
|
+
},
|
|
1869
|
+
required: ["story_id"],
|
|
1870
|
+
},
|
|
1871
|
+
},
|
|
1872
|
+
{
|
|
1873
|
+
name: "get_acceptance_criteria",
|
|
1874
|
+
description: "List acceptance criteria for a story with their verification status.",
|
|
1875
|
+
inputSchema: {
|
|
1876
|
+
type: "object",
|
|
1877
|
+
properties: {
|
|
1878
|
+
story_id: { type: "string", description: "Story UUID." },
|
|
1879
|
+
},
|
|
1880
|
+
required: ["story_id"],
|
|
1881
|
+
},
|
|
1882
|
+
},
|
|
1630
1883
|
];
|
|
1631
1884
|
|
|
1632
1885
|
// ---------------------------------------------------------------------------
|
|
@@ -1636,7 +1889,7 @@ const TOOLS = [
|
|
|
1636
1889
|
const server = new Server(
|
|
1637
1890
|
{
|
|
1638
1891
|
name: "loopctl",
|
|
1639
|
-
version: "1.
|
|
1892
|
+
version: "1.2.0",
|
|
1640
1893
|
},
|
|
1641
1894
|
{
|
|
1642
1895
|
capabilities: { tools: {} },
|
|
@@ -1789,6 +2042,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1789
2042
|
case "list_routes":
|
|
1790
2043
|
return await listRoutes();
|
|
1791
2044
|
|
|
2045
|
+
case "dispatch":
|
|
2046
|
+
return await createDispatch(args);
|
|
2047
|
+
|
|
2048
|
+
case "get_sth":
|
|
2049
|
+
return await getSth(args);
|
|
2050
|
+
|
|
2051
|
+
case "get_system_articles":
|
|
2052
|
+
return await getSystemArticles(args);
|
|
2053
|
+
|
|
2054
|
+
case "recover_cap":
|
|
2055
|
+
return await recoverCap(args);
|
|
2056
|
+
|
|
2057
|
+
case "get_acceptance_criteria":
|
|
2058
|
+
return await getAcceptanceCriteria(args);
|
|
2059
|
+
|
|
1792
2060
|
default:
|
|
1793
2061
|
throw new Error(`Unknown tool: ${name}`);
|
|
1794
2062
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopctl-mcp-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
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",
|