docguard-cli 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/PHILOSOPHY.md +1 -0
  2. package/README.md +70 -30
  3. package/cli/commands/ci.mjs +52 -13
  4. package/cli/commands/guard.mjs +80 -0
  5. package/cli/commands/hooks.mjs +167 -2
  6. package/cli/commands/impact.mjs +213 -5
  7. package/cli/commands/mcp.mjs +195 -53
  8. package/cli/commands/report.mjs +200 -0
  9. package/cli/commands/score.mjs +55 -1
  10. package/cli/docguard.mjs +101 -13
  11. package/cli/findings.mjs +6 -0
  12. package/cli/scanners/agent-readability.mjs +6 -1
  13. package/cli/scanners/semantic-claims.mjs +10 -2
  14. package/cli/shared-git.mjs +23 -0
  15. package/cli/validators/architecture.mjs +8 -1
  16. package/cli/validators/cross-reference.mjs +124 -3
  17. package/cli/validators/docs-coverage.mjs +5 -0
  18. package/cli/validators/reference-existence.mjs +172 -18
  19. package/cli/validators/traceability.mjs +63 -0
  20. package/cli/writers/baseline.mjs +84 -0
  21. package/cli/writers/history.mjs +82 -0
  22. package/cli/writers/junit.mjs +103 -0
  23. package/docs/commands.md +30 -2
  24. package/docs/configuration.md +14 -0
  25. package/docs/faq.md +12 -0
  26. package/extensions/spec-kit-docguard/extension.yml +1 -1
  27. package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
  28. package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
  29. package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
  30. package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
  31. package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
  32. package/package.json +1 -1
  33. package/schemas/docguard-config.schema.json +5 -0
@@ -30,13 +30,23 @@
30
30
  * @req SC-S11-004 — non-code files (.md, .json, etc.) are skipped from impact analysis
31
31
  * @req SC-S11-007 — agent-instruction files participate in impact analysis
32
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
33
41
  */
34
42
 
35
43
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
36
44
  import { resolve, basename } from 'node:path';
45
+ import { execFileSync } from 'node:child_process';
37
46
 
38
47
  import { c } from '../shared.mjs';
39
48
  import { changedFilesSince, isGitRepo } from '../shared-git.mjs';
49
+ import { buildImportGraph } from '../validators/architecture.mjs';
40
50
 
41
51
  /**
42
52
  * File extensions we consider "code" for the purposes of impact analysis.
@@ -52,16 +62,20 @@ function escapeRegex(s) {
52
62
  }
53
63
 
54
64
  /**
55
- * Doc→doc references: which indexed docs reference `changedDocPath` (by its
56
- * basename the form used in prose "see ARCHITECTURE.md" and markdown links
57
- * `](ARCHITECTURE.md)`). Skips self. This is the blast-radius edge set.
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.
58
70
  */
59
71
  function docsReferencing(changedDocPath, index) {
60
72
  const cbase = basename(changedDocPath);
73
+ const stem = cbase.replace(/\.md$/i, '');
74
+ const wikiRe = new RegExp(`\\[\\[${escapeRegex(stem)}(?:[#|\\]])`);
61
75
  const dependents = [];
62
76
  for (const [docName, lines] of index) {
63
77
  if (docName === cbase || docName === changedDocPath) continue; // not self
64
- if (lines.some(l => l.includes(cbase))) dependents.push(docName);
78
+ if (lines.some(l => l.includes(cbase) || wikiRe.test(l))) dependents.push(docName);
65
79
  }
66
80
  return dependents;
67
81
  }
@@ -92,7 +106,135 @@ function findReferences(file, docs) {
92
106
  return refs;
93
107
  }
94
108
 
95
- 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) {
96
238
  const isJson = flags.format === 'json';
97
239
  const since = flags.since || 'HEAD~1';
98
240
 
@@ -132,6 +274,12 @@ export function runImpact(projectDir, _config, flags) {
132
274
  try { docsIndex.set(a, readFileSync(p, 'utf-8').split('\n')); agentDocs.add(a); } catch { /* skip */ }
133
275
  }
134
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
+ }
282
+
135
283
  // Compute per-file references
136
284
  const fileImpact = []; // { file, references: [{doc, line, kind}] }
137
285
  for (const f of codeChanged) {
@@ -152,6 +300,51 @@ export function runImpact(projectDir, _config, flags) {
152
300
  isAgentFile: agentDocs.has(doc),
153
301
  }));
