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/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 { CoverageMode, Priority, PhaseStatus, RequirementStatus, StoryStatus, TestStatus, testKey, } from "./schema.js";
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
- const server = new McpServer({ name: "requ-mcp", version: "0.2.0" });
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 = []; // client doesn't support roots
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; // initialized workspace wins
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(); // not yet initialized
114
+ return roots[0] ?? process.cwd();
83
115
  }
84
- async function getStore(explicit) {
85
- return new Store(await resolveRoot(explicit));
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
- /** Register a tool that auto-injects `projectPath` and resolves the Store. */
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
- const inputSchema = { ...(config.inputSchema ?? {}), projectPath: projectPathSchema };
94
- server.registerTool(name, { title: config.title, description: config.description, inputSchema }, async (args) => {
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
- const store = await getStore(args.projectPath);
97
- return (await handler(args, store));
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: "PHASE-001",
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("Sub-systems/modules this requirement belongs to."),
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: { status: StoryStatus.optional(), requirement: z.string().regex(/^REQ-\d+$/).optional() },
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
- phase: phaseId,
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) to capture coverage at a point in time. Order auto-increments. Optionally make it the active phase.",
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
- name: z.string().describe("e.g. 'v1.0', 'Sprint 3'."),
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
- const id = Store.nextId("PHASE", existing.map((p) => p.id));
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().regex(/^PHASE-\d+$/),
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().regex(/^PHASE-\d+$/) },
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) — discovery & validation
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 corrections; use import_execution_report for whole cucumber runs.",
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().regex(/^PHASE-\d+$/).optional().describe("Defaults to the active phase."),
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().regex(/^PHASE-\d+$/).optional(),
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 (phase- and mode-aware, story-level)
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().regex(/^PHASE-\d+$/).optional(),
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(report, name));
962
+ return text(renderMarkdown(enrichedReport, name));
647
963
  }
648
- return json(report);
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: { phase: z.string().regex(/^PHASE-\d+$/).optional(), mode: CoverageMode.optional() },
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
- lines.push(`- **${c.component}** verified ${c.verified}/${c.requirements} (${c.verifiedPct}%)`);
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
- lines.push(`- ${mark} **${st.id}** ${st.title} — ${st.passing}/${st.scenarios.length} scenarios pass (${st.status})`);
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
- const transport = new StdioServerTransport();
715
- await server.connect(transport);
716
- console.error("requ-mcp running (per-call project resolution).");
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);