requ-mcp 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -3
- package/dist/coverage.d.ts +28 -4
- package/dist/coverage.js +48 -6
- package/dist/coverage.js.map +1 -1
- package/dist/export-import.d.ts +10 -0
- package/dist/export-import.js +127 -0
- package/dist/export-import.js.map +1 -0
- package/dist/index.js +663 -58
- package/dist/index.js.map +1 -1
- package/dist/public/app.js +914 -0
- package/dist/public/index.html +1418 -0
- package/dist/public/style.css +458 -0
- package/dist/schema.d.ts +666 -28
- package/dist/schema.js +90 -29
- package/dist/schema.js.map +1 -1
- package/dist/sqlite-store.d.ts +43 -0
- package/dist/sqlite-store.js +210 -0
- package/dist/sqlite-store.js.map +1 -0
- package/dist/storage.d.ts +16 -4
- package/dist/storage.js +53 -19
- package/dist/storage.js.map +1 -1
- package/dist/web-api.d.ts +9 -0
- package/dist/web-api.js +789 -0
- package/dist/web-api.js.map +1 -0
- package/package.json +9 -5
package/dist/index.js
CHANGED
|
@@ -6,10 +6,12 @@ import { promises as fs } from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import url from "node:url";
|
|
8
8
|
import { Store } from "./storage.js";
|
|
9
|
-
import {
|
|
9
|
+
import { SqliteStore } from "./sqlite-store.js";
|
|
10
|
+
import { ComponentStatus, CoverageMode, ExportPayload, Priority, PhaseStatus, RequirementStatus, StoryStatus, TestStatus, testKey, VcsRefKind, VcsRefState, } from "./schema.js";
|
|
10
11
|
import { danglingStoryTags, indexConductor, inspectConductorProject, linkedScenarioKeys, scenariosByStory, validateTestRef, } from "./conductor.js";
|
|
11
12
|
import { buildReport, buildTrend, findGaps, resolveStatuses } from "./coverage.js";
|
|
12
13
|
import { parseCucumberJson } from "./ingest.js";
|
|
14
|
+
import { buildExport, applyImport } from "./export-import.js";
|
|
13
15
|
const now = () => new Date().toISOString();
|
|
14
16
|
function json(data) {
|
|
15
17
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
@@ -23,16 +25,46 @@ function fail(message, extra) {
|
|
|
23
25
|
content: [{ type: "text", text: JSON.stringify({ error: message, ...extra }, null, 2) }],
|
|
24
26
|
};
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
/** SqliteStore instances for HTTP mode, keyed by URL-safe slug. */
|
|
29
|
+
const _stores = new Map();
|
|
30
|
+
/** Derive a URL-safe slug from a project root path. Deduplicates against `_stores`. */
|
|
31
|
+
function slugify(root) {
|
|
32
|
+
const base = path.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
33
|
+
if (!_stores.has(base))
|
|
34
|
+
return base;
|
|
35
|
+
let i = 2;
|
|
36
|
+
while (_stores.has(`${base}-${i}`))
|
|
37
|
+
i++;
|
|
38
|
+
return `${base}-${i}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Pre-load projects from env vars at HTTP server startup.
|
|
42
|
+
* REQU_PROJECTS: comma-separated absolute paths.
|
|
43
|
+
* Falls back to REQU_ROOT if REQU_PROJECTS is not set.
|
|
44
|
+
*/
|
|
45
|
+
function loadProjectsFromEnv() {
|
|
46
|
+
const raw = process.env.REQU_PROJECTS ?? process.env.REQU_ROOT;
|
|
47
|
+
if (!raw)
|
|
48
|
+
return;
|
|
49
|
+
// Deduplicate by resolved root — duplicate paths would share one .db file.
|
|
50
|
+
const roots = [...new Set(raw.split(",").map((s) => s.trim()).filter(Boolean).map((p) => path.resolve(p)))];
|
|
51
|
+
// Scope REQU_DB to single-project mode only — multiple projects must each use their own DB.
|
|
52
|
+
const dbOverride = roots.length === 1 ? (process.env.REQU_DB ?? undefined) : undefined;
|
|
53
|
+
for (const root of roots) {
|
|
54
|
+
try {
|
|
55
|
+
const slug = slugify(root);
|
|
56
|
+
_stores.set(slug, new SqliteStore(root, dbOverride));
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
throw new Error(`Failed to open store for project ${root}: ${err.message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
27
63
|
// ===========================================================================
|
|
28
64
|
// Project resolution
|
|
29
|
-
//
|
|
30
|
-
// A user-level (global) server must figure out which project each call targets.
|
|
31
|
-
// Precedence: explicit projectPath → REQU_ROOT → a workspace root (MCP roots)
|
|
32
|
-
// or cwd-ancestor that already contains `.requ/` → first workspace root → cwd.
|
|
33
65
|
// ===========================================================================
|
|
34
66
|
let cachedRoots = null;
|
|
35
|
-
async function workspaceRoots() {
|
|
67
|
+
async function workspaceRoots(server) {
|
|
36
68
|
if (cachedRoots)
|
|
37
69
|
return cachedRoots;
|
|
38
70
|
try {
|
|
@@ -43,7 +75,7 @@ async function workspaceRoots() {
|
|
|
43
75
|
.map((u) => url.fileURLToPath(u));
|
|
44
76
|
}
|
|
45
77
|
catch {
|
|
46
|
-
cachedRoots = [];
|
|
78
|
+
cachedRoots = [];
|
|
47
79
|
}
|
|
48
80
|
return cachedRoots;
|
|
49
81
|
}
|
|
@@ -67,45 +99,182 @@ async function findUp(start) {
|
|
|
67
99
|
dir = parent;
|
|
68
100
|
}
|
|
69
101
|
}
|
|
70
|
-
async function resolveRoot(explicit) {
|
|
102
|
+
async function resolveRoot(server, explicit) {
|
|
71
103
|
if (explicit)
|
|
72
104
|
return path.resolve(explicit);
|
|
73
105
|
if (process.env.REQU_ROOT)
|
|
74
106
|
return path.resolve(process.env.REQU_ROOT);
|
|
75
|
-
const roots = await workspaceRoots();
|
|
107
|
+
const roots = await workspaceRoots(server);
|
|
76
108
|
for (const r of roots)
|
|
77
109
|
if (await hasRequ(r))
|
|
78
|
-
return r;
|
|
110
|
+
return r;
|
|
79
111
|
const found = await findUp(process.cwd());
|
|
80
112
|
if (found)
|
|
81
113
|
return found;
|
|
82
|
-
return roots[0] ?? process.cwd();
|
|
114
|
+
return roots[0] ?? process.cwd();
|
|
83
115
|
}
|
|
84
|
-
async function getStore(explicit) {
|
|
85
|
-
|
|
116
|
+
async function getStore(server, explicit) {
|
|
117
|
+
if (process.env.REQU_TRANSPORT === "http") {
|
|
118
|
+
const root = await resolveRoot(server, explicit);
|
|
119
|
+
// Find an existing store for this root, or create a new one.
|
|
120
|
+
for (const store of _stores.values()) {
|
|
121
|
+
if (store.root === root)
|
|
122
|
+
return store;
|
|
123
|
+
}
|
|
124
|
+
const slug = slugify(root);
|
|
125
|
+
const store = new SqliteStore(root);
|
|
126
|
+
_stores.set(slug, store);
|
|
127
|
+
return store;
|
|
128
|
+
}
|
|
129
|
+
return new Store(await resolveRoot(server, explicit));
|
|
130
|
+
}
|
|
131
|
+
async function getStoreByKey(key, server) {
|
|
132
|
+
// HTTP mode: search loaded stores.
|
|
133
|
+
for (const store of _stores.values()) {
|
|
134
|
+
try {
|
|
135
|
+
const cfg = await store.readConfig();
|
|
136
|
+
if (cfg.key === key)
|
|
137
|
+
return store;
|
|
138
|
+
}
|
|
139
|
+
catch { /* skip uninitialized */ }
|
|
140
|
+
}
|
|
141
|
+
// stdio fallback: check the single auto-resolved store.
|
|
142
|
+
if (_stores.size === 0) {
|
|
143
|
+
try {
|
|
144
|
+
const store = await getStore(server, undefined);
|
|
145
|
+
const cfg = await store.readConfig();
|
|
146
|
+
if (cfg.key === key)
|
|
147
|
+
return store;
|
|
148
|
+
}
|
|
149
|
+
catch { /* no match */ }
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
86
152
|
}
|
|
87
153
|
const projectPathSchema = z
|
|
88
154
|
.string()
|
|
89
155
|
.optional()
|
|
90
156
|
.describe("Absolute path to the project root (the dir containing .requ/). Omit to auto-detect: REQU_ROOT, else a workspace root or ancestor of the cwd that contains .requ/.");
|
|
91
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* All tool definitions, collected once at module load. They are registered onto a
|
|
159
|
+
* fresh `McpServer` by `createServer()` — one server instance per stdio process or
|
|
160
|
+
* per HTTP session — so a single server is never connected to two transports.
|
|
161
|
+
*/
|
|
162
|
+
const toolDefs = [];
|
|
163
|
+
/** Collect a tool definition that auto-injects `projectPath` and resolves the Store. */
|
|
92
164
|
function tool(name, config, handler) {
|
|
93
|
-
|
|
94
|
-
|
|
165
|
+
toolDefs.push({ name, config, handler });
|
|
166
|
+
}
|
|
167
|
+
/** Build a fresh McpServer with every collected tool registered on it. */
|
|
168
|
+
function createServer() {
|
|
169
|
+
const server = new McpServer({ name: "requ-mcp", version: "0.2.0" });
|
|
170
|
+
for (const { name, config, handler } of toolDefs) {
|
|
171
|
+
const inputSchema = { ...(config.inputSchema ?? {}), projectPath: projectPathSchema };
|
|
172
|
+
server.registerTool(name, { title: config.title, description: config.description, inputSchema }, async (args) => {
|
|
173
|
+
try {
|
|
174
|
+
const store = await getStore(server, args.projectPath);
|
|
175
|
+
return (await handler(args, store));
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
return fail(e.message);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
// list_projects — enumerate all active projects on this server instance.
|
|
183
|
+
server.registerTool("list_projects", {
|
|
184
|
+
title: "List Projects",
|
|
185
|
+
description: "List all requ projects currently loaded on this server instance. " +
|
|
186
|
+
"Returns each project's key, name, and root path. " +
|
|
187
|
+
"Use this to discover which projects are available before calling other tools.",
|
|
188
|
+
inputSchema: {},
|
|
189
|
+
}, async () => {
|
|
95
190
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
191
|
+
// HTTP mode: _stores has one entry per loaded project root.
|
|
192
|
+
if (_stores.size > 0) {
|
|
193
|
+
const projects = [];
|
|
194
|
+
for (const store of _stores.values()) {
|
|
195
|
+
try {
|
|
196
|
+
const cfg = await store.readConfig();
|
|
197
|
+
projects.push({ key: cfg.key ?? null, name: cfg.name, root: store.root ?? "" });
|
|
198
|
+
}
|
|
199
|
+
catch { /* skip uninitialized */ }
|
|
200
|
+
}
|
|
201
|
+
return json(projects);
|
|
202
|
+
}
|
|
203
|
+
// Stdio / single-store mode: resolve the default store and report it.
|
|
204
|
+
try {
|
|
205
|
+
const store = await getStore(server, undefined);
|
|
206
|
+
const cfg = await store.readConfig();
|
|
207
|
+
return json([{ key: cfg.key ?? null, name: cfg.name, root: store.root ?? "" }]);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return json([]);
|
|
211
|
+
}
|
|
98
212
|
}
|
|
99
213
|
catch (e) {
|
|
100
214
|
return fail(e.message);
|
|
101
215
|
}
|
|
102
216
|
});
|
|
217
|
+
// get_project_brief is registered directly so it can use the server closure for key-based lookup.
|
|
218
|
+
server.registerTool("get_project_brief", {
|
|
219
|
+
title: "Get Project Brief",
|
|
220
|
+
description: "Retrieve a project's name, key, and Markdown brief by its project key. Useful when you know the project key but not its filesystem path.",
|
|
221
|
+
inputSchema: {
|
|
222
|
+
key: z.string().describe("The project key to look up (e.g. 'AUTH')."),
|
|
223
|
+
},
|
|
224
|
+
}, async (args) => {
|
|
225
|
+
try {
|
|
226
|
+
const store = await getStoreByKey(args.key, server);
|
|
227
|
+
if (!store) {
|
|
228
|
+
return json({ error: `No project found with key '${args.key}'.` });
|
|
229
|
+
}
|
|
230
|
+
const cfg = await store.readConfig();
|
|
231
|
+
return json({
|
|
232
|
+
key: cfg.key ?? null,
|
|
233
|
+
name: cfg.name,
|
|
234
|
+
brief: cfg.brief ?? "",
|
|
235
|
+
root: store.root ?? "",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
return fail(e.message);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
return server;
|
|
103
243
|
}
|
|
104
244
|
async function ensureInit(store) {
|
|
105
245
|
if (!(await store.isInitialized())) {
|
|
106
246
|
throw new Error(`requ project not initialized at ${store.root}. Run \`init_project\` first (pass projectPath to target a specific directory).`);
|
|
107
247
|
}
|
|
108
248
|
}
|
|
249
|
+
/** A `fail()` result if `id` is not an existing phase, else null. */
|
|
250
|
+
async function phaseError(store, id) {
|
|
251
|
+
if (await store.getPhase(id))
|
|
252
|
+
return null;
|
|
253
|
+
const phases = await store.listPhases();
|
|
254
|
+
return fail(`Unknown phase ${id}.`, { knownPhases: phases.map((p) => p.id) });
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Resolve the target phase for a newly created requirement/story:
|
|
258
|
+
* undefined → default to the active phase (may be unassigned),
|
|
259
|
+
* "" → explicitly unassigned,
|
|
260
|
+
* "P1" → validated against existing phases.
|
|
261
|
+
* Returns `{ value }` (value may be undefined = unassigned) or `{ error }`.
|
|
262
|
+
*/
|
|
263
|
+
async function resolveAssignedPhase(store, input) {
|
|
264
|
+
let id;
|
|
265
|
+
if (input === undefined)
|
|
266
|
+
id = await store.resolvePhaseId();
|
|
267
|
+
else if (input === "")
|
|
268
|
+
id = undefined;
|
|
269
|
+
else
|
|
270
|
+
id = input;
|
|
271
|
+
if (!id)
|
|
272
|
+
return { value: undefined };
|
|
273
|
+
const error = await phaseError(store, id);
|
|
274
|
+
if (error)
|
|
275
|
+
return { error };
|
|
276
|
+
return { value: id };
|
|
277
|
+
}
|
|
109
278
|
async function loadConductorIndex(store) {
|
|
110
279
|
const root = await store.conductorRoot();
|
|
111
280
|
return { root, index: await indexConductor(root) };
|
|
@@ -118,6 +287,8 @@ tool("init_project", {
|
|
|
118
287
|
description: "Create the `.requ/` directory at the resolved project root, record the Conductor project path and optional cucumber-json report path, and optionally create an initial phase. Before writing, it verifies the Conductor folder exists and is a real Conductor project (has features/ or a cucumber config), and reports its detected name. Refuses if the folder is missing/invalid unless force=true. Idempotent.",
|
|
119
288
|
inputSchema: {
|
|
120
289
|
name: z.string().optional(),
|
|
290
|
+
key: z.string().optional().describe("Short unique project identifier (e.g. 'AUTH'). Uppercase letters, digits, hyphens, underscores. 2–20 chars. Must be unique across projects."),
|
|
291
|
+
brief: z.string().optional().describe("Markdown-formatted description of what this project is about."),
|
|
121
292
|
conductorPath: z.string().optional().describe("Path to the Conductor project root (has features/). Default '.'."),
|
|
122
293
|
conductorReportPath: z
|
|
123
294
|
.string()
|
|
@@ -129,8 +300,6 @@ tool("init_project", {
|
|
|
129
300
|
}, async (args, store) => {
|
|
130
301
|
const existing = (await store.isInitialized()) ? await store.readConfig() : null;
|
|
131
302
|
const conductorPath = args.conductorPath ?? existing?.conductorPath ?? ".";
|
|
132
|
-
// Verify the Conductor folder is present and looks like a Conductor project
|
|
133
|
-
// BEFORE writing anything, and surface its detected name.
|
|
134
303
|
const conductorAbs = store.resolvePath(conductorPath);
|
|
135
304
|
const conductor = await inspectConductorProject(conductorAbs);
|
|
136
305
|
if (!conductor.isConductorProject && !args.force) {
|
|
@@ -140,6 +309,8 @@ tool("init_project", {
|
|
|
140
309
|
}
|
|
141
310
|
const config = {
|
|
142
311
|
name: args.name ?? existing?.name ?? path.basename(store.root),
|
|
312
|
+
key: args.key ?? existing?.key,
|
|
313
|
+
brief: args.brief ?? existing?.brief,
|
|
143
314
|
conductorPath,
|
|
144
315
|
conductorName: conductor.isConductorProject ? conductor.name : existing?.conductorName,
|
|
145
316
|
conductorReportPath: args.conductorReportPath ?? existing?.conductorReportPath,
|
|
@@ -149,7 +320,7 @@ tool("init_project", {
|
|
|
149
320
|
let phase;
|
|
150
321
|
if (args.initialPhase) {
|
|
151
322
|
phase = {
|
|
152
|
-
id: "
|
|
323
|
+
id: "P1",
|
|
153
324
|
name: args.initialPhase,
|
|
154
325
|
order: 1,
|
|
155
326
|
status: "active",
|
|
@@ -189,22 +360,118 @@ tool("check_conductor", {
|
|
|
189
360
|
return json(await inspectConductorProject(candidate));
|
|
190
361
|
});
|
|
191
362
|
// ===========================================================================
|
|
363
|
+
// Components
|
|
364
|
+
// ===========================================================================
|
|
365
|
+
tool("create_component", {
|
|
366
|
+
title: "Create component",
|
|
367
|
+
description: "Register a sub-system/component. The id should match broker domain_tags (e.g. 'C-auth' with domainTags=['auth','security']). Requirements reference component IDs in their components[] field. Coverage reports slice by component.",
|
|
368
|
+
inputSchema: {
|
|
369
|
+
id: z.string().min(1).describe("Unique identifier, e.g. 'C-auth'. Should match broker domain_tags."),
|
|
370
|
+
name: z.string().min(1).describe("Human-readable name, e.g. 'Authentication'."),
|
|
371
|
+
description: z.string().optional(),
|
|
372
|
+
domainTags: z.array(z.string()).optional().describe("Broker routing tags this component maps to, e.g. ['auth','security']."),
|
|
373
|
+
},
|
|
374
|
+
}, async (args, store) => {
|
|
375
|
+
await ensureInit(store);
|
|
376
|
+
const existing = await store.listComponents();
|
|
377
|
+
if (existing.some((c) => c.id === args.id))
|
|
378
|
+
return fail(`Component ${args.id} already exists.`);
|
|
379
|
+
const comp = {
|
|
380
|
+
id: args.id,
|
|
381
|
+
name: args.name,
|
|
382
|
+
description: args.description ?? "",
|
|
383
|
+
domainTags: args.domainTags ?? [],
|
|
384
|
+
status: "active",
|
|
385
|
+
createdAt: now(),
|
|
386
|
+
updatedAt: now(),
|
|
387
|
+
};
|
|
388
|
+
await store.writeComponent(comp);
|
|
389
|
+
return json(comp);
|
|
390
|
+
});
|
|
391
|
+
tool("list_components", {
|
|
392
|
+
title: "List components",
|
|
393
|
+
description: "List all registered components, optionally filtered by status.",
|
|
394
|
+
inputSchema: { status: ComponentStatus.optional() },
|
|
395
|
+
}, async (args, store) => {
|
|
396
|
+
await ensureInit(store);
|
|
397
|
+
let comps = await store.listComponents();
|
|
398
|
+
if (args.status)
|
|
399
|
+
comps = comps.filter((c) => c.status === args.status);
|
|
400
|
+
return json(comps);
|
|
401
|
+
});
|
|
402
|
+
tool("get_component", {
|
|
403
|
+
title: "Get component",
|
|
404
|
+
description: "Fetch one component and the requirements that reference it.",
|
|
405
|
+
inputSchema: { id: z.string().min(1) },
|
|
406
|
+
}, async (args, store) => {
|
|
407
|
+
await ensureInit(store);
|
|
408
|
+
const comp = await store.getComponent(args.id);
|
|
409
|
+
if (!comp)
|
|
410
|
+
return fail(`Component ${args.id} not found.`);
|
|
411
|
+
const reqs = await store.listRequirements();
|
|
412
|
+
return json({ ...comp, linkedRequirements: reqs.filter((r) => r.components.includes(args.id)).map((r) => r.id) });
|
|
413
|
+
});
|
|
414
|
+
tool("update_component", {
|
|
415
|
+
title: "Update component",
|
|
416
|
+
description: "Update a component's name, description, domainTags, or status.",
|
|
417
|
+
inputSchema: {
|
|
418
|
+
id: z.string().min(1),
|
|
419
|
+
name: z.string().optional(),
|
|
420
|
+
description: z.string().optional(),
|
|
421
|
+
domainTags: z.array(z.string()).optional(),
|
|
422
|
+
status: ComponentStatus.optional(),
|
|
423
|
+
},
|
|
424
|
+
}, async (args, store) => {
|
|
425
|
+
await ensureInit(store);
|
|
426
|
+
const comp = await store.getComponent(args.id);
|
|
427
|
+
if (!comp)
|
|
428
|
+
return fail(`Component ${args.id} not found.`);
|
|
429
|
+
if (args.name !== undefined)
|
|
430
|
+
comp.name = args.name;
|
|
431
|
+
if (args.description !== undefined)
|
|
432
|
+
comp.description = args.description;
|
|
433
|
+
if (args.domainTags !== undefined)
|
|
434
|
+
comp.domainTags = args.domainTags;
|
|
435
|
+
if (args.status !== undefined)
|
|
436
|
+
comp.status = args.status;
|
|
437
|
+
comp.updatedAt = now();
|
|
438
|
+
await store.writeComponent(comp);
|
|
439
|
+
return json(comp);
|
|
440
|
+
});
|
|
441
|
+
// ===========================================================================
|
|
192
442
|
// Requirements
|
|
193
443
|
// ===========================================================================
|
|
194
444
|
tool("create_requirement", {
|
|
195
445
|
title: "Create requirement",
|
|
196
|
-
description: "Register an imported requirement (the upstream 'what must be built').",
|
|
446
|
+
description: "Register an imported requirement (the upstream 'what must be built'). components[] must contain valid Component IDs if any components have been registered.",
|
|
197
447
|
inputSchema: {
|
|
198
448
|
title: z.string(),
|
|
199
449
|
description: z.string().optional(),
|
|
200
450
|
source: z.string().optional().describe("Provenance: doc, spec section, ticket id."),
|
|
201
451
|
priority: Priority.optional(),
|
|
202
|
-
components: z.array(z.string()).optional().describe("
|
|
452
|
+
components: z.array(z.string()).optional().describe("Component IDs this requirement belongs to (matches Component.id)."),
|
|
203
453
|
tags: z.array(z.string()).optional(),
|
|
454
|
+
phase: z.string().optional().describe("Target phase this requirement is planned for (e.g. 'P1'). Defaults to the active phase; pass '' to leave unassigned."),
|
|
204
455
|
id: z.string().regex(/^REQ-\d+$/).optional(),
|
|
205
456
|
},
|
|
206
457
|
}, async (args, store) => {
|
|
207
458
|
await ensureInit(store);
|
|
459
|
+
// Resolve & validate target phase (default to active phase; "" = unassigned).
|
|
460
|
+
const phase = await resolveAssignedPhase(store, args.phase);
|
|
461
|
+
if (phase.error)
|
|
462
|
+
return phase.error;
|
|
463
|
+
// Validate component IDs if components exist
|
|
464
|
+
if (args.components?.length) {
|
|
465
|
+
const existingComps = await store.listComponents();
|
|
466
|
+
if (existingComps.length > 0) {
|
|
467
|
+
const unknown = args.components.filter((c) => !existingComps.some((e) => e.id === c));
|
|
468
|
+
if (unknown.length) {
|
|
469
|
+
return fail(`Unknown component(s): ${unknown.join(", ")}. Create them first with create_component.`, {
|
|
470
|
+
knownComponents: existingComps.map((c) => c.id),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
208
475
|
const existing = await store.listRequirements();
|
|
209
476
|
const id = args.id ?? Store.nextId("REQ", existing.map((r) => r.id));
|
|
210
477
|
if (existing.some((r) => r.id === id))
|
|
@@ -218,6 +485,7 @@ tool("create_requirement", {
|
|
|
218
485
|
components: args.components ?? [],
|
|
219
486
|
tags: args.tags ?? [],
|
|
220
487
|
status: "active",
|
|
488
|
+
...(phase.value ? { phase: phase.value } : {}),
|
|
221
489
|
createdAt: now(),
|
|
222
490
|
updatedAt: now(),
|
|
223
491
|
};
|
|
@@ -231,6 +499,7 @@ tool("list_requirements", {
|
|
|
231
499
|
status: RequirementStatus.optional(),
|
|
232
500
|
component: z.string().optional(),
|
|
233
501
|
tag: z.string().optional(),
|
|
502
|
+
phase: z.string().optional().describe("Filter by assigned target phase (exact match)."),
|
|
234
503
|
},
|
|
235
504
|
}, async (args, store) => {
|
|
236
505
|
await ensureInit(store);
|
|
@@ -241,6 +510,8 @@ tool("list_requirements", {
|
|
|
241
510
|
reqs = reqs.filter((r) => r.components.includes(args.component));
|
|
242
511
|
if (args.tag)
|
|
243
512
|
reqs = reqs.filter((r) => r.tags.includes(args.tag));
|
|
513
|
+
if (args.phase)
|
|
514
|
+
reqs = reqs.filter((r) => r.phase === args.phase);
|
|
244
515
|
return json(reqs);
|
|
245
516
|
});
|
|
246
517
|
tool("get_requirement", {
|
|
@@ -266,6 +537,7 @@ tool("update_requirement", {
|
|
|
266
537
|
priority: Priority.optional(),
|
|
267
538
|
components: z.array(z.string()).optional(),
|
|
268
539
|
tags: z.array(z.string()).optional(),
|
|
540
|
+
phase: z.string().optional().describe("Target phase (e.g. 'P1'). Pass '' to clear the assignment."),
|
|
269
541
|
status: RequirementStatus.optional(),
|
|
270
542
|
},
|
|
271
543
|
}, async (args, store) => {
|
|
@@ -273,6 +545,16 @@ tool("update_requirement", {
|
|
|
273
545
|
const req = await store.getRequirement(args.id);
|
|
274
546
|
if (!req)
|
|
275
547
|
return fail(`Requirement ${args.id} not found.`);
|
|
548
|
+
if (args.phase !== undefined) {
|
|
549
|
+
if (args.phase === "")
|
|
550
|
+
req.phase = undefined;
|
|
551
|
+
else {
|
|
552
|
+
const error = await phaseError(store, args.phase);
|
|
553
|
+
if (error)
|
|
554
|
+
return error;
|
|
555
|
+
req.phase = args.phase;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
276
558
|
for (const k of ["title", "description", "source", "priority", "components", "tags", "status"]) {
|
|
277
559
|
if (args[k] !== undefined)
|
|
278
560
|
req[k] = args[k];
|
|
@@ -289,9 +571,10 @@ tool("create_user_story", {
|
|
|
289
571
|
description: "Author a user story. MUST link ≥1 existing requirement (validated). Acceptance criteria are descriptive; tests are linked by tagging scenarios with @<this story id> in the feature files.",
|
|
290
572
|
inputSchema: {
|
|
291
573
|
title: z.string(),
|
|
292
|
-
requirements: z.array(z.string().regex(/^REQ-\d+$/)).min(1),
|
|
574
|
+
requirements: z.array(z.string().regex(/^REQ-\d+$/)).min(1).describe("IDs of existing requirements this story implements."),
|
|
293
575
|
description: z.string().optional(),
|
|
294
576
|
acceptanceCriteria: z.array(z.string()).optional().describe("Descriptive criterion texts."),
|
|
577
|
+
phase: z.string().optional().describe("Target phase this story is planned for (e.g. 'P1'). Defaults to the active phase; pass '' to leave unassigned."),
|
|
295
578
|
id: z.string().regex(/^US-\d+$/).optional(),
|
|
296
579
|
},
|
|
297
580
|
}, async (args, store) => {
|
|
@@ -302,6 +585,9 @@ tool("create_user_story", {
|
|
|
302
585
|
missing.push(reqId);
|
|
303
586
|
if (missing.length)
|
|
304
587
|
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
588
|
+
const phase = await resolveAssignedPhase(store, args.phase);
|
|
589
|
+
if (phase.error)
|
|
590
|
+
return phase.error;
|
|
305
591
|
const existing = await store.listStories();
|
|
306
592
|
const id = args.id ?? Store.nextId("US", existing.map((s) => s.id));
|
|
307
593
|
if (existing.some((s) => s.id === id))
|
|
@@ -317,6 +603,7 @@ tool("create_user_story", {
|
|
|
317
603
|
requirements: args.requirements,
|
|
318
604
|
acceptanceCriteria,
|
|
319
605
|
status: "draft",
|
|
606
|
+
...(phase.value ? { phase: phase.value } : {}),
|
|
320
607
|
createdAt: now(),
|
|
321
608
|
updatedAt: now(),
|
|
322
609
|
};
|
|
@@ -326,7 +613,11 @@ tool("create_user_story", {
|
|
|
326
613
|
tool("list_user_stories", {
|
|
327
614
|
title: "List user stories",
|
|
328
615
|
description: "List user stories, optionally filtered by status or linked requirement.",
|
|
329
|
-
inputSchema: {
|
|
616
|
+
inputSchema: {
|
|
617
|
+
status: StoryStatus.optional(),
|
|
618
|
+
requirement: z.string().regex(/^REQ-\d+$/).optional(),
|
|
619
|
+
phase: z.string().optional().describe("Filter by assigned target phase (exact match)."),
|
|
620
|
+
},
|
|
330
621
|
}, async (args, store) => {
|
|
331
622
|
await ensureInit(store);
|
|
332
623
|
let stories = await store.listStories();
|
|
@@ -334,6 +625,8 @@ tool("list_user_stories", {
|
|
|
334
625
|
stories = stories.filter((s) => s.status === args.status);
|
|
335
626
|
if (args.requirement)
|
|
336
627
|
stories = stories.filter((s) => s.requirements.includes(args.requirement));
|
|
628
|
+
if (args.phase)
|
|
629
|
+
stories = stories.filter((s) => s.phase === args.phase);
|
|
337
630
|
return json(stories);
|
|
338
631
|
});
|
|
339
632
|
tool("get_user_story", {
|
|
@@ -352,7 +645,7 @@ tool("get_user_story", {
|
|
|
352
645
|
const status = resolveStatuses(execByPhase, phases, phaseId, "cumulative");
|
|
353
646
|
return json({
|
|
354
647
|
...story,
|
|
355
|
-
|
|
648
|
+
statusPhase: phaseId,
|
|
356
649
|
linkedScenarios: scs.map((sc) => ({
|
|
357
650
|
feature: sc.feature,
|
|
358
651
|
name: sc.name,
|
|
@@ -369,6 +662,7 @@ tool("update_user_story", {
|
|
|
369
662
|
description: z.string().optional(),
|
|
370
663
|
status: StoryStatus.optional(),
|
|
371
664
|
requirements: z.array(z.string().regex(/^REQ-\d+$/)).min(1).optional(),
|
|
665
|
+
phase: z.string().optional().describe("Target phase (e.g. 'P1'). Pass '' to clear the assignment."),
|
|
372
666
|
},
|
|
373
667
|
}, async (args, store) => {
|
|
374
668
|
await ensureInit(store);
|
|
@@ -384,6 +678,16 @@ tool("update_user_story", {
|
|
|
384
678
|
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
385
679
|
story.requirements = args.requirements;
|
|
386
680
|
}
|
|
681
|
+
if (args.phase !== undefined) {
|
|
682
|
+
if (args.phase === "")
|
|
683
|
+
story.phase = undefined;
|
|
684
|
+
else {
|
|
685
|
+
const error = await phaseError(store, args.phase);
|
|
686
|
+
if (error)
|
|
687
|
+
return error;
|
|
688
|
+
story.phase = args.phase;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
387
691
|
if (args.title !== undefined)
|
|
388
692
|
story.title = args.title;
|
|
389
693
|
if (args.description !== undefined)
|
|
@@ -418,20 +722,22 @@ tool("add_acceptance_criterion", {
|
|
|
418
722
|
// ===========================================================================
|
|
419
723
|
tool("create_phase", {
|
|
420
724
|
title: "Create phase/release",
|
|
421
|
-
description: "Create a phase (sprint or release)
|
|
725
|
+
description: "Create a phase (sprint or release). The id must be unique and MUST match the broker phase_id (e.g. 'P1', 'Sprint-3') so both systems share the same identifier. Order auto-increments if not provided. Optionally make it the active phase.",
|
|
422
726
|
inputSchema: {
|
|
423
|
-
|
|
727
|
+
id: z.string().min(1).describe("Phase identifier; use the same value as broker phase_id (e.g. 'P1', 'Sprint-3')."),
|
|
728
|
+
name: z.string().describe("e.g. 'Phase 1 MVP', 'Sprint 3'."),
|
|
424
729
|
order: z.number().int().optional().describe("Sort key; defaults to max+1."),
|
|
425
730
|
description: z.string().optional(),
|
|
426
|
-
activate: z.boolean().optional(),
|
|
731
|
+
activate: z.boolean().optional().describe("Make this the active phase."),
|
|
427
732
|
},
|
|
428
733
|
}, async (args, store) => {
|
|
429
734
|
await ensureInit(store);
|
|
430
735
|
const existing = await store.listPhases();
|
|
431
|
-
|
|
736
|
+
if (existing.some((p) => p.id === args.id))
|
|
737
|
+
return fail(`Phase ${args.id} already exists.`);
|
|
432
738
|
const order = args.order ?? (existing.reduce((m, p) => Math.max(m, p.order), 0) + 1);
|
|
433
739
|
const phase = {
|
|
434
|
-
id,
|
|
740
|
+
id: args.id,
|
|
435
741
|
name: args.name,
|
|
436
742
|
order,
|
|
437
743
|
status: args.activate ? "active" : "planned",
|
|
@@ -442,7 +748,7 @@ tool("create_phase", {
|
|
|
442
748
|
await store.writePhase(phase);
|
|
443
749
|
if (args.activate || existing.length === 0) {
|
|
444
750
|
const cfg = await store.readConfig();
|
|
445
|
-
await store.writeConfig({ ...cfg, activePhase: id });
|
|
751
|
+
await store.writeConfig({ ...cfg, activePhase: args.id });
|
|
446
752
|
}
|
|
447
753
|
return json(phase);
|
|
448
754
|
});
|
|
@@ -455,7 +761,7 @@ tool("update_phase", {
|
|
|
455
761
|
title: "Update phase",
|
|
456
762
|
description: "Update a phase's name, order, status, or description.",
|
|
457
763
|
inputSchema: {
|
|
458
|
-
id: z.string().
|
|
764
|
+
id: z.string().min(1),
|
|
459
765
|
name: z.string().optional(),
|
|
460
766
|
order: z.number().int().optional(),
|
|
461
767
|
status: PhaseStatus.optional(),
|
|
@@ -477,7 +783,7 @@ tool("update_phase", {
|
|
|
477
783
|
tool("set_active_phase", {
|
|
478
784
|
title: "Set active phase",
|
|
479
785
|
description: "Set which phase new executions are recorded against by default.",
|
|
480
|
-
inputSchema: { id: z.string().
|
|
786
|
+
inputSchema: { id: z.string().min(1) },
|
|
481
787
|
}, async (args, store) => {
|
|
482
788
|
await ensureInit(store);
|
|
483
789
|
if (!(await store.getPhase(args.id)))
|
|
@@ -487,7 +793,7 @@ tool("set_active_phase", {
|
|
|
487
793
|
return json({ activePhase: args.id });
|
|
488
794
|
});
|
|
489
795
|
// ===========================================================================
|
|
490
|
-
// Links (derived from @US-xxx scenario tags)
|
|
796
|
+
// Links (derived from @US-xxx scenario tags)
|
|
491
797
|
// ===========================================================================
|
|
492
798
|
tool("list_links", {
|
|
493
799
|
title: "List tag-derived links",
|
|
@@ -516,12 +822,12 @@ tool("list_links", {
|
|
|
516
822
|
// ===========================================================================
|
|
517
823
|
tool("record_execution", {
|
|
518
824
|
title: "Record a test execution",
|
|
519
|
-
description: "Record one scenario result against a phase (default: active). Validated against the Conductor project. Use for ad-hoc results or
|
|
825
|
+
description: "Record one scenario result against a phase (default: active). Validated against the Conductor project. Use for ad-hoc results or for teams running on a different machine than the requ-mcp server (no local file access needed). Use import_execution_report for whole cucumber runs on the same machine.",
|
|
520
826
|
inputSchema: {
|
|
521
827
|
feature: z.string().describe("Feature name (the `Feature:` line)."),
|
|
522
828
|
name: z.string().describe("Scenario name (the `Scenario:` line)."),
|
|
523
829
|
status: TestStatus,
|
|
524
|
-
phase: z.string().
|
|
830
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to the active phase."),
|
|
525
831
|
runId: z.string().optional(),
|
|
526
832
|
note: z.string().optional(),
|
|
527
833
|
},
|
|
@@ -550,10 +856,10 @@ tool("record_execution", {
|
|
|
550
856
|
});
|
|
551
857
|
tool("import_execution_report", {
|
|
552
858
|
title: "Import Conductor cucumber-json report",
|
|
553
|
-
description: "Parse a Conductor cucumber-js JSON result file and record one execution per scenario into a phase (default: active). Reports how many scenarios are tagged to a story. Path defaults to config.conductorReportPath.",
|
|
859
|
+
description: "Parse a Conductor cucumber-js JSON result file and record one execution per scenario into a phase (default: active). Reports how many scenarios are tagged to a story. Path defaults to config.conductorReportPath. Requires the report file to be accessible on the machine running requ-mcp.",
|
|
554
860
|
inputSchema: {
|
|
555
861
|
filePath: z.string().optional().describe("Path to the cucumber-json file. Defaults to config.conductorReportPath."),
|
|
556
|
-
phase: z.string().
|
|
862
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
557
863
|
runId: z.string().optional().describe("Run identifier stamped on every imported execution."),
|
|
558
864
|
},
|
|
559
865
|
}, async (args, store) => {
|
|
@@ -612,40 +918,50 @@ tool("import_execution_report", {
|
|
|
612
918
|
});
|
|
613
919
|
});
|
|
614
920
|
// ===========================================================================
|
|
615
|
-
// Reporting
|
|
921
|
+
// Reporting
|
|
616
922
|
// ===========================================================================
|
|
617
923
|
async function resolveForReport(store, phase, mode) {
|
|
618
|
-
const [reqs, stories, phases, execByPhase, { index }] = await Promise.all([
|
|
924
|
+
const [reqs, stories, phases, execByPhase, { index }, vcsRefs] = await Promise.all([
|
|
619
925
|
store.listRequirements(),
|
|
620
926
|
store.listStories(),
|
|
621
927
|
store.listPhases(),
|
|
622
928
|
store.readAllExecutions(),
|
|
623
929
|
loadConductorIndex(store),
|
|
930
|
+
store.listVcsRefs(),
|
|
624
931
|
]);
|
|
625
932
|
const phaseId = await store.resolvePhaseId(phase);
|
|
626
933
|
const m = mode ?? "cumulative";
|
|
627
934
|
const status = resolveStatuses(execByPhase, phases, phaseId, m);
|
|
628
935
|
const byStory = scenariosByStory(index);
|
|
629
|
-
return { reqs, stories, phases, byStory, status, phaseId, mode: m };
|
|
936
|
+
return { reqs, stories, phases, byStory, status, phaseId, mode: m, vcsRefs };
|
|
630
937
|
}
|
|
631
938
|
tool("coverage_report", {
|
|
632
939
|
title: "Coverage report",
|
|
633
|
-
description: "Story-level coverage for a phase: requirement → story → tagged scenarios, per-component breakdown, summary %. mode='cumulative' (latest result as of the phase) or 'strict' (only this phase's runs). Defaults to active phase, cumulative.",
|
|
940
|
+
description: "Story-level coverage for a phase: requirement → story → tagged scenarios, per-component breakdown (with component name and domainTags), summary %. mode='cumulative' (latest result as of the phase) or 'strict' (only this phase's runs). Defaults to active phase, cumulative.",
|
|
634
941
|
inputSchema: {
|
|
635
|
-
phase: z.string().
|
|
942
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
636
943
|
mode: CoverageMode.optional(),
|
|
637
944
|
format: z.enum(["json", "markdown"]).optional(),
|
|
638
945
|
},
|
|
639
946
|
}, async (args, store) => {
|
|
640
947
|
await ensureInit(store);
|
|
641
|
-
const { reqs, stories, byStory, status, phaseId, mode } = await resolveForReport(store, args.phase, args.mode);
|
|
642
|
-
const report = buildReport(reqs, stories, byStory, status, phaseId, mode);
|
|
948
|
+
const { reqs, stories, byStory, status, phaseId, mode, vcsRefs, phases } = await resolveForReport(store, args.phase, args.mode);
|
|
949
|
+
const report = buildReport(reqs, stories, byStory, status, phaseId, mode, vcsRefs, phases);
|
|
950
|
+
// Enrich byComponent with component name and domainTags
|
|
951
|
+
const components = await store.listComponents();
|
|
952
|
+
const compMap = new Map(components.map((c) => [c.id, c]));
|
|
953
|
+
const enrichedByComponent = report.byComponent.map((bc) => ({
|
|
954
|
+
...bc,
|
|
955
|
+
componentName: compMap.get(bc.component)?.name ?? bc.component,
|
|
956
|
+
domainTags: compMap.get(bc.component)?.domainTags ?? [],
|
|
957
|
+
}));
|
|
958
|
+
const enrichedReport = { ...report, byComponent: enrichedByComponent };
|
|
643
959
|
if (args.format === "markdown") {
|
|
644
960
|
const phases = await store.listPhases();
|
|
645
961
|
const name = phases.find((p) => p.id === phaseId)?.name ?? "(none)";
|
|
646
|
-
return text(renderMarkdown(
|
|
962
|
+
return text(renderMarkdown(enrichedReport, name));
|
|
647
963
|
}
|
|
648
|
-
return json(
|
|
964
|
+
return json(enrichedReport);
|
|
649
965
|
});
|
|
650
966
|
tool("coverage_trend", {
|
|
651
967
|
title: "Coverage evolution by phase",
|
|
@@ -666,11 +982,221 @@ tool("coverage_trend", {
|
|
|
666
982
|
tool("find_gaps", {
|
|
667
983
|
title: "Find coverage gaps",
|
|
668
984
|
description: "For a phase: active requirements with no story, stories with no tagged scenario, and stories not covered (with failing/not-run scenarios). Defaults to active phase, cumulative.",
|
|
669
|
-
inputSchema: {
|
|
985
|
+
inputSchema: {
|
|
986
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
987
|
+
mode: CoverageMode.optional(),
|
|
988
|
+
},
|
|
670
989
|
}, async (args, store) => {
|
|
671
990
|
await ensureInit(store);
|
|
672
|
-
const { reqs, stories, byStory, status, phaseId, mode } = await resolveForReport(store, args.phase, args.mode);
|
|
673
|
-
return json(findGaps(reqs, stories, byStory, status, phaseId, mode));
|
|
991
|
+
const { reqs, stories, byStory, status, phaseId, mode, phases } = await resolveForReport(store, args.phase, args.mode);
|
|
992
|
+
return json(findGaps(reqs, stories, byStory, status, phaseId, mode, phases));
|
|
993
|
+
});
|
|
994
|
+
// ===========================================================================
|
|
995
|
+
// VCS references (GitLab branches / merge requests)
|
|
996
|
+
//
|
|
997
|
+
// requ-mcp NEVER calls the VCS provider and holds NO token. These tools only
|
|
998
|
+
// record references that nodes report, for traceability.
|
|
999
|
+
// ===========================================================================
|
|
1000
|
+
tool("set_repo", {
|
|
1001
|
+
title: "Set VCS repository reference",
|
|
1002
|
+
description: "Record the project's VCS repository reference (repoUrl, defaultBranch, vcsType) in config. requ-mcp never calls the VCS provider — it only stores these references for traceability.",
|
|
1003
|
+
inputSchema: {
|
|
1004
|
+
repoUrl: z.string().describe("Repository URL, e.g. 'https://gitlab.com/group/project'."),
|
|
1005
|
+
defaultBranch: z.string().optional().describe("Default branch name. Defaults to 'main'."),
|
|
1006
|
+
vcsType: z.enum(["gitlab"]).optional().describe("VCS provider type."),
|
|
1007
|
+
},
|
|
1008
|
+
}, async (args, store) => {
|
|
1009
|
+
await ensureInit(store);
|
|
1010
|
+
const cfg = await store.readConfig();
|
|
1011
|
+
const next = {
|
|
1012
|
+
...cfg,
|
|
1013
|
+
repoUrl: args.repoUrl,
|
|
1014
|
+
defaultBranch: args.defaultBranch ?? cfg.defaultBranch ?? "main",
|
|
1015
|
+
vcsType: args.vcsType ?? cfg.vcsType,
|
|
1016
|
+
};
|
|
1017
|
+
await store.writeConfig(next);
|
|
1018
|
+
return json({ repoUrl: next.repoUrl, defaultBranch: next.defaultBranch, vcsType: next.vcsType ?? null });
|
|
1019
|
+
});
|
|
1020
|
+
tool("get_repo", {
|
|
1021
|
+
title: "Get VCS repository reference",
|
|
1022
|
+
description: "Return the recorded VCS repository config: repoUrl, defaultBranch, vcsType.",
|
|
1023
|
+
inputSchema: {},
|
|
1024
|
+
}, async (_args, store) => {
|
|
1025
|
+
await ensureInit(store);
|
|
1026
|
+
const cfg = await store.readConfig();
|
|
1027
|
+
return json({ repoUrl: cfg.repoUrl ?? null, defaultBranch: cfg.defaultBranch ?? "main", vcsType: cfg.vcsType ?? null });
|
|
1028
|
+
});
|
|
1029
|
+
/** Validate story/requirement ids referenced by a VcsRef; fail on unknown ids. */
|
|
1030
|
+
async function validateVcsLinks(store, storyIds, requirementIds) {
|
|
1031
|
+
const missingStories = [];
|
|
1032
|
+
for (const id of storyIds)
|
|
1033
|
+
if (!(await store.getStory(id)))
|
|
1034
|
+
missingStories.push(id);
|
|
1035
|
+
if (missingStories.length)
|
|
1036
|
+
return fail(`Unknown story id(s): ${missingStories.join(", ")}`);
|
|
1037
|
+
const missingReqs = [];
|
|
1038
|
+
for (const id of requirementIds)
|
|
1039
|
+
if (!(await store.getRequirement(id)))
|
|
1040
|
+
missingReqs.push(id);
|
|
1041
|
+
if (missingReqs.length)
|
|
1042
|
+
return fail(`Unknown requirement id(s): ${missingReqs.join(", ")}`);
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
tool("link_branch", {
|
|
1046
|
+
title: "Link a VCS branch reference",
|
|
1047
|
+
description: "Record a reference to a VCS branch (kind='branch', state='opened'), auto-id 'BR-n'. Referenced storyIds/requirementIds must exist (fails if unknown). requ-mcp does not create the branch — it only records the reference.",
|
|
1048
|
+
inputSchema: {
|
|
1049
|
+
branch: z.string().min(1).describe("Branch name."),
|
|
1050
|
+
component: z.string().optional().describe("Component id this branch relates to."),
|
|
1051
|
+
storyIds: z.array(z.string().regex(/^US-\d+$/)).optional().describe("User story ids (US-…) this branch implements."),
|
|
1052
|
+
requirementIds: z.array(z.string().regex(/^REQ-\d+$/)).optional().describe("Requirement ids (REQ-…) this branch relates to."),
|
|
1053
|
+
url: z.string().optional().describe("URL of the branch (optional)."),
|
|
1054
|
+
},
|
|
1055
|
+
}, async (args, store) => {
|
|
1056
|
+
await ensureInit(store);
|
|
1057
|
+
const storyIds = args.storyIds ?? [];
|
|
1058
|
+
const requirementIds = args.requirementIds ?? [];
|
|
1059
|
+
const bad = await validateVcsLinks(store, storyIds, requirementIds);
|
|
1060
|
+
if (bad)
|
|
1061
|
+
return bad;
|
|
1062
|
+
const existing = await store.listVcsRefs();
|
|
1063
|
+
const dup = existing.find((r) => r.kind === "branch" && r.ref === args.branch);
|
|
1064
|
+
const id = dup?.id ?? Store.nextId("BR", existing.map((r) => r.id));
|
|
1065
|
+
const ts = now();
|
|
1066
|
+
const ref = {
|
|
1067
|
+
id,
|
|
1068
|
+
kind: "branch",
|
|
1069
|
+
ref: args.branch,
|
|
1070
|
+
url: args.url ?? "",
|
|
1071
|
+
branch: args.branch,
|
|
1072
|
+
component: args.component,
|
|
1073
|
+
storyIds,
|
|
1074
|
+
requirementIds,
|
|
1075
|
+
state: "opened",
|
|
1076
|
+
createdAt: dup?.createdAt ?? ts,
|
|
1077
|
+
updatedAt: ts,
|
|
1078
|
+
};
|
|
1079
|
+
await store.writeVcsRef(ref);
|
|
1080
|
+
return json(ref);
|
|
1081
|
+
});
|
|
1082
|
+
tool("link_merge_request", {
|
|
1083
|
+
title: "Link a VCS merge request reference",
|
|
1084
|
+
description: "Record (or upsert) a reference to a merge request (kind='mr'), keyed by `ref` (the MR iid), id 'MR-<ref>'. Referenced storyIds/requirementIds must exist (fails if unknown). requ-mcp does not call GitLab — it only records the reference.",
|
|
1085
|
+
inputSchema: {
|
|
1086
|
+
ref: z.string().regex(/^\d+$/).describe("MR iid (numeric string)."),
|
|
1087
|
+
url: z.string().min(1).describe("MR URL."),
|
|
1088
|
+
branch: z.string().min(1).describe("Source branch of the MR."),
|
|
1089
|
+
storyIds: z.array(z.string().regex(/^US-\d+$/)).optional().describe("User story ids (US-…) this MR implements."),
|
|
1090
|
+
requirementIds: z.array(z.string().regex(/^REQ-\d+$/)).optional().describe("Requirement ids (REQ-…) this MR relates to."),
|
|
1091
|
+
targetBranch: z.string().optional().describe("Target branch of the MR."),
|
|
1092
|
+
state: VcsRefState.optional().describe("MR state. Defaults to 'opened'."),
|
|
1093
|
+
component: z.string().optional().describe("Component id this MR relates to."),
|
|
1094
|
+
},
|
|
1095
|
+
}, async (args, store) => {
|
|
1096
|
+
await ensureInit(store);
|
|
1097
|
+
const storyIds = args.storyIds ?? [];
|
|
1098
|
+
const requirementIds = args.requirementIds ?? [];
|
|
1099
|
+
const bad = await validateVcsLinks(store, storyIds, requirementIds);
|
|
1100
|
+
if (bad)
|
|
1101
|
+
return bad;
|
|
1102
|
+
const id = `MR-${args.ref}`;
|
|
1103
|
+
const existing = await store.getVcsRef(id);
|
|
1104
|
+
const ts = now();
|
|
1105
|
+
const ref = {
|
|
1106
|
+
id,
|
|
1107
|
+
kind: "mr",
|
|
1108
|
+
ref: args.ref,
|
|
1109
|
+
url: args.url,
|
|
1110
|
+
branch: args.branch,
|
|
1111
|
+
targetBranch: args.targetBranch,
|
|
1112
|
+
component: args.component,
|
|
1113
|
+
storyIds,
|
|
1114
|
+
requirementIds,
|
|
1115
|
+
state: args.state ?? "opened",
|
|
1116
|
+
mergeCommit: existing?.mergeCommit,
|
|
1117
|
+
createdAt: existing?.createdAt ?? ts,
|
|
1118
|
+
updatedAt: ts,
|
|
1119
|
+
};
|
|
1120
|
+
await store.writeVcsRef(ref);
|
|
1121
|
+
return json(ref);
|
|
1122
|
+
});
|
|
1123
|
+
tool("update_merge_request", {
|
|
1124
|
+
title: "Update a merge request reference state",
|
|
1125
|
+
description: "Update the state (and optional mergeCommit) of a recorded MR reference, found by its `ref` (MR iid). Bumps updatedAt. Fails if no MR reference with that ref exists.",
|
|
1126
|
+
inputSchema: {
|
|
1127
|
+
ref: z.string().regex(/^\d+$/).describe("MR iid (numeric string)."),
|
|
1128
|
+
state: VcsRefState.describe("New MR state."),
|
|
1129
|
+
mergeCommit: z.string().optional().describe("Merge commit SHA (when merged)."),
|
|
1130
|
+
},
|
|
1131
|
+
}, async (args, store) => {
|
|
1132
|
+
await ensureInit(store);
|
|
1133
|
+
const refs = await store.listVcsRefs();
|
|
1134
|
+
const target = refs.find((r) => r.kind === "mr" && r.ref === args.ref);
|
|
1135
|
+
if (!target)
|
|
1136
|
+
return fail(`No merge request reference found for ref '${args.ref}'.`);
|
|
1137
|
+
const patch = { state: args.state, updatedAt: now() };
|
|
1138
|
+
if (args.mergeCommit !== undefined)
|
|
1139
|
+
patch.mergeCommit = args.mergeCommit;
|
|
1140
|
+
const updated = await store.updateVcsRef(target.id, patch);
|
|
1141
|
+
if (!updated)
|
|
1142
|
+
return fail(`Merge request reference ${target.id} not found.`);
|
|
1143
|
+
return json(updated);
|
|
1144
|
+
});
|
|
1145
|
+
tool("list_vcs_refs", {
|
|
1146
|
+
title: "List VCS references",
|
|
1147
|
+
description: "List recorded VCS references (branches and MRs), optionally filtered by kind, component, state, or linked storyId.",
|
|
1148
|
+
inputSchema: {
|
|
1149
|
+
kind: VcsRefKind.optional(),
|
|
1150
|
+
component: z.string().optional(),
|
|
1151
|
+
state: z.string().optional(),
|
|
1152
|
+
storyId: z.string().optional(),
|
|
1153
|
+
},
|
|
1154
|
+
}, async (args, store) => {
|
|
1155
|
+
await ensureInit(store);
|
|
1156
|
+
let refs = await store.listVcsRefs();
|
|
1157
|
+
if (args.kind)
|
|
1158
|
+
refs = refs.filter((r) => r.kind === args.kind);
|
|
1159
|
+
if (args.component)
|
|
1160
|
+
refs = refs.filter((r) => r.component === args.component);
|
|
1161
|
+
if (args.state)
|
|
1162
|
+
refs = refs.filter((r) => r.state === args.state);
|
|
1163
|
+
if (args.storyId)
|
|
1164
|
+
refs = refs.filter((r) => r.storyIds.includes(args.storyId));
|
|
1165
|
+
return json(refs);
|
|
1166
|
+
});
|
|
1167
|
+
// ===========================================================================
|
|
1168
|
+
// Export / Import
|
|
1169
|
+
// ===========================================================================
|
|
1170
|
+
tool("export_project", {
|
|
1171
|
+
title: "Export project",
|
|
1172
|
+
description: "Export all project data (requirements, stories, phases, executions, components, VCS refs) as a JSON string. Pass the result to import_project on another instance to migrate or copy data.",
|
|
1173
|
+
inputSchema: {},
|
|
1174
|
+
}, async (_args, store) => {
|
|
1175
|
+
await ensureInit(store);
|
|
1176
|
+
const payload = await buildExport(store);
|
|
1177
|
+
return json(JSON.stringify(payload, null, 2));
|
|
1178
|
+
});
|
|
1179
|
+
tool("import_project", {
|
|
1180
|
+
title: "Import project",
|
|
1181
|
+
description: "Import project data from a JSON string produced by export_project. Existing records (same ID) are skipped and reported. Returns a summary of what was imported and what was skipped.",
|
|
1182
|
+
inputSchema: {
|
|
1183
|
+
data: z.string().describe("JSON string produced by export_project"),
|
|
1184
|
+
},
|
|
1185
|
+
}, async (args, store) => {
|
|
1186
|
+
await ensureInit(store);
|
|
1187
|
+
let payload;
|
|
1188
|
+
try {
|
|
1189
|
+
payload = JSON.parse(args.data);
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return fail("data is not valid JSON");
|
|
1193
|
+
}
|
|
1194
|
+
const parsed = ExportPayload.safeParse(payload);
|
|
1195
|
+
if (!parsed.success) {
|
|
1196
|
+
return fail(`Invalid export format: ${parsed.error.message}`);
|
|
1197
|
+
}
|
|
1198
|
+
const report = await applyImport(store, parsed.data);
|
|
1199
|
+
return json(report);
|
|
674
1200
|
});
|
|
675
1201
|
function renderMarkdown(report, phaseName) {
|
|
676
1202
|
const s = report.summary;
|
|
@@ -684,8 +1210,11 @@ function renderMarkdown(report, phaseName) {
|
|
|
684
1210
|
lines.push(`- Scenarios passing: **${s.scenariosPassing}/${s.scenariosLinked}**`, "");
|
|
685
1211
|
if (report.byComponent.length) {
|
|
686
1212
|
lines.push("## By component", "");
|
|
687
|
-
for (const c of report.byComponent)
|
|
688
|
-
|
|
1213
|
+
for (const c of report.byComponent) {
|
|
1214
|
+
const label = c.componentName && c.componentName !== c.component ? `${c.component} (${c.componentName})` : c.component;
|
|
1215
|
+
const tags = c.domainTags?.length ? ` [${c.domainTags.join(", ")}]` : "";
|
|
1216
|
+
lines.push(`- **${label}**${tags} — verified ${c.verified}/${c.requirements} (${c.verifiedPct}%)`);
|
|
1217
|
+
}
|
|
689
1218
|
lines.push("");
|
|
690
1219
|
}
|
|
691
1220
|
lines.push("## Requirements", "");
|
|
@@ -697,7 +1226,12 @@ function renderMarkdown(report, phaseName) {
|
|
|
697
1226
|
lines.push("", "## Stories", "");
|
|
698
1227
|
for (const st of report.stories) {
|
|
699
1228
|
const mark = st.covered ? "✅" : !st.tested ? "❌" : "🟡";
|
|
700
|
-
|
|
1229
|
+
const mr = st.mergedMr
|
|
1230
|
+
? st.mergedMr.state === "merged"
|
|
1231
|
+
? ` — verified + merged (MR ${st.mergedMr.ref})`
|
|
1232
|
+
: ` — MR ${st.mergedMr.ref} (${st.mergedMr.state})`
|
|
1233
|
+
: "";
|
|
1234
|
+
lines.push(`- ${mark} **${st.id}** ${st.title} — ${st.passing}/${st.scenarios.length} scenarios pass (${st.status})${mr}`);
|
|
701
1235
|
for (const sc of st.scenarios) {
|
|
702
1236
|
const cm = sc.status === "pass" ? "✓" : sc.status === "fail" ? "✗" : "·";
|
|
703
1237
|
lines.push(` - ${cm} ${sc.feature} :: ${sc.name} — ${sc.status}`);
|
|
@@ -708,12 +1242,83 @@ function renderMarkdown(report, phaseName) {
|
|
|
708
1242
|
return lines.join("\n");
|
|
709
1243
|
}
|
|
710
1244
|
// ===========================================================================
|
|
1245
|
+
// HTTP server (REQU_TRANSPORT=http)
|
|
1246
|
+
// ===========================================================================
|
|
1247
|
+
function readBody(req) {
|
|
1248
|
+
return new Promise((resolve, reject) => {
|
|
1249
|
+
const chunks = [];
|
|
1250
|
+
req.on("data", (c) => chunks.push(c));
|
|
1251
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
1252
|
+
req.on("error", reject);
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
async function startHttpServer() {
|
|
1256
|
+
const { createServer: createHttpServer } = await import("node:http");
|
|
1257
|
+
const { randomUUID } = await import("node:crypto");
|
|
1258
|
+
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
1259
|
+
const { handleWebRequest } = await import("./web-api.js");
|
|
1260
|
+
const port = parseInt(process.env.REQU_PORT ?? "8788", 10);
|
|
1261
|
+
const host = process.env.REQU_HOST ?? "0.0.0.0";
|
|
1262
|
+
const sessions = new Map();
|
|
1263
|
+
loadProjectsFromEnv();
|
|
1264
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
1265
|
+
// Web dashboard routes (REST API + static files)
|
|
1266
|
+
if (await handleWebRequest(req, res, _stores))
|
|
1267
|
+
return;
|
|
1268
|
+
if (!req.url?.includes("/mcp")) {
|
|
1269
|
+
res.writeHead(404).end("Not Found");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
try {
|
|
1273
|
+
const bodyStr = req.method === "POST" ? await readBody(req) : "{}";
|
|
1274
|
+
const body = bodyStr.trim() ? JSON.parse(bodyStr) : undefined;
|
|
1275
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
1276
|
+
let transport;
|
|
1277
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
1278
|
+
transport = sessions.get(sessionId);
|
|
1279
|
+
}
|
|
1280
|
+
else {
|
|
1281
|
+
transport = new StreamableHTTPServerTransport({
|
|
1282
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1283
|
+
onsessioninitialized: (id) => { sessions.set(id, transport); },
|
|
1284
|
+
});
|
|
1285
|
+
transport.onclose = () => {
|
|
1286
|
+
const sid = transport.sessionId;
|
|
1287
|
+
if (sid)
|
|
1288
|
+
sessions.delete(sid);
|
|
1289
|
+
};
|
|
1290
|
+
// Fresh McpServer per session — an McpServer cannot be connected to two transports.
|
|
1291
|
+
const sessionServer = createServer();
|
|
1292
|
+
await sessionServer.connect(transport);
|
|
1293
|
+
}
|
|
1294
|
+
await transport.handleRequest(req, res, body);
|
|
1295
|
+
}
|
|
1296
|
+
catch (err) {
|
|
1297
|
+
if (!res.headersSent)
|
|
1298
|
+
res.writeHead(500).end(String(err));
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
httpServer.listen(port, host, () => {
|
|
1302
|
+
const loadedCount = _stores.size;
|
|
1303
|
+
const dbInfo = loadedCount > 0
|
|
1304
|
+
? `projects=${loadedCount}`
|
|
1305
|
+
: `db=${process.env.REQU_ROOT ?? process.cwd()}/.requ/requ.db`;
|
|
1306
|
+
console.error(`requ-mcp HTTP → http://${host}:${port}/mcp ${dbInfo}`);
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
// ===========================================================================
|
|
711
1310
|
// Boot
|
|
712
1311
|
// ===========================================================================
|
|
713
1312
|
async function main() {
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
1313
|
+
if (process.env.REQU_TRANSPORT === "http") {
|
|
1314
|
+
await startHttpServer();
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
const server = createServer();
|
|
1318
|
+
const transport = new StdioServerTransport();
|
|
1319
|
+
await server.connect(transport);
|
|
1320
|
+
console.error("requ-mcp running (stdio, YAML storage, per-call project resolution).");
|
|
1321
|
+
}
|
|
717
1322
|
}
|
|
718
1323
|
main().catch((err) => {
|
|
719
1324
|
console.error("requ-mcp fatal:", err);
|