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.
- package/PHILOSOPHY.md +1 -0
- package/README.md +70 -30
- package/cli/commands/ci.mjs +52 -13
- package/cli/commands/guard.mjs +80 -0
- package/cli/commands/hooks.mjs +167 -2
- package/cli/commands/impact.mjs +213 -5
- package/cli/commands/mcp.mjs +195 -53
- package/cli/commands/report.mjs +200 -0
- package/cli/commands/score.mjs +55 -1
- package/cli/docguard.mjs +101 -13
- package/cli/findings.mjs +6 -0
- package/cli/scanners/agent-readability.mjs +6 -1
- package/cli/scanners/semantic-claims.mjs +10 -2
- package/cli/shared-git.mjs +23 -0
- package/cli/validators/architecture.mjs +8 -1
- package/cli/validators/cross-reference.mjs +124 -3
- package/cli/validators/docs-coverage.mjs +5 -0
- package/cli/validators/reference-existence.mjs +172 -18
- package/cli/validators/traceability.mjs +63 -0
- package/cli/writers/baseline.mjs +84 -0
- package/cli/writers/history.mjs +82 -0
- package/cli/writers/junit.mjs +103 -0
- package/docs/commands.md +30 -2
- package/docs/configuration.md +14 -0
- package/docs/faq.md +12 -0
- package/extensions/spec-kit-docguard/extension.yml +1 -1
- package/extensions/spec-kit-docguard/skills/docguard-fix/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-guard/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-review/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-score/SKILL.md +2 -2
- package/extensions/spec-kit-docguard/skills/docguard-sync/SKILL.md +2 -2
- package/package.json +1 -1
- package/schemas/docguard-config.schema.json +5 -0
package/cli/commands/impact.mjs
CHANGED
|
@@ -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`
|
|
56
|
-
* basename
|
|
57
|
-
* `](ARCHITECTURE.md)`)
|
|
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
|
-
|
|
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) {
|
package/cli/commands/mcp.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
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 {
|
|
274
|
-
try {
|
|
275
|
-
|
|
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)
|
|
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
|
+
}
|