154
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
+
155
348
  // ── Doc→doc blast radius: a changed DOC flags the docs that reference it ──
156
349
  // (including agent-instruction files that point at it). Only meaningful edges
157
350
  // are emitted (changed doc with ≥1 dependent).
@@ -180,6 +373,7 @@ export function runImpact(projectDir, _config, flags) {
180
373
  changedDocs,
181
374
  ignoredFiles: changed.filter(f => !CODE_EXTENSIONS.test(f) && !f.endsWith('.md')),
182
375
  affectedDocs,
376
+ indirectDocs,
183
377
  blastRadius,
184
378
  timestamp: new Date().toISOString(),
185
379
  }, null, 2));
@@ -237,6 +431,20 @@ export function runImpact(projectDir, _config, flags) {
237
431
  }
238
432
  }
239
433
 
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}`);
444
+ }
445
+ if (indirectDocs.length > 8) console.log(` ${c.dim}... ${indirectDocs.length - 8} more doc(s)${c.reset}`);
446
+ }
447
+
240
448
  // List code files with NO doc references — these may need new docs
241
449
  const orphaned = fileImpact.filter(fi => fi.references.length === 0).map(fi => fi.file);
242
450
  if (orphaned.length > 0) {
@@ -27,6 +27,7 @@ import { resolve, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { runGuardInternal } from './guard.mjs';
29
29
  import { runScoreInternal } from './score.mjs';
30
+ import { buildReport } from './report.mjs';
30
31
  import { loadConfig } from '../config.mjs';
31
32
  import { CODES } from '../findings.mjs';
32
33
  import { extractSemanticClaims, buildSemanticVerifyTasks } from '../scanners/semantic-claims.mjs';
@@ -110,6 +111,16 @@ const TOOLS = [
110
111
  },
111
112
  annotations: READONLY_ANNOTATIONS,
112
113
  },
114
+ {
115
+ name: 'docguard_report',
116
+ title: 'Compliance-evidence bundle',
117
+ description: 'Generate the commit-stamped compliance-evidence bundle: guard verdict per validator, findings grouped by stable code, CDD score, ALCOA+ data-integrity attributes, fix history, and a tamper-evident sha256 integrity hash. Evidence, not a gate — it reports state without failing.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: { ...PROJECT_DIR_PROP },
121
+ },
122
+ annotations: READONLY_ANNOTATIONS,
123
+ },
113
124
  {
114
125
  name: 'docguard_diagnose',
115
126
  title: 'Diagnose what to fix',
@@ -169,6 +180,11 @@ const TOOL_HANDLERS = {
169
180
  };
170
181
  },
171
182
 
183
+ docguard_report(args, defaultDir) {
184
+ const { dir, config } = resolveTarget(args, defaultDir);
185
+ return buildReport(dir, config);
186
+ },
187
+
172
188
  docguard_diagnose(args, defaultDir) {
173
189
  const { dir, config } = resolveTarget(args, defaultDir);
174
190
  const data = runGuardInternal(dir, config);
@@ -204,62 +220,69 @@ const TOOL_HANDLERS = {
204
220
  };
205
221
 
206
222
  /**
207
- * Serve MCP over stdio until stdin closes. The returned promise keeps the
223
+ * Transport-agnostic JSON-RPC dispatch. Returns the response message for a
224
+ * request, or null for notifications (which get no response by spec). Both
225
+ * the stdio and HTTP transports route through this one dispatcher.
226
+ */
227
+ function dispatchMessage(msg, projectDir) {
228
+ const result = (id, res) => ({ jsonrpc: '2.0', id, result: res });
229
+ const error = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
230
+
231
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
232
+ return error(msg && msg.id !== undefined ? msg.id : null, E_INVALID_REQUEST, 'Invalid Request');
233
+ }
234
+ const { id, method, params } = msg;
235
+ const isNotification = id === undefined || id === null;
236
+
237
+ switch (method) {
238
+ case 'initialize':
239
+ return result(id, {
240
+ protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION,
241
+ capabilities: { tools: {} },
242
+ serverInfo: { name: 'docguard', version: _PKG.version },
243
+ });
244
+ case 'ping':
245
+ return result(id, {});
246
+ case 'tools/list':
247
+ return result(id, { tools: TOOLS });
248
+ case 'tools/call': {
249
+ const handler = TOOL_HANDLERS[params?.name];
250
+ if (!handler) return error(id, E_INVALID_PARAMS, `Unknown tool: ${params?.name}`);
251
+ // In-tool failures are tool RESULTS (isError), not protocol errors —
252
+ // one bad call must never take down the server or the session.
253
+ try {
254
+ const payload = handler(params?.arguments || {}, projectDir);
255
+ return result(id, { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
256
+ } catch (err) {
257
+ return result(id, { content: [{ type: 'text', text: String((err && err.message) || err) }], isError: true });
258
+ }
259
+ }
260
+ default:
261
+ // Notifications (initialized, cancelled, …) get no response by spec.
262
+ if (isNotification) return null;
263
+ return error(id, E_METHOD_NOT_FOUND, `Method not found: ${method}`);
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Serve MCP until the transport closes. The returned promise keeps the
208
269
  * dispatcher's `await` (and thus the process) alive for the server's lifetime.
270
+ * Default transport is stdio; `--transport http` serves the same tools over
271
+ * the MCP Streamable HTTP transport so one shared process can serve a team.
209
272
  */
210
- export function runMcp(projectDir, _config, _flags) {
273
+ export function runMcp(projectDir, _config, flags = {}) {
274
+ if (flags.transport === 'http') return runMcpHttp(projectDir, flags);
275
+ if (flags.transport && flags.transport !== 'stdio') {
276
+ process.stderr.write(`docguard mcp: unknown transport "${flags.transport}" (expected stdio or http)\n`);
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+
211
281
  const send = (msg) => {
212
282
  // A vanished client (EPIPE) is a normal shutdown, not a crash.
213
283
  try { process.stdout.write(JSON.stringify(msg) + '\n'); }
214
284
  catch { /* client gone — the readline close handler ends the server */ }
215
285
  };
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
286
 
264
287
  process.stderr.write(`docguard mcp v${_PKG.version} — serving ${TOOLS.length} tools on stdio (project: ${projectDir})\n`);
265
288
 
@@ -270,14 +293,133 @@ export function runMcp(projectDir, _config, _flags) {
270
293
  if (!trimmed) return;
271
294
  let msg;
272
295
  try { msg = JSON.parse(trimmed); }
273
- catch { replyError(null, E_PARSE, 'Parse error'); return; }
274
- try { handleMessage(msg); }
275
- catch (err) {
296
+ catch { send({ jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); return; }
297
+ try {
298
+ const resp = dispatchMessage(msg, projectDir);
299
+ if (resp) send(resp);
300
+ } catch (err) {
276
301
  // Last-resort trap: a protocol-handler bug must not kill the server.
277
302
  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');
303
+ if (msg && msg.id !== undefined && msg.id !== null) {
304
+ send({ jsonrpc: '2.0', id: msg.id, error: { code: E_INTERNAL, message: 'Internal error' } });
305
+ }
279
306
  }
280
307
  });
281
308
  rl.on('close', () => done());
282
309
  });
283
310
  }
311
+
312
+ // ── Streamable HTTP transport ───────────────────────────────────────────────
313
+ //
314
+ // Minimal spec-compliant subset, zero-dep (node:http):
315
+ // - POST <path>: JSON-RPC request/batch in, application/json out. A body of
316
+ // only notifications → 202 Accepted, empty.
317
+ // - GET <path>: 405 — this server does not offer a server-initiated SSE
318
+ // stream (clients that need one fall back to plain request/response).
319
+ // - DELETE <path>: 200 — the server is stateless; nothing to clean up.
320
+ // - `Mcp-Session-Id` is issued on initialize and accepted (not required)
321
+ // afterwards — stateless by design, like `--stateless` HTTP MCP servers.
322
+ //
323
+ // Security posture (Security → Production-readiness → Simplicity):
324
+ // - Default bind 127.0.0.1 (loopback-only).
325
+ // - Binding any non-loopback host REQUIRES --api-key / DOCGUARD_API_KEY —
326
+ // the server refuses to start otherwise, instead of warning and exposing
327
+ // read access to the whole network.
328
+ // - When an api-key is set, every request must carry it
329
+ // (`Authorization: Bearer <key>` or `X-API-Key: <key>`) → else 401.
330
+ // - Origin allow-list on loopback binds (DNS-rebinding guard per the MCP
331
+ // Streamable HTTP security notes): browser-originated cross-site requests
332
+ // are rejected; non-browser clients send no Origin and pass.
333
+
334
+ const HTTP_BODY_CAP = 4 * 1024 * 1024; // 4 MiB — guard payloads are large but bounded
335
+
336
+ function isLoopbackHost(host) {
337
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
338
+ }
339
+
340
+ async function runMcpHttp(projectDir, flags) {
341
+ const { createServer } = await import('node:http');
342
+ const { randomUUID } = await import('node:crypto');
343
+
344
+ const host = flags.host || '127.0.0.1';
345
+ const port = Number.isFinite(Number(flags.port)) && Number(flags.port) >= 0 ? Number(flags.port) : 8585;
346
+ const mountPath = flags.path || '/mcp';
347
+ const apiKey = flags.apiKey || process.env.DOCGUARD_API_KEY || '';
348
+
349
+ if (!isLoopbackHost(host) && !apiKey) {
350
+ process.stderr.write(
351
+ `docguard mcp: refusing to bind ${host} without an API key.\n` +
352
+ `Exposing the server beyond localhost requires --api-key <key> (or DOCGUARD_API_KEY).\n`);
353
+ process.exitCode = 1;
354
+ return;
355
+ }
356
+
357
+ const authorized = (req) => {
358
+ if (!apiKey) return true;
359
+ const auth = req.headers['authorization'] || '';
360
+ const xkey = req.headers['x-api-key'] || '';
361
+ return auth === `Bearer ${apiKey}` || xkey === apiKey;
362
+ };
363
+
364
+ const originAllowed = (req) => {
365
+ const origin = req.headers['origin'];
366
+ if (!origin) return true; // non-browser clients (MCP SDKs, curl) send none
367
+ try {
368
+ const o = new URL(origin);
369
+ return isLoopbackHost(o.hostname);
370
+ } catch { return false; }
371
+ };
372
+
373
+ const server = createServer((req, res) => {
374
+ const answer = (status, body, headers = {}) => {
375
+ res.writeHead(status, { 'content-type': 'application/json', ...headers });
376
+ res.end(body === undefined ? '' : JSON.stringify(body));
377
+ };
378
+
379
+ const url = (req.url || '').split('?')[0];
380
+ if (url !== mountPath) return answer(404, { error: 'not found' });
381
+ if (!originAllowed(req)) return answer(403, { error: 'origin not allowed' });
382
+ if (!authorized(req)) return answer(401, { error: 'unauthorized' }, { 'www-authenticate': 'Bearer' });
383
+
384
+ if (req.method === 'GET') return answer(405, { error: 'SSE stream not offered — POST JSON-RPC to this endpoint' }, { allow: 'POST, DELETE' });
385
+ if (req.method === 'DELETE') return answer(200, {}); // stateless — nothing to end
386
+ if (req.method !== 'POST') return answer(405, { error: 'method not allowed' }, { allow: 'POST, DELETE' });
387
+
388
+ let size = 0;
389
+ const chunks = [];
390
+ req.on('data', (c) => {
391
+ size += c.length;
392
+ if (size > HTTP_BODY_CAP) { answer(413, { error: 'payload too large' }); req.destroy(); return; }
393
+ chunks.push(c);
394
+ });
395
+ req.on('end', () => {
396
+ if (res.writableEnded) return;
397
+ let parsed;
398
+ try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf-8')); }
399
+ catch { return answer(400, { jsonrpc: '2.0', id: null, error: { code: E_PARSE, message: 'Parse error' } }); }
400
+
401
+ try {
402
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
403
+ const responses = messages.map((m) => dispatchMessage(m, projectDir)).filter(Boolean);
404
+ // New sessions get an id on initialize; we accept any/none afterwards.
405
+ const headers = messages.some((m) => m && m.method === 'initialize')
406
+ ? { 'mcp-session-id': randomUUID() } : {};
407
+ if (responses.length === 0) return answer(202, undefined, headers); // notifications only
408
+ return answer(200, Array.isArray(parsed) ? responses : responses[0], headers);
409
+ } catch (err) {
410
+ process.stderr.write(`docguard mcp: internal error: ${err && err.stack || err}\n`);
411
+ return answer(500, { jsonrpc: '2.0', id: null, error: { code: E_INTERNAL, message: 'Internal error' } });
412
+ }
413
+ });
414
+ });
415
+
416
+ return new Promise((done) => {
417
+ server.listen(port, host, () => {
418
+ const addr = server.address();
419
+ process.stderr.write(
420
+ `docguard mcp v${_PKG.version} — Streamable HTTP on http://${host}:${addr.port}${mountPath} ` +
421
+ `(project: ${projectDir}${apiKey ? ', api-key required' : ', loopback only'})\n`);
422
+ });
423
+ server.on('close', () => done());
424
+ });
425
+ }