loopctl-mcp-server 1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/index.js +817 -0
  4. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Michael Kreyman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # loopctl-mcp-server
2
+
3
+ MCP (Model Context Protocol) server for [loopctl](https://loopctl.com) -- structural trust for AI development loops.
4
+
5
+ Wraps the loopctl REST API into 19 typed MCP tools so AI coding agents (Claude Code, etc.) can interact with loopctl without writing curl commands.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install loopctl-mcp-server
11
+ ```
12
+
13
+ Or run directly with npx:
14
+
15
+ ```bash
16
+ npx loopctl-mcp-server
17
+ ```
18
+
19
+ ## Configuration
20
+
21
+ Add to your `.mcp.json` (Claude Code) or equivalent MCP config:
22
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "loopctl": {
27
+ "command": "npx",
28
+ "args": ["loopctl-mcp-server"],
29
+ "env": {
30
+ "LOOPCTL_SERVER": "https://loopctl.com",
31
+ "LOOPCTL_ORCH_KEY": "lc_your_orchestrator_key",
32
+ "LOOPCTL_AGENT_KEY": "lc_your_agent_key"
33
+ }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Or if installed locally:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "loopctl": {
45
+ "command": "node",
46
+ "args": ["node_modules/loopctl-mcp-server/index.js"],
47
+ "env": {
48
+ "LOOPCTL_SERVER": "https://loopctl.com",
49
+ "LOOPCTL_ORCH_KEY": "lc_your_orchestrator_key",
50
+ "LOOPCTL_AGENT_KEY": "lc_your_agent_key"
51
+ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ## Environment Variables
58
+
59
+ | Variable | Description | Default |
60
+ |---|---|---|
61
+ | `LOOPCTL_SERVER` | loopctl server URL | `https://loopctl.com` |
62
+ | `LOOPCTL_API_KEY` | Global API key override (if set, always used) | -- |
63
+ | `LOOPCTL_ORCH_KEY` | Orchestrator role API key (verify, reject, review, import) | -- |
64
+ | `LOOPCTL_AGENT_KEY` | Agent role API key (contract, claim, start, request-review) | -- |
65
+
66
+ Key resolution priority: `LOOPCTL_API_KEY` > tool-specific key > `LOOPCTL_ORCH_KEY`.
67
+
68
+ ## Tools (19)
69
+
70
+ ### Project Tools
71
+
72
+ | Tool | Description |
73
+ |---|---|
74
+ | `get_tenant` | Get current tenant info. Use to verify connectivity. |
75
+ | `list_projects` | List all projects in the current tenant. |
76
+ | `create_project` | Create a new project in the current tenant. |
77
+ | `get_progress` | Get progress summary for a project, including story counts by status. |
78
+ | `import_stories` | Import stories into a project from a structured payload (Epic 12 import format). |
79
+
80
+ ### Story Tools
81
+
82
+ | Tool | Description |
83
+ |---|---|
84
+ | `list_stories` | List stories for a project, optionally filtered by agent_status, verified_status, or epic_id. |
85
+ | `list_ready_stories` | List stories that are ready to be worked on (contracted, dependencies met). |
86
+ | `get_story` | Get full details for a single story by ID. |
87
+
88
+ ### Workflow Tools (agent key)
89
+
90
+ | Tool | Description |
91
+ |---|---|
92
+ | `contract_story` | Agent acknowledges a story's acceptance criteria. Transitions pending -> contracted. |
93
+ | `claim_story` | Agent claims a contracted story with pessimistic locking. Transitions contracted -> assigned. |
94
+ | `start_story` | Agent starts work on a claimed story. Transitions assigned -> implementing. |
95
+ | `request_review` | Agent signals implementation is complete and ready for review. |
96
+
97
+ ### Reviewer Tools (orchestrator key)
98
+
99
+ | Tool | Description |
100
+ |---|---|
101
+ | `report_story` | Reviewer confirms the implementation is done. Transitions implementing -> reported_done. |
102
+ | `review_complete` | Record that a review has been completed for a story. Required before verify. |
103
+
104
+ ### Verification Tools (orchestrator key)
105
+
106
+ | Tool | Description |
107
+ |---|---|
108
+ | `verify_story` | Orchestrator verifies a reported_done story. Transitions reported_done -> verified. |
109
+ | `reject_story` | Orchestrator rejects a story with a reason. |
110
+
111
+ ### Bulk Tools (orchestrator key)
112
+
113
+ | Tool | Description |
114
+ |---|---|
115
+ | `bulk_mark_complete` | Bulk mark multiple stories as complete in a single API call. |
116
+ | `verify_all_in_epic` | Bulk verify all reported_done, unverified stories in an epic. |
117
+
118
+ ### Discovery Tools
119
+
120
+ | Tool | Description |
121
+ |---|---|
122
+ | `list_routes` | List all available API routes on the loopctl server. |
123
+
124
+ ## Chain-of-Custody Enforcement
125
+
126
+ 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:
127
+
128
+ - `report_story` -- 409 `self_report_blocked`
129
+ - `review_complete` -- 409 `self_review_blocked`
130
+ - `verify_story` -- 409 `self_verify_blocked`
131
+
132
+ The implementer's final action is `request_review`. All subsequent steps (report, review, verify) must come from different agents.
133
+
134
+ ## Troubleshooting
135
+
136
+ ### Connection errors
137
+
138
+ - Verify `LOOPCTL_SERVER` is set and reachable
139
+ - Check that the server URL includes the protocol (`https://`)
140
+ - If using a self-signed certificate, set `NODE_TLS_REJECT_UNAUTHORIZED=0` in your environment (not recommended for production)
141
+
142
+ ### Authentication errors (401)
143
+
144
+ - Verify your API key is correct and active
145
+ - Check that the key has the right role for the operation (agent vs orchestrator)
146
+ - Keys are prefixed with `lc_` -- ensure the full key is provided
147
+
148
+ ### Permission errors (403)
149
+
150
+ - Orchestrator operations require an orchestrator-role key
151
+ - Agent operations require an agent-role key
152
+ - Chain-of-custody violations return 409, not 403
153
+
154
+ ### Tool not found
155
+
156
+ - Ensure the MCP server is running (`npx loopctl-mcp-server` to test)
157
+ - Check your `.mcp.json` configuration syntax
158
+ - Restart your AI coding tool after configuration changes
159
+
160
+ ## Links
161
+
162
+ - [loopctl.com](https://loopctl.com) -- landing page and documentation
163
+ - [API docs](https://loopctl.com/swaggerui) -- Swagger UI
164
+ - [GitHub](https://github.com/mkreyman/loopctl) -- source code
165
+ - [npm](https://www.npmjs.com/package/loopctl-mcp-server) -- npm package
166
+
167
+ ## License
168
+
169
+ MIT
package/index.js ADDED
@@ -0,0 +1,817 @@
1
+ #!/usr/bin/env node
2
+
3
+ // loopctl MCP Server
4
+ // Wraps the loopctl REST API into typed MCP tools for Claude Code agents.
5
+ // Runs via stdio (stdin/stdout).
6
+
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import {
10
+ ListToolsRequestSchema,
11
+ CallToolRequestSchema,
12
+ } from "@modelcontextprotocol/sdk/types.js";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // HTTP helper
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function getBaseUrl() {
19
+ return (process.env.LOOPCTL_SERVER || "https://loopctl.com").replace(/\/$/, "");
20
+ }
21
+
22
+ /**
23
+ * Resolve which API key to use for a request.
24
+ *
25
+ * Priority:
26
+ * 1. LOOPCTL_API_KEY (global override — if set, always used)
27
+ * 2. keyOverride passed by the tool function (role-specific key)
28
+ * 3. LOOPCTL_ORCH_KEY (safe default for reads)
29
+ */
30
+ function resolveKey(keyOverride) {
31
+ return (
32
+ process.env.LOOPCTL_API_KEY ||
33
+ keyOverride ||
34
+ process.env.LOOPCTL_ORCH_KEY
35
+ );
36
+ }
37
+
38
+ async function apiCall(method, path, body, keyOverride) {
39
+ const url = `${getBaseUrl()}${path}`;
40
+ const key = resolveKey(keyOverride);
41
+
42
+ if (!key) {
43
+ return { error: true, status: 0, body: "No API key configured. Set LOOPCTL_API_KEY, LOOPCTL_ORCH_KEY, or LOOPCTL_AGENT_KEY." };
44
+ }
45
+
46
+ const options = {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${key}`,
50
+ "Content-Type": "application/json",
51
+ Accept: "application/json",
52
+ },
53
+ signal: AbortSignal.timeout(30_000),
54
+ };
55
+
56
+ if (body !== undefined && body !== null) {
57
+ options.body = JSON.stringify(body);
58
+ }
59
+
60
+ let response;
61
+ try {
62
+ response = await fetch(url, options);
63
+ } catch (err) {
64
+ if (err.name === "TimeoutError") {
65
+ return { error: true, status: 0, body: "Request timed out after 30s" };
66
+ }
67
+ const cause = err.cause?.message ? ` (${err.cause.message})` : "";
68
+ return { error: true, status: 0, body: `Network error: ${err.message}${cause}` };
69
+ }
70
+
71
+ if (response.status === 204) {
72
+ return { ok: true };
73
+ }
74
+
75
+ let responseBody;
76
+ const contentType = response.headers.get("content-type") || "";
77
+ if (contentType.includes("application/json")) {
78
+ responseBody = await response.json();
79
+ } else {
80
+ const text = await response.text();
81
+ try {
82
+ responseBody = JSON.parse(text);
83
+ } catch {
84
+ responseBody = text;
85
+ }
86
+ }
87
+
88
+ if (!response.ok) {
89
+ let errorBody = responseBody;
90
+ if (typeof errorBody === "string" && errorBody.length > 500) {
91
+ errorBody = errorBody
92
+ .replace(/<[^>]+>/g, " ")
93
+ .replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">")
94
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ")
95
+ .replace(/\s+/g, " ")
96
+ .trim()
97
+ .slice(0, 500) + "... (truncated)";
98
+ }
99
+ return { error: true, status: response.status, body: errorBody };
100
+ }
101
+
102
+ return responseBody;
103
+ }
104
+
105
+ function toContent(result) {
106
+ const isErr = result && result.error === true;
107
+ return {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: JSON.stringify(result, null, 2),
112
+ },
113
+ ],
114
+ ...(isErr && { isError: true }),
115
+ };
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Tool implementations
120
+ // ---------------------------------------------------------------------------
121
+
122
+ // --- Project Tools ---
123
+
124
+ async function getTenant() {
125
+ const result = await apiCall("GET", "/api/v1/tenants/me");
126
+ return toContent(result);
127
+ }
128
+
129
+ async function listProjects() {
130
+ const result = await apiCall("GET", "/api/v1/projects");
131
+ return toContent(result);
132
+ }
133
+
134
+ async function createProject({ name, slug, repo_url, description, tech_stack }) {
135
+ const body = { name, slug };
136
+ if (repo_url) body.repo_url = repo_url;
137
+ if (description) body.description = description;
138
+ if (tech_stack) body.tech_stack = tech_stack;
139
+ const result = await apiCall("POST", "/api/v1/projects", body, process.env.LOOPCTL_ORCH_KEY);
140
+ return toContent(result);
141
+ }
142
+
143
+ async function getProgress({ project_id }) {
144
+ const result = await apiCall("GET", `/api/v1/projects/${project_id}/progress`);
145
+ return toContent(result);
146
+ }
147
+
148
+ async function importStories({ project_id, payload }) {
149
+ const result = await apiCall(
150
+ "POST",
151
+ `/api/v1/projects/${project_id}/import`,
152
+ payload,
153
+ process.env.LOOPCTL_ORCH_KEY
154
+ );
155
+ return toContent(result);
156
+ }
157
+
158
+ // --- Story Tools ---
159
+
160
+ async function listStories({ project_id, agent_status, verified_status, epic_id, limit, offset }) {
161
+ const params = new URLSearchParams({ project_id });
162
+ if (agent_status) params.set("agent_status", agent_status);
163
+ if (verified_status) params.set("verified_status", verified_status);
164
+ if (epic_id) params.set("epic_id", epic_id);
165
+ if (limit != null) params.set("limit", String(limit));
166
+ if (offset != null) params.set("offset", String(offset));
167
+
168
+ const result = await apiCall("GET", `/api/v1/stories?${params}`);
169
+ return toContent(result);
170
+ }
171
+
172
+ async function listReadyStories({ project_id, limit }) {
173
+ const params = new URLSearchParams({ project_id });
174
+ if (limit != null) params.set("limit", String(limit));
175
+
176
+ const result = await apiCall("GET", `/api/v1/stories/ready?${params}`);
177
+ return toContent(result);
178
+ }
179
+
180
+ async function getStory({ story_id }) {
181
+ const result = await apiCall("GET", `/api/v1/stories/${story_id}`);
182
+ return toContent(result);
183
+ }
184
+
185
+ // --- Workflow Tools (agent key) ---
186
+
187
+ async function contractStory({ story_id, story_title, ac_count }) {
188
+ const result = await apiCall(
189
+ "POST",
190
+ `/api/v1/stories/${story_id}/contract`,
191
+ { story_title, ac_count },
192
+ process.env.LOOPCTL_AGENT_KEY
193
+ );
194
+ return toContent(result);
195
+ }
196
+
197
+ async function claimStory({ story_id }) {
198
+ const result = await apiCall(
199
+ "POST",
200
+ `/api/v1/stories/${story_id}/claim`,
201
+ null,
202
+ process.env.LOOPCTL_AGENT_KEY
203
+ );
204
+ return toContent(result);
205
+ }
206
+
207
+ async function startStory({ story_id }) {
208
+ const result = await apiCall(
209
+ "POST",
210
+ `/api/v1/stories/${story_id}/start`,
211
+ null,
212
+ process.env.LOOPCTL_AGENT_KEY
213
+ );
214
+ return toContent(result);
215
+ }
216
+
217
+ async function requestReview({ story_id }) {
218
+ const result = await apiCall(
219
+ "POST",
220
+ `/api/v1/stories/${story_id}/request-review`,
221
+ null,
222
+ process.env.LOOPCTL_AGENT_KEY
223
+ );
224
+ return toContent(result);
225
+ }
226
+
227
+ // --- Reviewer Tools (orch key — reviewer uses orchestrator role) ---
228
+
229
+ async function reportStory({ story_id, artifact_type, artifact_path }) {
230
+ const body = {};
231
+ if (artifact_type || artifact_path) {
232
+ body.artifact = {};
233
+ if (artifact_type) body.artifact.artifact_type = artifact_type;
234
+ if (artifact_path) body.artifact.path = artifact_path;
235
+ }
236
+
237
+ const result = await apiCall(
238
+ "POST",
239
+ `/api/v1/stories/${story_id}/report`,
240
+ Object.keys(body).length > 0 ? body : null,
241
+ process.env.LOOPCTL_ORCH_KEY
242
+ );
243
+ return toContent(result);
244
+ }
245
+
246
+ async function reviewComplete({ story_id, review_type, findings_count, fixes_count, disproved_count, summary }) {
247
+ const body = { review_type };
248
+ if (findings_count != null) body.findings_count = findings_count;
249
+ if (fixes_count != null) body.fixes_count = fixes_count;
250
+ if (disproved_count != null) body.disproved_count = disproved_count;
251
+ if (summary) body.summary = summary;
252
+
253
+ const result = await apiCall(
254
+ "POST",
255
+ `/api/v1/stories/${story_id}/review-complete`,
256
+ body,
257
+ process.env.LOOPCTL_ORCH_KEY
258
+ );
259
+ return toContent(result);
260
+ }
261
+
262
+ // --- Verification Tools (orch key) ---
263
+
264
+ async function verifyStory({ story_id, summary, review_type }) {
265
+ const body = {};
266
+ if (summary) body.summary = summary;
267
+ if (review_type) body.review_type = review_type;
268
+
269
+ const result = await apiCall(
270
+ "POST",
271
+ `/api/v1/stories/${story_id}/verify`,
272
+ body,
273
+ process.env.LOOPCTL_ORCH_KEY
274
+ );
275
+ return toContent(result);
276
+ }
277
+
278
+ async function rejectStory({ story_id, reason }) {
279
+ const result = await apiCall(
280
+ "POST",
281
+ `/api/v1/stories/${story_id}/reject`,
282
+ { reason },
283
+ process.env.LOOPCTL_ORCH_KEY
284
+ );
285
+ return toContent(result);
286
+ }
287
+
288
+ // --- Bulk Tools ---
289
+
290
+ async function bulkMarkComplete({ stories }) {
291
+ // stories: [{story_id, summary, review_type}]
292
+ const result = await apiCall(
293
+ "POST",
294
+ "/api/v1/stories/bulk/mark-complete",
295
+ { stories },
296
+ process.env.LOOPCTL_ORCH_KEY
297
+ );
298
+ return toContent(result);
299
+ }
300
+
301
+ async function verifyAllInEpic({ epic_id, review_type, summary }) {
302
+ const result = await apiCall(
303
+ "POST",
304
+ `/api/v1/epics/${epic_id}/verify-all`,
305
+ { review_type, summary },
306
+ process.env.LOOPCTL_ORCH_KEY
307
+ );
308
+ return toContent(result);
309
+ }
310
+
311
+ // --- Discovery Tools ---
312
+
313
+ async function listRoutes() {
314
+ const result = await apiCall("GET", "/api/v1/routes");
315
+ return toContent(result);
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Tool definitions
320
+ // ---------------------------------------------------------------------------
321
+
322
+ const TOOLS = [
323
+ // Project Tools
324
+ {
325
+ name: "get_tenant",
326
+ description: "Get current tenant info. Use to verify connectivity.",
327
+ inputSchema: {
328
+ type: "object",
329
+ properties: {},
330
+ required: [],
331
+ },
332
+ },
333
+ {
334
+ name: "list_projects",
335
+ description: "List all projects in the current tenant.",
336
+ inputSchema: {
337
+ type: "object",
338
+ properties: {},
339
+ required: [],
340
+ },
341
+ },
342
+ {
343
+ name: "create_project",
344
+ description: "Create a new project in the current tenant.",
345
+ inputSchema: {
346
+ type: "object",
347
+ properties: {
348
+ name: { type: "string", description: "Project name." },
349
+ slug: { type: "string", description: "URL-safe slug." },
350
+ repo_url: { type: "string", description: "GitHub repo URL." },
351
+ description: { type: "string", description: "Project description." },
352
+ tech_stack: { type: "string", description: "Tech stack summary." },
353
+ },
354
+ required: ["name", "slug"],
355
+ },
356
+ },
357
+ {
358
+ name: "get_progress",
359
+ description: "Get progress summary for a project, including story counts by status.",
360
+ inputSchema: {
361
+ type: "object",
362
+ properties: {
363
+ project_id: {
364
+ type: "string",
365
+ description: "The UUID of the project.",
366
+ },
367
+ },
368
+ required: ["project_id"],
369
+ },
370
+ },
371
+ {
372
+ name: "import_stories",
373
+ description: "Import stories into a project from a structured payload (Epic 12 import format).",
374
+ inputSchema: {
375
+ type: "object",
376
+ properties: {
377
+ project_id: {
378
+ type: "string",
379
+ description: "The UUID of the project to import into.",
380
+ },
381
+ payload: {
382
+ type: "object",
383
+ description: "The import payload object (epics + stories structure).",
384
+ },
385
+ },
386
+ required: ["project_id", "payload"],
387
+ },
388
+ },
389
+
390
+ // Story Tools
391
+ {
392
+ name: "list_stories",
393
+ description:
394
+ "List stories for a project, optionally filtered by agent_status, verified_status, or epic_id.",
395
+ inputSchema: {
396
+ type: "object",
397
+ properties: {
398
+ project_id: {
399
+ type: "string",
400
+ description: "The UUID of the project.",
401
+ },
402
+ agent_status: {
403
+ type: "string",
404
+ description:
405
+ "Filter by agent status (e.g. pending, contracted, assigned, implementing, reported_done, verified, rejected).",
406
+ },
407
+ verified_status: {
408
+ type: "string",
409
+ description: "Filter by verified status (e.g. unverified, verified, rejected).",
410
+ },
411
+ epic_id: {
412
+ type: "string",
413
+ description: "Filter by epic UUID.",
414
+ },
415
+ limit: {
416
+ type: "integer",
417
+ description: "Maximum number of stories to return.",
418
+ },
419
+ offset: {
420
+ type: "integer",
421
+ description: "Number of stories to skip (for pagination).",
422
+ },
423
+ },
424
+ required: ["project_id"],
425
+ },
426
+ },
427
+ {
428
+ name: "list_ready_stories",
429
+ description:
430
+ "List stories that are ready to be worked on (contracted, dependencies met). These are the stories an agent should pick up next.",
431
+ inputSchema: {
432
+ type: "object",
433
+ properties: {
434
+ project_id: {
435
+ type: "string",
436
+ description: "The UUID of the project.",
437
+ },
438
+ limit: {
439
+ type: "integer",
440
+ description: "Maximum number of stories to return.",
441
+ },
442
+ },
443
+ required: ["project_id"],
444
+ },
445
+ },
446
+ {
447
+ name: "get_story",
448
+ description: "Get full details for a single story by ID.",
449
+ inputSchema: {
450
+ type: "object",
451
+ properties: {
452
+ story_id: {
453
+ type: "string",
454
+ description: "The UUID of the story.",
455
+ },
456
+ },
457
+ required: ["story_id"],
458
+ },
459
+ },
460
+
461
+ // Workflow Tools (agent)
462
+ {
463
+ name: "contract_story",
464
+ description:
465
+ "Agent acknowledges a story's acceptance criteria to claim the contract. " +
466
+ "Transitions the story from pending to contracted. " +
467
+ "story_title and ac_count must match the actual story to prevent silent misclaims.",
468
+ inputSchema: {
469
+ type: "object",
470
+ properties: {
471
+ story_id: {
472
+ type: "string",
473
+ description: "The UUID of the story.",
474
+ },
475
+ story_title: {
476
+ type: "string",
477
+ description: "Must match the story's title exactly (anti-confusion check).",
478
+ },
479
+ ac_count: {
480
+ type: "integer",
481
+ description: "Must match the number of acceptance criteria in the story.",
482
+ },
483
+ },
484
+ required: ["story_id", "story_title", "ac_count"],
485
+ },
486
+ },
487
+ {
488
+ name: "claim_story",
489
+ description:
490
+ "Agent claims a contracted story. Uses pessimistic locking to prevent double-claims. " +
491
+ "Transitions contracted -> assigned. Uses the AGENT key.",
492
+ inputSchema: {
493
+ type: "object",
494
+ properties: {
495
+ story_id: {
496
+ type: "string",
497
+ description: "The UUID of the story.",
498
+ },
499
+ },
500
+ required: ["story_id"],
501
+ },
502
+ },
503
+ {
504
+ name: "start_story",
505
+ description:
506
+ "Agent starts work on a claimed story. Transitions assigned -> implementing. Uses the AGENT key.",
507
+ inputSchema: {
508
+ type: "object",
509
+ properties: {
510
+ story_id: {
511
+ type: "string",
512
+ description: "The UUID of the story.",
513
+ },
514
+ },
515
+ required: ["story_id"],
516
+ },
517
+ },
518
+ {
519
+ name: "request_review",
520
+ description:
521
+ "Agent signals that implementation is complete and ready for review. " +
522
+ "Does NOT change the story status — fires a webhook event for the reviewer. Uses the AGENT key.",
523
+ inputSchema: {
524
+ type: "object",
525
+ properties: {
526
+ story_id: {
527
+ type: "string",
528
+ description: "The UUID of the story.",
529
+ },
530
+ },
531
+ required: ["story_id"],
532
+ },
533
+ },
534
+
535
+ // Reviewer Tools (orch key)
536
+ {
537
+ name: "report_story",
538
+ description:
539
+ "Reviewer (a DIFFERENT agent from the implementer) confirms the implementation is done. " +
540
+ "Chain-of-custody: the implementing agent cannot call this. " +
541
+ "Transitions implementing -> reported_done. Uses the ORCH key.",
542
+ inputSchema: {
543
+ type: "object",
544
+ properties: {
545
+ story_id: {
546
+ type: "string",
547
+ description: "The UUID of the story.",
548
+ },
549
+ artifact_type: {
550
+ type: "string",
551
+ description: "Optional: type of artifact being reported (e.g. branch, pr, test_run).",
552
+ },
553
+ artifact_path: {
554
+ type: "string",
555
+ description: "Optional: path or URL of the artifact.",
556
+ },
557
+ },
558
+ required: ["story_id"],
559
+ },
560
+ },
561
+ {
562
+ name: "review_complete",
563
+ description:
564
+ "Record that a review has been completed for a story. " +
565
+ "Must be called before verify_story. Uses the ORCH key.",
566
+ inputSchema: {
567
+ type: "object",
568
+ properties: {
569
+ story_id: {
570
+ type: "string",
571
+ description: "The UUID of the story.",
572
+ },
573
+ review_type: {
574
+ type: "string",
575
+ description:
576
+ "The type of review conducted (e.g. enhanced_6_agent, single_reviewer, orchestrator).",
577
+ },
578
+ findings_count: {
579
+ type: "integer",
580
+ description: "Optional: number of findings from the review.",
581
+ },
582
+ fixes_count: {
583
+ type: "integer",
584
+ description: "Number of fixes applied. fixes_count + disproved_count must equal findings_count.",
585
+ },
586
+ disproved_count: {
587
+ type: "integer",
588
+ description: "Number of findings disproved as false positives. fixes_count + disproved_count must equal findings_count.",
589
+ },
590
+ summary: {
591
+ type: "string",
592
+ description: "Optional: summary of the review outcome.",
593
+ },
594
+ },
595
+ required: ["story_id", "review_type"],
596
+ },
597
+ },
598
+
599
+ // Verification Tools (orch key)
600
+ {
601
+ name: "verify_story",
602
+ description:
603
+ "Orchestrator verifies a reported_done story. " +
604
+ "Requires a review_record to exist (call review_complete first). " +
605
+ "Transitions reported_done -> verified. Uses the ORCH key.",
606
+ inputSchema: {
607
+ type: "object",
608
+ properties: {
609
+ story_id: {
610
+ type: "string",
611
+ description: "The UUID of the story.",
612
+ },
613
+ summary: {
614
+ type: "string",
615
+ description: "Optional: verification summary.",
616
+ },
617
+ review_type: {
618
+ type: "string",
619
+ description: "Optional: review type for the verification record.",
620
+ },
621
+ },
622
+ required: ["story_id"],
623
+ },
624
+ },
625
+ {
626
+ name: "reject_story",
627
+ description:
628
+ "Orchestrator rejects a story with a reason. " +
629
+ "Creates a verification_result with result=fail. Uses the ORCH key.",
630
+ inputSchema: {
631
+ type: "object",
632
+ properties: {
633
+ story_id: {
634
+ type: "string",
635
+ description: "The UUID of the story.",
636
+ },
637
+ reason: {
638
+ type: "string",
639
+ description: "Required: the reason for rejection.",
640
+ },
641
+ },
642
+ required: ["story_id", "reason"],
643
+ },
644
+ },
645
+
646
+ // Bulk Tools
647
+ {
648
+ name: "bulk_mark_complete",
649
+ description:
650
+ "Bulk mark multiple stories as complete in a single API call. " +
651
+ "Each story entry needs a story_id, summary, and review_type. Uses the ORCH key.",
652
+ inputSchema: {
653
+ type: "object",
654
+ properties: {
655
+ stories: {
656
+ type: "array",
657
+ description: "Array of stories to mark complete.",
658
+ items: {
659
+ type: "object",
660
+ properties: {
661
+ story_id: {
662
+ type: "string",
663
+ description: "The UUID of the story.",
664
+ },
665
+ summary: {
666
+ type: "string",
667
+ description: "Summary of the completion.",
668
+ },
669
+ review_type: {
670
+ type: "string",
671
+ description: "Review type used.",
672
+ },
673
+ },
674
+ required: ["story_id", "summary", "review_type"],
675
+ },
676
+ },
677
+ },
678
+ required: ["stories"],
679
+ },
680
+ },
681
+ {
682
+ name: "verify_all_in_epic",
683
+ description:
684
+ "Bulk verify all reported_done, unverified stories in an epic. " +
685
+ "Convenience endpoint for the orchestrator after a review pass. Uses the ORCH key.",
686
+ inputSchema: {
687
+ type: "object",
688
+ properties: {
689
+ epic_id: {
690
+ type: "string",
691
+ description: "The UUID of the epic.",
692
+ },
693
+ review_type: {
694
+ type: "string",
695
+ description: "The review type applied to all stories (e.g. enhanced_6_agent).",
696
+ },
697
+ summary: {
698
+ type: "string",
699
+ description: "Summary of the review/verification pass.",
700
+ },
701
+ },
702
+ required: ["epic_id", "review_type", "summary"],
703
+ },
704
+ },
705
+
706
+ // Discovery Tools
707
+ {
708
+ name: "list_routes",
709
+ description: "List all available API routes on the loopctl server.",
710
+ inputSchema: {
711
+ type: "object",
712
+ properties: {},
713
+ required: [],
714
+ },
715
+ },
716
+ ];
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // MCP Server
720
+ // ---------------------------------------------------------------------------
721
+
722
+ const server = new Server(
723
+ {
724
+ name: "loopctl",
725
+ version: "1.0.0",
726
+ },
727
+ {
728
+ capabilities: { tools: {} },
729
+ }
730
+ );
731
+
732
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
733
+ tools: TOOLS,
734
+ }));
735
+
736
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
737
+ const { name, arguments: args } = request.params;
738
+
739
+ switch (name) {
740
+ // Project Tools
741
+ case "get_tenant":
742
+ return await getTenant();
743
+
744
+ case "list_projects":
745
+ return await listProjects();
746
+
747
+ case "create_project":
748
+ return await createProject(args);
749
+
750
+ case "get_progress":
751
+ return await getProgress(args);
752
+
753
+ case "import_stories":
754
+ return await importStories(args);
755
+
756
+ // Story Tools
757
+ case "list_stories":
758
+ return await listStories(args);
759
+
760
+ case "list_ready_stories":
761
+ return await listReadyStories(args);
762
+
763
+ case "get_story":
764
+ return await getStory(args);
765
+
766
+ // Workflow Tools
767
+ case "contract_story":
768
+ return await contractStory(args);
769
+
770
+ case "claim_story":
771
+ return await claimStory(args);
772
+
773
+ case "start_story":
774
+ return await startStory(args);
775
+
776
+ case "request_review":
777
+ return await requestReview(args);
778
+
779
+ // Reviewer Tools
780
+ case "report_story":
781
+ return await reportStory(args);
782
+
783
+ case "review_complete":
784
+ return await reviewComplete(args);
785
+
786
+ // Verification Tools
787
+ case "verify_story":
788
+ return await verifyStory(args);
789
+
790
+ case "reject_story":
791
+ return await rejectStory(args);
792
+
793
+ // Bulk Tools
794
+ case "bulk_mark_complete":
795
+ return await bulkMarkComplete(args);
796
+
797
+ case "verify_all_in_epic":
798
+ return await verifyAllInEpic(args);
799
+
800
+ // Discovery Tools
801
+ case "list_routes":
802
+ return await listRoutes();
803
+
804
+ default:
805
+ throw new Error(`Unknown tool: ${name}`);
806
+ }
807
+ });
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Start
811
+ // ---------------------------------------------------------------------------
812
+
813
+ const transport = new StdioServerTransport();
814
+ await server.connect(transport).catch((err) => {
815
+ process.stderr.write(`loopctl MCP server failed to start: ${err.message}\n`);
816
+ process.exit(1);
817
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "loopctl-mcp-server",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for loopctl — structural trust for AI development loops",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "bin": {
8
+ "loopctl-mcp-server": "index.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "ai-agents",
16
+ "claude-code",
17
+ "loopctl",
18
+ "model-context-protocol",
19
+ "ai-development"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/mkreyman/loopctl.git",
24
+ "directory": "mcp-server"
25
+ },
26
+ "homepage": "https://loopctl.com",
27
+ "author": "Mark Kreyman",
28
+ "license": "MIT",
29
+ "exports": "./index.js",
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.28.0"
40
+ }
41
+ }