docguard-cli 0.30.1 → 0.32.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.
@@ -15,17 +15,38 @@
15
15
  * JSON mode emits a structured `{ changedFiles, affectedDocs }` payload
16
16
  * for CI integrations and PR-comment bots.
17
17
  *
18
+ * v0.31.0 — blast radius (feat 1):
19
+ * - Agent-instruction files (AGENTS.md/CLAUDE.md/GEMINI.md) are indexed
20
+ * alongside canonical docs, so a changed code file they reference is
21
+ * surfaced too (agent instructions drift when the code they describe moves).
22
+ * - Doc→doc graph: when a DOC changes, the docs that reference it — INCLUDING
23
+ * agent-instruction files — are flagged as suspect ("blast radius"). This is
24
+ * the unclaimed slice: a change in ARCHITECTURE.md marks the AGENTS.md that
25
+ * points at it for review.
26
+ *
18
27
  * @req SC-S11-001 — impact reports per-file → doc mappings
19
28
  * @req SC-S11-002 — files with no doc references are listed as "no impact"
20
29
  * @req SC-S11-003 — --format json emits parseable structured output
21
30
  * @req SC-S11-004 — non-code files (.md, .json, etc.) are skipped from impact analysis
31
+ * @req SC-S11-007 — agent-instruction files participate in impact analysis
32
+ * @req SC-S11-008 — a changed doc flags the docs that reference it (blast radius)
33
+ *
34
+ * Indirect impact (import-graph BFS):
35
+ * A changed file with no doc references can still invalidate docs about the
36
+ * modules that IMPORT it (change shared-git.mjs → the doc describing
37
+ * `impact` is suspect). We walk the reverse import graph up to 2 hops —
38
+ * beyond that, hub modules connect everything and the signal drowns.
39
+ * JS/TS only (the graph builder's scope); `--no-indirect` disables.
40
+ * @req SC-S11-009 — docs referencing an importer of a changed file are flagged as indirect
22
41
  */
23
42
 
24
43
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
25
44
  import { resolve, basename } from 'node:path';
45
+ import { execFileSync } from 'node:child_process';
26
46
 
27
47
  import { c } from '../shared.mjs';
28
48
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
49
+ import { buildImportGraph } from '../validators/architecture.mjs';
29
50
 
30
51
  /**
31
52
  * File extensions we consider "code" for the purposes of impact analysis.
@@ -33,10 +54,32 @@ import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
33
54
  */
34
55
  const CODE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|cs|swift)$/;
35
56
 
57
+ // Root agent-instruction files — documentation that names code and other docs.
58
+ const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
59
+
36
60
  function escapeRegex(s) {
37
61
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
62
  }
39
63
 
