requ-mcp 0.1.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 +107 -25
- package/dist/conductor.d.ts +14 -0
- package/dist/conductor.js +60 -0
- package/dist/conductor.js.map +1 -1
- 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 +703 -60
- 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 +669 -27
- package/dist/schema.js +91 -28
- 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 {
|
|
10
|
-
import {
|
|
9
|
+
import { SqliteStore } from "./sqlite-store.js";
|
|
10
|
+
import { ComponentStatus, CoverageMode, ExportPayload, Priority, PhaseStatus, RequirementStatus, StoryStatus, TestStatus, testKey, VcsRefKind, VcsRefState, } from "./schema.js";
|
|
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();
|
|
115
|
+
}
|
|
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));
|
|
83
130
|
}
|
|
84
|
-
async function
|
|
85
|
-
|
|
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) };
|
|
@@ -115,21 +284,35 @@ async function loadConductorIndex(store) {
|
|
|
115
284
|
// ===========================================================================
|
|
116
285
|
tool("init_project", {
|
|
117
286
|
title: "Initialize requ project",
|
|
118
|
-
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. Idempotent.",
|
|
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()
|
|
124
295
|
.optional()
|
|
125
296
|
.describe("Default path to Conductor's cucumber-json result file, for import_execution_report."),
|
|
126
297
|
initialPhase: z.string().optional().describe("If set, create and activate a first phase with this name."),
|
|
298
|
+
force: z.boolean().optional().describe("Initialize even if the Conductor folder is missing or not a Conductor project."),
|
|
127
299
|
},
|
|
128
300
|
}, async (args, store) => {
|
|
129
301
|
const existing = (await store.isInitialized()) ? await store.readConfig() : null;
|
|
302
|
+
const conductorPath = args.conductorPath ?? existing?.conductorPath ?? ".";
|
|
303
|
+
const conductorAbs = store.resolvePath(conductorPath);
|
|
304
|
+
const conductor = await inspectConductorProject(conductorAbs);
|
|
305
|
+
if (!conductor.isConductorProject && !args.force) {
|
|
306
|
+
return fail(conductor.exists
|
|
307
|
+
? `'${conductorAbs}' exists but doesn't look like a Conductor project (no features/ directory or cucumber config). Pass force:true to init anyway, or set the correct conductorPath.`
|
|
308
|
+
: `Conductor folder not found at '${conductorAbs}'. Set conductorPath to your Conductor project root (the folder containing features/), or pass force:true.`, { conductor });
|
|
309
|
+
}
|
|
130
310
|
const config = {
|
|
131
311
|
name: args.name ?? existing?.name ?? path.basename(store.root),
|
|
132
|
-
|
|
312
|
+
key: args.key ?? existing?.key,
|
|
313
|
+
brief: args.brief ?? existing?.brief,
|
|
314
|
+
conductorPath,
|
|
315
|
+
conductorName: conductor.isConductorProject ? conductor.name : existing?.conductorName,
|
|
133
316
|
conductorReportPath: args.conductorReportPath ?? existing?.conductorReportPath,
|
|
134
317
|
activePhase: existing?.activePhase,
|
|
135
318
|
};
|
|
@@ -137,7 +320,7 @@ tool("init_project", {
|
|
|
137
320
|
let phase;
|
|
138
321
|
if (args.initialPhase) {
|
|
139
322
|
phase = {
|
|
140
|
-
id: "
|
|
323
|
+
id: "P1",
|
|
141
324
|
name: args.initialPhase,
|
|
142
325
|
order: 1,
|
|
143
326
|
status: "active",
|
|
@@ -148,25 +331,147 @@ tool("init_project", {
|
|
|
148
331
|
await store.writePhase(phase);
|
|
149
332
|
await store.writeConfig({ ...config, activePhase: phase.id });
|
|
150
333
|
}
|
|
151
|
-
return json({
|
|
334
|
+
return json({
|
|
335
|
+
initialized: true,
|
|
336
|
+
root: store.root,
|
|
337
|
+
config: await store.readConfig(),
|
|
338
|
+
conductor: {
|
|
339
|
+
path: conductor.path,
|
|
340
|
+
name: conductor.name,
|
|
341
|
+
isConductorProject: conductor.isConductorProject,
|
|
342
|
+
featureFiles: conductor.featureFiles,
|
|
343
|
+
cucumberConfig: conductor.cucumberConfig,
|
|
344
|
+
},
|
|
345
|
+
phase,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
tool("check_conductor", {
|
|
349
|
+
title: "Check the Conductor project",
|
|
350
|
+
description: "Inspect the Conductor folder without modifying anything: whether it exists, looks like a Conductor project, its detected name, cucumber config, and number of .feature files. Pass conductorPath to check a candidate before init; otherwise uses the configured path.",
|
|
351
|
+
inputSchema: {
|
|
352
|
+
conductorPath: z.string().optional().describe("Candidate Conductor project root. Defaults to the configured conductorPath."),
|
|
353
|
+
},
|
|
354
|
+
}, async (args, store) => {
|
|
355
|
+
const candidate = args.conductorPath !== undefined
|
|
356
|
+
? store.resolvePath(args.conductorPath)
|
|
357
|
+
: (await store.isInitialized())
|
|
358
|
+
? await store.conductorRoot()
|
|
359
|
+
: store.root;
|
|
360
|
+
return json(await inspectConductorProject(candidate));
|
|
361
|
+
});
|
|
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);
|
|
152
440
|
});
|
|
153
441
|
// ===========================================================================
|
|
154
442
|
// Requirements
|
|
155
443
|
// ===========================================================================
|
|
156
444
|
tool("create_requirement", {
|
|
157
445
|
title: "Create requirement",
|
|
158
|
-
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.",
|
|
159
447
|
inputSchema: {
|
|
160
448
|
title: z.string(),
|
|
161
449
|
description: z.string().optional(),
|
|
162
450
|
source: z.string().optional().describe("Provenance: doc, spec section, ticket id."),
|
|
163
451
|
priority: Priority.optional(),
|
|
164
|
-
components: z.array(z.string()).optional().describe("
|
|
452
|
+
components: z.array(z.string()).optional().describe("Component IDs this requirement belongs to (matches Component.id)."),
|
|
165
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."),
|
|
166
455
|
id: z.string().regex(/^REQ-\d+$/).optional(),
|
|
167
456
|
},
|
|
168
457
|
}, async (args, store) => {
|
|
169
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
|
+
}
|
|
170
475
|
const existing = await store.listRequirements();
|
|
171
476
|
const id = args.id ?? Store.nextId("REQ", existing.map((r) => r.id));
|
|
172
477
|
if (existing.some((r) => r.id === id))
|
|
@@ -180,6 +485,7 @@ tool("create_requirement", {
|
|
|
180
485
|
components: args.components ?? [],
|
|
181
486
|
tags: args.tags ?? [],
|
|
182
487
|
status: "active",
|
|
488
|
+
...(phase.value ? { phase: phase.value } : {}),
|
|
183
489
|
createdAt: now(),
|
|
184
490
|
updatedAt: now(),
|
|
185
491
|
};
|
|
@@ -193,6 +499,7 @@ tool("list_requirements", {
|
|
|
193
499
|
status: RequirementStatus.optional(),
|
|
194
500
|
component: z.string().optional(),
|
|
195
501
|
tag: z.string().optional(),
|
|
502
|
+
phase: z.string().optional().describe("Filter by assigned target phase (exact match)."),
|
|
196
503
|
},
|
|
197
504
|
}, async (args, store) => {
|
|
198
505
|
await ensureInit(store);
|
|
@@ -203,6 +510,8 @@ tool("list_requirements", {
|
|
|
203
510
|
reqs = reqs.filter((r) => r.components.includes(args.component));
|
|
204
511
|
if (args.tag)
|
|
205
512
|
reqs = reqs.filter((r) => r.tags.includes(args.tag));
|
|
513
|
+
if (args.phase)
|
|
514
|
+
reqs = reqs.filter((r) => r.phase === args.phase);
|
|
206
515
|
return json(reqs);
|
|
207
516
|
});
|
|
208
517
|
tool("get_requirement", {
|
|
@@ -228,6 +537,7 @@ tool("update_requirement", {
|
|
|
228
537
|
priority: Priority.optional(),
|
|
229
538
|
components: z.array(z.string()).optional(),
|
|
230
539
|
tags: z.array(z.string()).optional(),
|
|
540
|
+
phase: z.string().optional().describe("Target phase (e.g. 'P1'). Pass '' to clear the assignment."),
|
|
231
541
|
status: RequirementStatus.optional(),
|
|
232
542
|
},
|
|
233
543
|
}, async (args, store) => {
|
|
@@ -235,6 +545,16 @@ tool("update_requirement", {
|
|
|
235
545
|
const req = await store.getRequirement(args.id);
|
|
236
546
|
if (!req)
|
|
237
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
|
+
}
|
|
238
558
|
for (const k of ["title", "description", "source", "priority", "components", "tags", "status"]) {
|
|
239
559
|
if (args[k] !== undefined)
|
|
240
560
|
req[k] = args[k];
|
|
@@ -251,9 +571,10 @@ tool("create_user_story", {
|
|
|
251
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.",
|
|
252
572
|
inputSchema: {
|
|
253
573
|
title: z.string(),
|
|
254
|
-
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."),
|
|
255
575
|
description: z.string().optional(),
|
|
256
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."),
|
|
257
578
|
id: z.string().regex(/^US-\d+$/).optional(),
|
|
258
579
|
},
|
|
259
580
|
}, async (args, store) => {
|
|
@@ -264,6 +585,9 @@ tool("create_user_story", {
|
|
|
264
585
|
missing.push(reqId);
|
|
265
586
|
if (missing.length)
|
|
266
587
|
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
588
|
+
const phase = await resolveAssignedPhase(store, args.phase);
|
|
589
|
+
if (phase.error)
|
|
590
|
+
return phase.error;
|
|
267
591
|
const existing = await store.listStories();
|
|
268
592
|
const id = args.id ?? Store.nextId("US", existing.map((s) => s.id));
|
|
269
593
|
if (existing.some((s) => s.id === id))
|
|
@@ -279,6 +603,7 @@ tool("create_user_story", {
|
|
|
279
603
|
requirements: args.requirements,
|
|
280
604
|
acceptanceCriteria,
|
|
281
605
|
status: "draft",
|
|
606
|
+
...(phase.value ? { phase: phase.value } : {}),
|
|
282
607
|
createdAt: now(),
|
|
283
608
|
updatedAt: now(),
|
|
284
609
|
};
|
|
@@ -288,7 +613,11 @@ tool("create_user_story", {
|
|
|
288
613
|
tool("list_user_stories", {
|
|
289
614
|
title: "List user stories",
|
|
290
615
|
description: "List user stories, optionally filtered by status or linked requirement.",
|
|
291
|
-
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
|
+
},
|
|
292
621
|
}, async (args, store) => {
|
|
293
622
|
await ensureInit(store);
|
|
294
623
|
let stories = await store.listStories();
|
|
@@ -296,6 +625,8 @@ tool("list_user_stories", {
|
|
|
296
625
|
stories = stories.filter((s) => s.status === args.status);
|
|
297
626
|
if (args.requirement)
|
|
298
627
|
stories = stories.filter((s) => s.requirements.includes(args.requirement));
|
|
628
|
+
if (args.phase)
|
|
629
|
+
stories = stories.filter((s) => s.phase === args.phase);
|
|
299
630
|
return json(stories);
|
|
300
631
|
});
|
|
301
632
|
tool("get_user_story", {
|
|
@@ -314,7 +645,7 @@ tool("get_user_story", {
|
|
|
314
645
|
const status = resolveStatuses(execByPhase, phases, phaseId, "cumulative");
|
|
315
646
|
return json({
|
|
316
647
|
...story,
|
|
317
|
-
|
|
648
|
+
statusPhase: phaseId,
|
|
318
649
|
linkedScenarios: scs.map((sc) => ({
|
|
319
650
|
feature: sc.feature,
|
|
320
651
|
name: sc.name,
|
|
@@ -331,6 +662,7 @@ tool("update_user_story", {
|
|
|
331
662
|
description: z.string().optional(),
|
|
332
663
|
status: StoryStatus.optional(),
|
|
333
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."),
|
|
334
666
|
},
|
|
335
667
|
}, async (args, store) => {
|
|
336
668
|
await ensureInit(store);
|
|
@@ -346,6 +678,16 @@ tool("update_user_story", {
|
|
|
346
678
|
return fail(`Unknown requirement(s): ${missing.join(", ")}`);
|
|
347
679
|
story.requirements = args.requirements;
|
|
348
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
|
+
}
|
|
349
691
|
if (args.title !== undefined)
|
|
350
692
|
story.title = args.title;
|
|
351
693
|
if (args.description !== undefined)
|
|
@@ -380,20 +722,22 @@ tool("add_acceptance_criterion", {
|
|
|
380
722
|
// ===========================================================================
|
|
381
723
|
tool("create_phase", {
|
|
382
724
|
title: "Create phase/release",
|
|
383
|
-
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.",
|
|
384
726
|
inputSchema: {
|
|
385
|
-
|
|
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'."),
|
|
386
729
|
order: z.number().int().optional().describe("Sort key; defaults to max+1."),
|
|
387
730
|
description: z.string().optional(),
|
|
388
|
-
activate: z.boolean().optional(),
|
|
731
|
+
activate: z.boolean().optional().describe("Make this the active phase."),
|
|
389
732
|
},
|
|
390
733
|
}, async (args, store) => {
|
|
391
734
|
await ensureInit(store);
|
|
392
735
|
const existing = await store.listPhases();
|
|
393
|
-
|
|
736
|
+
if (existing.some((p) => p.id === args.id))
|
|
737
|
+
return fail(`Phase ${args.id} already exists.`);
|
|
394
738
|
const order = args.order ?? (existing.reduce((m, p) => Math.max(m, p.order), 0) + 1);
|
|
395
739
|
const phase = {
|
|
396
|
-
id,
|
|
740
|
+
id: args.id,
|
|
397
741
|
name: args.name,
|
|
398
742
|
order,
|
|
399
743
|
status: args.activate ? "active" : "planned",
|
|
@@ -404,7 +748,7 @@ tool("create_phase", {
|
|
|
404
748
|
await store.writePhase(phase);
|
|
405
749
|
if (args.activate || existing.length === 0) {
|
|
406
750
|
const cfg = await store.readConfig();
|
|
407
|
-
await store.writeConfig({ ...cfg, activePhase: id });
|
|
751
|
+
await store.writeConfig({ ...cfg, activePhase: args.id });
|
|
408
752
|
}
|
|
409
753
|
return json(phase);
|
|
410
754
|
});
|
|
@@ -417,7 +761,7 @@ tool("update_phase", {
|
|
|
417
761
|
title: "Update phase",
|
|
418
762
|
description: "Update a phase's name, order, status, or description.",
|
|
419
763
|
inputSchema: {
|
|
420
|
-
id: z.string().
|
|
764
|
+
id: z.string().min(1),
|
|
421
765
|
name: z.string().optional(),
|
|
422
766
|
order: z.number().int().optional(),
|
|
423
767
|
status: PhaseStatus.optional(),
|
|
@@ -439,7 +783,7 @@ tool("update_phase", {
|
|
|
439
783
|
tool("set_active_phase", {
|
|
440
784
|
title: "Set active phase",
|
|
441
785
|
description: "Set which phase new executions are recorded against by default.",
|
|
442
|
-
inputSchema: { id: z.string().
|
|
786
|
+
inputSchema: { id: z.string().min(1) },
|
|
443
787
|
}, async (args, store) => {
|
|
444
788
|
await ensureInit(store);
|
|
445
789
|
if (!(await store.getPhase(args.id)))
|
|
@@ -449,7 +793,7 @@ tool("set_active_phase", {
|
|
|
449
793
|
return json({ activePhase: args.id });
|
|
450
794
|
});
|
|
451
795
|
// ===========================================================================
|
|
452
|
-
// Links (derived from @US-xxx scenario tags)
|
|
796
|
+
// Links (derived from @US-xxx scenario tags)
|
|
453
797
|
// ===========================================================================
|
|
454
798
|
tool("list_links", {
|
|
455
799
|
title: "List tag-derived links",
|
|
@@ -478,12 +822,12 @@ tool("list_links", {
|
|
|
478
822
|
// ===========================================================================
|
|
479
823
|
tool("record_execution", {
|
|
480
824
|
title: "Record a test execution",
|
|
481
|
-
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.",
|
|
482
826
|
inputSchema: {
|
|
483
827
|
feature: z.string().describe("Feature name (the `Feature:` line)."),
|
|
484
828
|
name: z.string().describe("Scenario name (the `Scenario:` line)."),
|
|
485
829
|
status: TestStatus,
|
|
486
|
-
phase: z.string().
|
|
830
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to the active phase."),
|
|
487
831
|
runId: z.string().optional(),
|
|
488
832
|
note: z.string().optional(),
|
|
489
833
|
},
|
|
@@ -512,10 +856,10 @@ tool("record_execution", {
|
|
|
512
856
|
});
|
|
513
857
|
tool("import_execution_report", {
|
|
514
858
|
title: "Import Conductor cucumber-json report",
|
|
515
|
-
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.",
|
|
516
860
|
inputSchema: {
|
|
517
861
|
filePath: z.string().optional().describe("Path to the cucumber-json file. Defaults to config.conductorReportPath."),
|
|
518
|
-
phase: z.string().
|
|
862
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
519
863
|
runId: z.string().optional().describe("Run identifier stamped on every imported execution."),
|
|
520
864
|
},
|
|
521
865
|
}, async (args, store) => {
|
|
@@ -574,40 +918,50 @@ tool("import_execution_report", {
|
|
|
574
918
|
});
|
|
575
919
|
});
|
|
576
920
|
// ===========================================================================
|
|
577
|
-
// Reporting
|
|
921
|
+
// Reporting
|
|
578
922
|
// ===========================================================================
|
|
579
923
|
async function resolveForReport(store, phase, mode) {
|
|
580
|
-
const [reqs, stories, phases, execByPhase, { index }] = await Promise.all([
|
|
924
|
+
const [reqs, stories, phases, execByPhase, { index }, vcsRefs] = await Promise.all([
|
|
581
925
|
store.listRequirements(),
|
|
582
926
|
store.listStories(),
|
|
583
927
|
store.listPhases(),
|
|
584
928
|
store.readAllExecutions(),
|
|
585
929
|
loadConductorIndex(store),
|
|
930
|
+
store.listVcsRefs(),
|
|
586
931
|
]);
|
|
587
932
|
const phaseId = await store.resolvePhaseId(phase);
|
|
588
933
|
const m = mode ?? "cumulative";
|
|
589
934
|
const status = resolveStatuses(execByPhase, phases, phaseId, m);
|
|
590
935
|
const byStory = scenariosByStory(index);
|
|
591
|
-
return { reqs, stories, phases, byStory, status, phaseId, mode: m };
|
|
936
|
+
return { reqs, stories, phases, byStory, status, phaseId, mode: m, vcsRefs };
|
|
592
937
|
}
|
|
593
938
|
tool("coverage_report", {
|
|
594
939
|
title: "Coverage report",
|
|
595
|
-
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.",
|
|
596
941
|
inputSchema: {
|
|
597
|
-
phase: z.string().
|
|
942
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
598
943
|
mode: CoverageMode.optional(),
|
|
599
944
|
format: z.enum(["json", "markdown"]).optional(),
|
|
600
945
|
},
|
|
601
946
|
}, async (args, store) => {
|
|
602
947
|
await ensureInit(store);
|
|
603
|
-
const { reqs, stories, byStory, status, phaseId, mode } = await resolveForReport(store, args.phase, args.mode);
|
|
604
|
-
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 };
|
|
605
959
|
if (args.format === "markdown") {
|
|
606
960
|
const phases = await store.listPhases();
|
|
607
961
|
const name = phases.find((p) => p.id === phaseId)?.name ?? "(none)";
|
|
608
|
-
return text(renderMarkdown(
|
|
962
|
+
return text(renderMarkdown(enrichedReport, name));
|
|
609
963
|
}
|
|
610
|
-
return json(
|
|
964
|
+
return json(enrichedReport);
|
|
611
965
|
});
|
|
612
966
|
tool("coverage_trend", {
|
|
613
967
|
title: "Coverage evolution by phase",
|
|
@@ -628,11 +982,221 @@ tool("coverage_trend", {
|
|
|
628
982
|
tool("find_gaps", {
|
|
629
983
|
title: "Find coverage gaps",
|
|
630
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.",
|
|
631
|
-
inputSchema: {
|
|
985
|
+
inputSchema: {
|
|
986
|
+
phase: z.string().optional().describe("Phase ID (e.g. 'P1'). Defaults to active phase."),
|
|
987
|
+
mode: CoverageMode.optional(),
|
|
988
|
+
},
|
|
989
|
+
}, async (args, store) => {
|
|
990
|
+
await ensureInit(store);
|
|
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
|
+
},
|
|
632
1131
|
}, async (args, store) => {
|
|
633
1132
|
await ensureInit(store);
|
|
634
|
-
const
|
|
635
|
-
|
|
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);
|
|
636
1200
|
});
|
|
637
1201
|
function renderMarkdown(report, phaseName) {
|
|
638
1202
|
const s = report.summary;
|
|
@@ -646,8 +1210,11 @@ function renderMarkdown(report, phaseName) {
|
|
|
646
1210
|
lines.push(`- Scenarios passing: **${s.scenariosPassing}/${s.scenariosLinked}**`, "");
|
|
647
1211
|
if (report.byComponent.length) {
|
|
648
1212
|
lines.push("## By component", "");
|
|
649
|
-
for (const c of report.byComponent)
|
|
650
|
-
|
|
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
|
+
}
|
|
651
1218
|
lines.push("");
|
|
652
1219
|
}
|
|
653
1220
|
lines.push("## Requirements", "");
|
|
@@ -659,7 +1226,12 @@ function renderMarkdown(report, phaseName) {
|
|
|
659
1226
|
lines.push("", "## Stories", "");
|
|
660
1227
|
for (const st of report.stories) {
|
|
661
1228
|
const mark = st.covered ? "✅" : !st.tested ? "❌" : "🟡";
|
|
662
|
-
|
|
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}`);
|
|
663
1235
|
for (const sc of st.scenarios) {
|
|
664
1236
|
const cm = sc.status === "pass" ? "✓" : sc.status === "fail" ? "✗" : "·";
|
|
665
1237
|
lines.push(` - ${cm} ${sc.feature} :: ${sc.name} — ${sc.status}`);
|
|
@@ -670,12 +1242,83 @@ function renderMarkdown(report, phaseName) {
|
|
|
670
1242
|
return lines.join("\n");
|
|
671
1243
|
}
|
|
672
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
|
+
// ===========================================================================
|
|
673
1310
|
// Boot
|
|
674
1311
|
// ===========================================================================
|
|
675
1312
|
async function main() {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
+
}
|
|
679
1322
|
}
|
|
680
1323
|
main().catch((err) => {
|
|
681
1324
|
console.error("requ-mcp fatal:", err);
|