64
+ /**
65
+ * Doc→doc references: which indexed docs reference `changedDocPath` — by its
66
+ * basename (the form used in prose "see ARCHITECTURE.md" and markdown links
67
+ * `](ARCHITECTURE.md)`) or by an extension-less Obsidian wikilink
68
+ * (`[[ARCHITECTURE]]`, `[[ARCHITECTURE#Heading]]`, `[[ARCHITECTURE|alias]]`).
69
+ * Skips self. This is the blast-radius edge set.
70
+ */
71
+ function docsReferencing(changedDocPath, index) {
72
+ const cbase = basename(changedDocPath);
73
+ const stem = cbase.replace(/\.md$/i, '');
74
+ const wikiRe = new RegExp(`\\[\\[${escapeRegex(stem)}(?:[#|\\]])`);
75
+ const dependents = [];
76
+ for (const [docName, lines] of index) {
77
+ if (docName === cbase || docName === changedDocPath) continue; // not self
78
+ if (lines.some(l => l.includes(cbase) || wikiRe.test(l))) dependents.push(docName);
79
+ }
80
+ return dependents;
81
+ }
82
+
40
83
  /**
41
84
  * Find canonical doc references for a single file. Reuses the same three
42
85
  * match strategies as trace --reverse for consistency: direct path,
@@ -63,7 +106,135 @@ function findReferences(file, docs) {
63
106
  return refs;
64
107
  }
65
108
 
66
- export function runImpact(projectDir, _config, flags) {
109
+ /**
110
+ * Ancestors of `file` in the reverse import graph, capped at `maxHops`.
111
+ * Returns Map<ancestorPath, {hops, via}> where `via` is the first hop on the
112
+ * path back toward the changed file (for explainable output).
113
+ */
114
+ function reverseImportAncestors(file, reverseEdges, maxHops = 2) {
115
+ const seen = new Map(); // ancestor → hops from the changed file
116
+ let frontier = [file];
117
+ for (let hop = 1; hop <= maxHops && frontier.length > 0; hop++) {
118
+ const next = [];
119
+ for (const cur of frontier) {
120
+ for (const importer of reverseEdges.get(cur) || []) {
121
+ if (importer === file || seen.has(importer)) continue;
122
+ seen.set(importer, hop);
123
+ next.push(importer);
124
+ }
125
+ }
126
+ frontier = next;
127
+ }
128
+ return seen;
129
+ }
130
+
131
+ // ── PR doc-conflict analysis (`impact --prs`) ───────────────────────────────
132
+ //
133
+ // Two open PRs whose changed files impact the SAME canonical doc are a
134
+ // merge-order risk: whichever lands second must re-verify (and often re-edit)
135
+ // a doc the first one already changed the ground truth for. The graph-
136
+ // community version of this idea ships in graphify's `prs --conflicts`; this
137
+ // is the doc-integrity equivalent, computed from DocGuard's own code→doc
138
+ // reference index. Pure function — the `gh` plumbing stays at the edge.
139
+
140
+ /**
141
+ * @param {Array<{number:number,title:string,files:string[]}>} prs
142
+ * @param {Map<string,string[]>} docsIndex docName → lines[]
143
+ * @returns {{prImpacts: Array, conflicts: Array}}
144
+ */
145
+ export function computeDocConflicts(prs, docsIndex) {
146
+ const prImpacts = prs.map(pr => {
147
+ const docs = new Set();
148
+ for (const f of pr.files) {
149
+ if (!CODE_EXTENSIONS.test(f)) {
150
+ // A PR that edits a canonical doc directly impacts that doc too.
151
+ if (f.endsWith('.md') && docsIndex.has(basename(f))) docs.add(basename(f));
152
+ continue;
153
+ }
154
+ for (const r of findReferences(f, docsIndex)) docs.add(r.doc);
155
+ }
156
+ return { number: pr.number, title: pr.title, docs: [...docs].sort() };
157
+ });
158
+
159
+ const conflicts = [];
160
+ for (let i = 0; i < prImpacts.length; i++) {
161
+ for (let j = i + 1; j < prImpacts.length; j++) {
162
+ const shared = prImpacts[i].docs.filter(d => prImpacts[j].docs.includes(d));
163
+ if (shared.length > 0) {
164
+ conflicts.push({ prs: [prImpacts[i].number, prImpacts[j].number], docs: shared });
165
+ }
166
+ }
167
+ }
168
+ return { prImpacts, conflicts };
169
+ }
170
+
171
+ const MAX_PRS = 20; // keep the per-PR file fetches bounded
172
+
173
+ /** Fetch open PRs + their changed files via the gh CLI. Throws with a human message. */
174
+ function fetchOpenPrs(projectDir) {
175
+ const gh = (args) => execFileSync('gh', args, {
176
+ cwd: projectDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
177
+ });
178
+ let list;
179
+ try {
180
+ list = JSON.parse(gh(['pr', 'list', '--state', 'open', '--json', 'number,title', '--limit', String(MAX_PRS)]));
181
+ } catch (err) {
182
+ const msg = String(err?.message || err);
183
+ if (/ENOENT/.test(msg)) throw new Error('the GitHub CLI (gh) is not installed — install it or run impact without --prs');
184
+ throw new Error(`gh pr list failed — is this repo on GitHub and gh authenticated? (${msg.split('\n')[0]})`);
185
+ }
186
+ return list.map(pr => {
187
+ let files = [];
188
+ try {
189
+ files = JSON.parse(gh(['pr', 'view', String(pr.number), '--json', 'files']))
190
+ .files.map(f => f.path);
191
+ } catch { /* PR vanished mid-scan — treat as no files */ }
192
+ return { number: pr.number, title: pr.title, files };
193
+ });
194
+ }
195
+
196
+ function runPrConflicts(projectDir, flags, docsIndex) {
197
+ const isJson = flags.format === 'json';
198
+ let prs;
199
+ try {
200
+ prs = fetchOpenPrs(projectDir);
201
+ } catch (err) {
202
+ if (isJson) {
203
+ console.log(JSON.stringify({ error: err.message, prs: [], conflicts: [] }, null, 2));
204
+ } else {
205
+ console.log(` ${c.yellow}⚠ ${err.message}${c.reset}`);
206
+ }
207
+ return;
208
+ }
209
+
210
+ const { prImpacts, conflicts } = computeDocConflicts(prs, docsIndex);
211
+
212
+ if (isJson) {
213
+ console.log(JSON.stringify({ prs: prImpacts, conflicts, timestamp: new Date().toISOString() }, null, 2));
214
+ return;
215
+ }
216
+
217
+ console.log(`${c.bold}📊 DocGuard Impact — open-PR doc conflicts${c.reset}\n`);
218
+ if (prImpacts.length === 0) {
219
+ console.log(` ${c.green}✅ No open PRs.${c.reset}`);
220
+ return;
221
+ }
222
+ for (const pr of prImpacts) {
223
+ const docsNote = pr.docs.length > 0 ? pr.docs.join(', ') : `${c.dim}no canonical-doc impact${c.reset}`;
224
+ console.log(` ${c.cyan}#${pr.number}${c.reset} ${pr.title.slice(0, 60)} ${c.dim}→${c.reset} ${docsNote}`);
225
+ }
226
+ if (conflicts.length === 0) {
227
+ console.log(`\n ${c.green}✅ No two open PRs impact the same canonical doc.${c.reset}`);
228
+ return;
229
+ }
230
+ console.log(`\n ${c.yellow}⚠ ${conflicts.length} doc-conflict pair(s) — merge order matters:${c.reset}`);
231
+ for (const cf of conflicts) {
232
+ console.log(` ${c.yellow}#${cf.prs[0]} × #${cf.prs[1]}${c.reset} both impact ${c.cyan}${cf.docs.join(', ')}${c.reset}`);
233
+ }
234
+ console.log(` ${c.dim}Whichever lands second should re-run docguard impact before updating the shared doc(s).${c.reset}`);
235
+ }
236
+
237
+ export function runImpact(projectDir, config, flags) {
67
238
  const isJson = flags.format === 'json';
68
239
  const since = flags.since || 'HEAD~1';
69
240
 
@@ -81,9 +252,11 @@ export function runImpact(projectDir, _config, flags) {
81
252
  // impact" in the same sense; they ARE the docs (or config).
82
253
  const codeChanged = changed.filter(f => CODE_EXTENSIONS.test(f));
83
254
 
84
- // Index canonical docs once
255
+ // Index canonical docs once, PLUS root agent-instruction files (they name
256
+ // code and other docs, so they belong in both code→doc and doc→doc analysis).
85
257
  const docsDir = resolve(projectDir, 'docs-canonical');
86
258
  const docsIndex = new Map(); // docName → lines[]
259
+ const agentDocs = new Set(); // which indexed docs are agent-instruction files
87
260
  if (existsSync(docsDir)) {
88
261
  try {
89
262
  for (const f of readdirSync(docsDir)) {
@@ -95,6 +268,17 @@ export function runImpact(projectDir, _config, flags) {
95
268
  }
96
269
  } catch { /* skip if dir unreadable */ }
97
270
  }
271
+ for (const a of AGENT_FILES) {
272
+ const p = resolve(projectDir, a);
273
+ if (!existsSync(p)) continue;
274
+ try { docsIndex.set(a, readFileSync(p, 'utf-8').split('\n')); agentDocs.add(a); } catch { /* skip */ }
275
+ }
276
+
277
+ // `impact --prs`: cross-PR doc-conflict analysis instead of a --since diff.
278
+ if (flags.prs) {
279
+ runPrConflicts(projectDir, flags, docsIndex);
280
+ return;
281
+ }
98
282
 
99
283
  // Compute per-file references
100
284
  const fileImpact = []; // { file, references: [{doc, line, kind}] }
@@ -113,15 +297,84 @@ export function runImpact(projectDir, _config, flags) {
113
297
  const affectedDocs = Array.from(docMap.entries()).map(([doc, files]) => ({
114
298
  doc,
115
299
  files: Array.from(files),
300
+ isAgentFile: agentDocs.has(doc),
116
301
  }));
117
302
 
303
+ // ── Indirect impact: docs about the IMPORTERS of a changed file ──
304
+ // Reverse-import BFS (2 hops max). A doc already directly affected by the
305
+ // same changed file is not repeated here — direct wins.
306
+ const indirectDocs = [];
307
+ if (flags.indirect !== false && codeChanged.length > 0) {
308
+ const graph = buildImportGraph(projectDir, config || {});
309
+ if (graph.edges.length > 0) {
310
+ const reverseEdges = new Map(); // to → [from…]
311
+ const outDegree = new Map(); // from → number of imports
312
+ for (const e of graph.edges) {
313
+ if (!reverseEdges.has(e.to)) reverseEdges.set(e.to, []);
314
+ reverseEdges.get(e.to).push(e.from);
315
+ outDegree.set(e.from, (outDegree.get(e.from) || 0) + 1);
316
+ }
317
+ // Hub suppression (dogfooded): an orchestrator that imports many modules
318
+ // (a CLI dispatcher, a barrel index) would flag its docs on EVERY
319
+ // dependency change — recurring noise, not signal. Its doc-relevant
320
+ // surface rarely shifts when one of 30 imports does.
321
+ const HUB_OUT_DEGREE = 15;
322
+ const isHub = (f) => (outDegree.get(f) || 0) > HUB_OUT_DEGREE;
323
+ const changedSet = new Set(codeChanged.map(f => f.replace(/^\.\//, '')));
324
+ const indirectMap = new Map(); // doc → chains[]
325
+ for (const f of codeChanged) {
326
+ const norm = f.replace(/^\.\//, '');
327
+ const directDocs = new Set(
328
+ (fileImpact.find(fi => fi.file === f)?.references || []).map(r => r.doc));
329
+ for (const [ancestor, hops] of reverseImportAncestors(norm, reverseEdges)) {
330
+ if (changedSet.has(ancestor)) continue; // changed files have their own direct row
331
+ if (isHub(ancestor)) continue; // hub modules: noise, not signal
332
+ for (const r of findReferences(ancestor, docsIndex)) {
333
+ if (directDocs.has(r.doc)) continue;
334
+ if (!indirectMap.has(r.doc)) indirectMap.set(r.doc, []);
335
+ const chains = indirectMap.get(r.doc);
336
+ if (!chains.some(ch => ch.changed === norm && ch.via === ancestor)) {
337
+ chains.push({ changed: norm, via: ancestor, hops });
338
+ }
339
+ }
340
+ }
341
+ }
342
+ for (const [doc, chains] of indirectMap) {
343
+ indirectDocs.push({ doc, isAgentFile: agentDocs.has(doc), chains });
344
+ }
345
+ }
346
+ }
347
+
348
+ // ── Doc→doc blast radius: a changed DOC flags the docs that reference it ──
349
+ // (including agent-instruction files that point at it). Only meaningful edges
350
+ // are emitted (changed doc with ≥1 dependent).
351
+ //
352
+ // A source must be an INDEXED canonical/agent doc — not any changed `.md`.
353
+ // Otherwise a CHANGELOG.md / README.md / .wolf/*.md change flags every doc
354
+ // that merely mentions it in passing (dogfooding false positives).
355
+ const indexBasenames = new Set(docsIndex.keys());
356
+ const changedDocs = changed.filter(f => f.endsWith('.md') && indexBasenames.has(basename(f)));
357
+ const blastRadius = [];
358
+ for (const cd of changedDocs) {
359
+ const dependents = docsReferencing(cd, docsIndex);
360
+ if (dependents.length > 0) {
361
+ blastRadius.push({
362
+ changedDoc: cd,
363
+ dependents: dependents.map(d => ({ doc: d, isAgentFile: agentDocs.has(d) })),
364
+ });
365
+ }
366
+ }
367
+
118
368
  // ── JSON output ──
119
369
  if (isJson) {
120
370
  console.log(JSON.stringify({
121
371
  since,
122
372
  changedFiles: codeChanged,
123
- ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f)),
373
+ changedDocs,
374
+ ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f) && !f.endsWith('.md')),
124
375
  affectedDocs,
376
+ indirectDocs,
377
+ blastRadius,
125
378
  timestamp: new Date().toISOString(),
126
379
  }, null, 2));
127
380
  return;
@@ -134,8 +387,28 @@ export function runImpact(projectDir, _config, flags) {
134
387
  console.log(` ${c.green}✅ No file changes since ${since}.${c.reset}`);
135
388
  return;
136
389
  }
390
+
391
+ // Doc→doc blast radius — shown whether or not code changed (a doc-only change
392
+ // can still ripple to the docs / agent files that reference it).
393
+ const printBlast = () => {
394
+ if (blastRadius.length === 0) return;
395
+ console.log(`\n ${c.bold}🌐 Doc blast radius${c.reset} ${c.dim}(${changedDocs.length} doc(s) changed)${c.reset}`);
396
+ for (const { changedDoc, dependents } of blastRadius) {
397
+ console.log(` ${c.cyan}${changedDoc}${c.reset} ${c.dim}changed → review ${dependents.length} dependent doc(s):${c.reset}`);
398
+ for (const dep of dependents.slice(0, 8)) {
399
+ const tag = dep.isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
400
+ console.log(` ${c.dim}↳${c.reset} ${dep.doc}${tag}`);
401
+ }
402
+ if (dependents.length > 8) console.log(` ${c.dim}... ${dependents.length - 8} more${c.reset}`);
403
+ }
404
+ };
405
+
137
406
  if (codeChanged.length === 0) {
138
- console.log(` ${c.dim}No code files changed (${changed.length} non-code files: ${changed.slice(0, 3).join(', ')}${changed.length > 3 ? '…' : ''}).${c.reset}`);
407
+ console.log(` ${c.dim}No code files changed (${changedDocs.length} doc(s) + ${changed.length - changedDocs.length} other non-code file(s)).${c.reset}`);
408
+ if (blastRadius.length === 0 && changedDocs.length > 0) {
409
+ console.log(` ${c.green}✅ No other docs reference the changed doc(s).${c.reset}`);
410
+ }
411
+ printBlast();
139
412
  return;
140
413
  }
141
414
 
@@ -146,16 +419,30 @@ export function runImpact(projectDir, _config, flags) {
146
419
  console.log(` ${c.dim}This often means the changed code is undocumented. Consider:${c.reset}`);
147
420
  console.log(` ${c.dim} - Running ${c.cyan}docguard generate --plan${c.dim} to add doc skeletons${c.reset}`);
148
421
  console.log(` ${c.dim} - Reviewing whether the change belongs in an existing doc${c.reset}`);
149
- return;
422
+ } else {
423
+ console.log(` ${c.green}${affectedDocs.length}${c.reset} canonical doc(s) reference the changed files:\n`);
424
+ for (const { doc, files, isAgentFile } of affectedDocs) {
425
+ const tag = isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
426
+ console.log(` ${c.cyan}${doc}${c.reset}${tag} ${c.dim}(${files.length} file${files.length > 1 ? 's' : ''})${c.reset}`);
427
+ for (const f of files.slice(0, 5)) {
428
+ console.log(` ${c.dim}via${c.reset} ${f}`);
429
+ }
430
+ if (files.length > 5) console.log(` ${c.dim}... ${files.length - 5} more${c.reset}`);
431
+ }
150
432
  }
151
433
 
152
- console.log(` ${c.green}${affectedDocs.length}${c.reset} canonical doc(s) reference the changed files:\n`);
153
- for (const { doc, files } of affectedDocs) {
154
- console.log(` ${c.cyan}${doc}${c.reset} ${c.dim}(${files.length} file${files.length > 1 ? 's' : ''})${c.reset}`);
155
- for (const f of files.slice(0, 5)) {
156
- console.log(` ${c.dim}via${c.reset} ${f}`);
434
+ // Indirect impact docs about modules that import a changed file.
435
+ if (indirectDocs.length > 0) {
436
+ console.log(`\n ${c.bold}↺ Indirect impact${c.reset} ${c.dim}(docs about modules that import the changed files)${c.reset}`);
437
+ for (const { doc, isAgentFile, chains } of indirectDocs.slice(0, 8)) {
438
+ const tag = isAgentFile ? ` ${c.yellow}[agent-instruction]${c.reset}` : '';
439
+ console.log(` ${c.cyan}${doc}${c.reset}${tag}`);
440
+ for (const ch of chains.slice(0, 3)) {
441
+ console.log(` ${c.dim}↳ describes${c.reset} ${ch.via}${c.dim}, which imports${c.reset} ${ch.changed} ${c.dim}(${ch.hops} hop${ch.hops > 1 ? 's' : ''})${c.reset}`);
442
+ }
443
+ if (chains.length > 3) console.log(` ${c.dim}... ${chains.length - 3} more chain(s)${c.reset}`);
157
444
  }
158
- if (files.length > 5) console.log(` ${c.dim}... ${files.length - 5} more${c.reset}`);
445
+ if (indirectDocs.length > 8) console.log(` ${c.dim}... ${indirectDocs.length - 8} more doc(s)${c.reset}`);
159
446
  }
160
447
 
161
448
  // List code files with NO doc references — these may need new docs
@@ -166,4 +453,6 @@ export function runImpact(projectDir, _config, flags) {
166
453
  if (orphaned.length > 5) console.log(` ${c.dim}... ${orphaned.length - 5} more${c.reset}`);
167
454
  console.log(` ${c.dim}These may be undocumented — review whether they belong in an existing doc.${c.reset}`);
168
455
  }
456
+
457
+ printBlast();
169
458
  }
@@ -204,62 +204,69 @@ const TOOL_HANDLERS = {
204
204
  };
205
205
 
206
206
  /**
207
- * Serve MCP over stdio until stdin closes. The returned promise keeps the
207
+ * Transport-agnostic JSON-RPC dispatch. Returns the response message for a
208
+ * request, or null for notifications (which get no response by spec). Both
209
+ * the stdio and HTTP transports route through this one dispatcher.
210
+ */
211
+ function dispatchMessage(msg, projectDir) {
212
+ const result = (id, res) => ({ jsonrpc: '2.0', id, result: res });
213
+ const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
214
+
215
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
216
+ return error(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
217
+ }
218
+ const { id, method, params } = msg;
219
+ const isNotification = id === undefined || id === null;
220
+
221
+ switch (method) {
222
+ case 'initialize':
223
+ return result(id, {
224
+ protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
225
+ capabilities: { tools: {} },
226
+ serverInfo: { name: 'docguard', version: _PKG.version },
227
+ });
228
+ case 'ping':
229
+ return result(id, {});
230
+ case 'tools/list':
231
+ return result(id, { tools: TOOLS });
232
+ case 'tools/call': {
233
+ const handler = TOOL_HANDLERS[params?.name];
234
+ if (!handler) return error(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
235
+ // In-tool failures are tool RESULTS (isError), not protocol errors —
236
+ // one bad call must never take down the server or the session.
237
+ try {
238
+ const payload = handler(params?.arguments || {}, projectDir);
239
+ return result(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
240
+ } catch (err) {
241
+ return result(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
242
+ }
243
+ }
244
+ default:
245
+ // Notifications (initialized, cancelled, …) get no response by spec.
246
+ if (isNotification) return null;
247
+ return error(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Serve MCP until the transport closes. The returned promise keeps the
208
253
  * dispatcher's `await` (and thus the process) alive for the server's lifetime.
254
+ * Default transport is stdio; `--transport http` serves the same tools over
255
+ * the MCP Streamable HTTP transport so one shared process can serve a team.
209
256
  */
210
- export function runMcp(projectDir, _config, _flags) {
257
+ export function runMcp(projectDir, _config, flags = {}) {
258
+ if (flags.transport === 'http') return runMcpHttp(projectDir, flags);
259
+ if (flags.transport && flags.transport !== 'stdio') {
260
+ process.stderr.write(`docguard mcp: unknown transport "${flags.transport}" (expected stdio or http)\n`);
261
+ process.exitCode = 1;
262
+ return;
263
+ }
264
+
211
265
  const send = (msg) => {
212
266
  // A vanished client (EPIPE) is a normal shutdown, not a crash.
213
267
  try { process.stdout.write(JSON.stringify(msg) + '\n'); }
214
268
  catch { /* client gone — the readline close handler ends the server */ }
215
269
  };
216
- const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
217
- const replyError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
218
-
219
- const handleMessage = (msg) => {
220
- if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
221
- replyError(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
222
- return;
223
- }
224
- const { id, method, params } = msg;
225
- const isNotification = id === undefined || id === null;
226
-
227
- switch (method) {
228
- case 'initialize':
229
- reply(id, {
230
- protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
231
- capabilities: { tools: {} },
232
- serverInfo: { name: 'docguard', version: _PKG.version },
233
- });
234
- return;
235
- case 'ping':
236
- reply(id, {});
237
- return;
238
- case 'tools/list':
239
- reply(id, { tools: TOOLS });
240
- return;
241
- case 'tools/call': {
242
- const handler = TOOL_HANDLERS[params?.name];
243
- if (!handler) {
244
- replyError(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
245
- return;
246
- }
247
- // In-tool failures are tool RESULTS (isError), not protocol errors —
248
- // one bad call must never take down the server or the session.
249
- try {
250
- const payload = handler(params?.arguments || {}, projectDir);
251
- reply(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
252
- } catch (err) {
253
- reply(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
254
- }
255
- return;
256
- }
257
- default:
258
- // Notifications (initialized, cancelled, …) get no response by spec.
259
- if (isNotification) return;
260
- replyError(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
261
- }
262
- };
263
270
 
264
271
  process.stderr.write(`docguard mcp v${_PKG.version} — serving ${TOOLS.length} tools on stdio (project: ${projectDir})\n`);
265
272
 
@@ -270,14 +277,133 @@ export function runMcp(projectDir, _config, _flags) {
270
277
  if (!trimmed) return;
271
278
  let msg;
272
279
  try { msg = JSON.parse(trimmed); }
273
- catch { replyError(null, E_PARSE, 'Parse error'); return; }
274
- try { handleMessage(msg); }
275
- catch (err) {
280
+ catch { send({ jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); return; }
281
+ try {
282
+ const resp = dispatchMessage(msg, projectDir);
283
+ if (resp) send(resp);
284
+ } catch (err) {
276
285
  // Last-resort trap: a protocol-handler bug must not kill the server.
277
286
  process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
278
- if (msg && msg.id !== undefined && msg.id !== null) replyError(msg.id, E_INTERNAL, 'Internal error');
287
+ if (msg && msg.id !== undefined && msg.id !== null) {
288
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: E_INTERNAL, message: 'Internal error' } });
289
+ }
279
290
  }
280
291
  });
281
292
  rl.on('close', () => done());
282
293
  });
283
294
  }
295
+
296
+ // ── Streamable HTTP transport ───────────────────────────────────────────────
297
+ //
298
+ // Minimal spec-compliant subset, zero-dep (node:http):
299
+ // - POST <path>: JSON-RPC request/batch in, application/json out. A body of
300
+ // only notifications → 202 Accepted, empty.
301
+ // - GET <path>: 405 — this server does not offer a server-initiated SSE
302
+ // stream (clients that need one fall back to plain request/response).
303
+ // - DELETE <path>: 200 — the server is stateless; nothing to clean up.
304
+ // - `Mcp-Session-Id` is issued on initialize and accepted (not required)
305
+ // afterwards — stateless by design, like `--stateless` HTTP MCP servers.
306
+ //
307
+ // Security posture (Security → Production-readiness → Simplicity):
308
+ // - Default bind 127.0.0.1 (loopback-only).
309
+ // - Binding any non-loopback host REQUIRES --api-key / DOCGUARD_API_KEY —
310
+ // the server refuses to start otherwise, instead of warning and exposing
311
+ // read access to the whole network.
312
+ // - When an api-key is set, every request must carry it
313
+ // (`Authorization: Bearer <key>` or `X-API-Key: <key>`) → else 401.
314
+ // - Origin allow-list on loopback binds (DNS-rebinding guard per the MCP
315
+ // Streamable HTTP security notes): browser-originated cross-site requests
316
+ // are rejected; non-browser clients send no Origin and pass.
317
+
318
+ const HTTP_BODY_CAP = 4 * 1024 * 1024; // 4 MiB — guard payloads are large but bounded
319
+
320
+ function isLoopbackHost(host) {
321
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
322
+ }
323
+
324
+ async function runMcpHttp(projectDir, flags) {
325
+ const { createServer } = await import('node:http');
326
+ const { randomUUID } = await import('node:crypto');
327
+
328
+ const host = flags.host || '127.0.0.1';
329
+ const port = Number.isFinite(Number(flags.port)) && Number(flags.port) >= 0 ? Number(flags.port) : 8585;
330
+ const mountPath = flags.path || '/mcp';
331
+ const apiKey = flags.apiKey || process.env.DOCGUARD_API_KEY || '';
332
+
333
+ if (!isLoopbackHost(host) && !apiKey) {
334
+ process.stderr.write(
335
+ `docguard mcp: refusing to bind ${host} without an API key.\n` +
336
+ `Exposing the server beyond localhost requires --api-key <key> (or DOCGUARD_API_KEY).\n`);
337
+ process.exitCode = 1;
338
+ return;
339
+ }
340
+
341
+ const authorized = (req) => {
342
+ if (!apiKey) return true;
343
+ const auth = req.headers['authorization'] || '';
344
+ const xkey = req.headers['x-api-key'] || '';
345
+ return auth === `Bearer ${apiKey}` || xkey === apiKey;
346
+ };
347
+
348
+ const originAllowed = (req) => {
349
+ const origin = req.headers['origin'];
350
+ if (!origin) return true; // non-browser clients (MCP SDKs, curl) send none
351
+ try {
352
+ const o = new URL(origin);
353
+ return isLoopbackHost(o.hostname);
354
+ } catch { return false; }
355
+ };
356
+
357
+ const server = createServer((req, res) => {
358
+ const answer = (status, body, headers = {}) => {
359
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
360
+ res.end(body === undefined ? '' : JSON.stringify(body));
361
+ };
362
+
363
+ const url = (req.url || '').split('?')[0];
364
+ if (url !== mountPath) return answer(404, { error: 'not found' });
365
+ if (!originAllowed(req)) return answer(403, { error: 'origin not allowed' });
366
+ if (!authorized(req)) return answer(401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer' });
367
+
368
+ if (req.method === 'GET') return answer(405, { error: 'SSE stream not offered — POST JSON-RPC to this endpoint' }, { allow: 'POST, DELETE' });
369
+ if (req.method === 'DELETE') return answer(200, {}); // stateless — nothing to end
370
+ if (req.method !== 'POST') return answer(405, { error: 'method not allowed' }, { allow: 'POST, DELETE' });
371
+
372
+ let size = 0;
373
+ const chunks = [];
374
+ req.on('data', (c) => {
375
+ size += c.length;
376
+ if (size > HTTP_BODY_CAP) { answer(413, { error: 'payload too large' }); req.destroy(); return; }
377
+ chunks.push(c);
378
+ });
379
+ req.on('end', () => {
380
+ if (res.writableEnded) return;
381
+ let parsed;
382
+ try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')); }
383
+ catch { return answer(400, { jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); }
384
+
385
+ try {
386
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
387
+ const responses = messages.map((m) => dispatchMessage(m, projectDir)).filter(Boolean);
388
+ // New sessions get an id on initialize; we accept any/none afterwards.
389
+ const headers = messages.some((m) => m && m.method === 'initialize')
390
+ ? { 'mcp-session-id': randomUUID() } : {};
391
+ if (responses.length === 0) return answer(202, undefined, headers); // notifications only
392
+ return answer(200, Array.isArray(parsed) ? responses : responses[0], headers);
393
+ } catch (err) {
394
+ process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
395
+ return answer(500, { jsonrpc: '2.0', id: null, error: { code: E_INTERNAL, message: 'Internal error' } });
396
+ }
397
+ });
398
+ });
399
+
400
+ return new Promise((done) => {
401
+ server.listen(port, host, () => {
402
+ const addr = server.address();
403
+ process.stderr.write(
404
+ `docguard mcp v${_PKG.version} — Streamable HTTP on http://${host}:${addr.port}${mountPath} ` +
405
+ `(project: ${projectDir}${apiKey ? ', api-key required' : ', loopback only'})\n`);
406
+ });
407
+ server.on('close', () => done());
408
+ });
409
+ }