mcp-proof 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,15 @@ npx mcp-proof --config ~/.config/Claude/claude_desktop_config.json
28
28
  # Scan a Claude SKILL.md for hidden attack instructions
29
29
  npx mcp-proof --skill ~/.claude/skills/example/SKILL.md
30
30
 
31
+ # Inspect an exact tools/call payload before execution
32
+ cat call.json | npx mcp-proof --execute --json
33
+
34
+ # Create a shareable Trust Card for a PR or README
35
+ npx mcp-proof tools.json --markdown > MCP_TRUST.md
36
+
37
+ # Emit SARIF for GitHub code scanning
38
+ npx mcp-proof tools.json --sarif > results.sarif
39
+
31
40
  # Or pipe stdin
32
41
  cat tools.json | npx mcp-proof --transport http --bind 0.0.0.0:3001
33
42
 
@@ -125,6 +134,10 @@ Options:
125
134
  --stdio <cmd> launch a real stdio MCP server, call tools/list, scan returned tools
126
135
  --config <path> scan every stdio server in an MCP client config with mcpServers
127
136
  --skill <path> scan a Claude SKILL.md (educational/warning context aware)
137
+ --execute [<path>] inspect a tools/call payload before execution
138
+ --markdown emit a shareable Markdown Trust Card
139
+ --trust-card alias for --markdown
140
+ --sarif emit SARIF 2.1.0 for GitHub code scanning
128
141
  --live confirm npm publisher on registry.npmjs.org (one network call)
129
142
  --timeout <ms> MCP launch/protocol timeout per server (default: 15000)
130
143
  --source <s> npm:@scope/pkg, git+https://..., file://...
@@ -140,9 +153,20 @@ Exit codes: 0 allow · 1 warn · 2 block · 64 usage · 65 parse error
140
153
 
141
154
  The non-zero exit code on `warn` / `block` makes this drop-in for CI: pin in your `package.json` and any tool poisoning fails the pipeline.
142
155
 
156
+ ## Trust Cards and GitHub SARIF
157
+
158
+ Every scan can produce a portable review artifact without uploading server metadata:
159
+
160
+ ```bash
161
+ npx mcp-proof --stdio "npx -y @modelcontextprotocol/server-filesystem /tmp" --markdown > MCP_TRUST.md
162
+ npx mcp-proof ./tools.json --sarif > mcp-proof-results.sarif
163
+ ```
164
+
165
+ `MCP_TRUST.md` is suitable for a pull request or repository README. SARIF can be uploaded to GitHub code scanning by a workflow or the included `action.yml`. The output is generated locally from the same verdict and includes the package version and findings; it is not a cryptographic authorization receipt.
166
+
143
167
  ## Real MCP scanning
144
168
 
145
- `0.3.0` can scan a live stdio MCP server, not just a saved JSON manifest. The CLI launches the server locally, performs the MCP `initialize` handshake, calls `tools/list`, and scans the returned tool metadata. It uses a timeout cap so a broken or malicious server cannot hang CI forever.
169
+ `mcp-proof` can scan a live stdio MCP server, not just a saved JSON manifest. The CLI launches the server locally, performs the MCP `initialize` handshake, calls `tools/list`, and scans the returned tool metadata. It uses a timeout cap so a broken or malicious server cannot hang CI forever.
146
170
 
147
171
  ```bash
148
172
  npx mcp-proof --stdio "npx -y @modelcontextprotocol/server-filesystem /tmp"
@@ -170,7 +194,7 @@ The scanner only supports stdio MCP servers in this release. HTTP/SSE runtime di
170
194
  ## Programmatic API
171
195
 
172
196
  ```js
173
- const { evaluate, scanSkillManifest } = require('mcp-proof');
197
+ const { evaluate, evaluateCall, scanSkillManifest } = require('mcp-proof');
174
198
 
175
199
  const result = evaluate({
176
200
  source: 'npm:@modelcontextprotocol/server-filesystem',
@@ -184,6 +208,10 @@ console.log(result.composite); // 0.0 – 10.0
184
208
  console.log(result.reasons); // human-readable explanations
185
209
  console.log(result.toolPoisoning.perTool); // per-tool findings
186
210
 
211
+ // Inspect an exact tool call before execution (does not execute or authorize it)
212
+ const call = evaluateCall({ tool: 'read_file', arguments: { path: './README.md' } });
213
+ console.log(call.decision);
214
+
187
215
  // Scan a Claude SKILL.md
188
216
  const skill = scanSkillManifest(fs.readFileSync('./SKILL.md', 'utf8'));
189
217
  console.log(skill.decision);
@@ -194,6 +222,7 @@ console.log(skill.decision);
194
222
  - **Publisher verification is lexical only by default.** npm scope match + Levenshtein typosquat. Pass `--live` to confirm npm packages against `registry.npmjs.org` with one network call. *No GitHub-API lookup, no signature verification.*
195
223
  - **No source-tree scan.** The full VyriAI gateway also runs a static scanner over the package source for `child_process`, postinstall scripts, obfuscation. That step needs the source unpacked locally and isn't shipped in this CLI.
196
224
  - **No dependency CVE check.** The full gateway wraps `npm audit`; this CLI doesn't shell out.
225
+ - **Limited runtime-payload inspection.** `--execute` scans a supplied `tools/call` name and arguments before execution; it does not execute the call, enforce an authorization policy, verify identity, or observe server runtime behavior.
197
226
  - **Stdio discovery only.** `--stdio` / `--config` launches local stdio MCP servers and calls `tools/list`. It does not yet connect to HTTP/SSE MCP servers.
198
227
  - **Skill context is keyword-based.** The skill detector looks for warning/example/demonstration framing around sensitive signals. A well-disguised attack without that framing may not downgrade.
199
228
  - **Local payload cap.** The CLI refuses inputs over 5 MB by default to avoid accidental memory blowups in CI. Override with `MCP_SCAN_MAX_BYTES=20mb` if you have an unusually large tools manifest.
package/bin/mcp-proof.js CHANGED
@@ -23,7 +23,7 @@ const fs = require('fs');
23
23
  const os = require('os');
24
24
  const path = require('path');
25
25
  const { spawn } = require('child_process');
26
- const { evaluate, scanSkillManifest, verifyPublisherAsync } = require('../engine.js');
26
+ const { evaluate, evaluateCall, scanSkillManifest, verifyPublisherAsync } = require('../engine.js');
27
27
 
28
28
  const DEFAULT_MAX_INPUT_BYTES = 5 * 1024 * 1024;
29
29
  const DEFAULT_TIMEOUT_MS = 15000;
@@ -43,13 +43,18 @@ Usage:
43
43
  mcp-proof --stdio "<command to launch MCP server>"
44
44
  mcp-proof --config <claude-or-cursor-config.json>
45
45
  mcp-proof --skill <SKILL.md>
46
+ mcp-proof --execute [<call.json>] runtime authorization of a tools/call payload
46
47
 
47
- If <file.json> is omitted and --stdio/--config/--skill are not used, JSON is read from stdin.
48
+ If <file.json> is omitted and --stdio/--config/--skill/--execute are not used, JSON is read from stdin.
48
49
 
49
50
  Options:
50
51
  --stdio <cmd> launch a real stdio MCP server, call tools/list, scan returned tools
51
52
  --config <path> scan every stdio server in an MCP client config with mcpServers
52
53
  --skill <path> scan a Claude SKILL.md (educational/warning context aware)
54
+ --execute [<path>] inspect a tools/call payload before execution (reads stdin if path omitted)
55
+ --markdown emit a shareable Markdown Trust Card
56
+ --trust-card alias for --markdown
57
+ --sarif emit SARIF 2.1.0 for GitHub code scanning
53
58
  --live confirm npm publisher against registry.npmjs.org (one network call)
54
59
  --timeout <ms> MCP launch/protocol timeout per server (default: 15000)
55
60
  --source <s> npm:@scope/pkg, git+https://..., or file://... (drives publisher verification)
@@ -89,9 +94,9 @@ async function main() {
89
94
  if (args.selfTest) {
90
95
  return runSelfTest(args);
91
96
  }
92
- const exclusiveModes = [args.stdio, args.config, args.skill].filter(Boolean).length;
97
+ const exclusiveModes = [args.stdio, args.config, args.skill, args.execute].filter(Boolean).length;
93
98
  if (exclusiveModes > 1) {
94
- process.stderr.write('mcp-proof: use one of --stdio, --config, --skill at a time.\n');
99
+ process.stderr.write('mcp-proof: use one of --stdio, --config, --skill, --execute at a time.\n');
95
100
  process.exit(64);
96
101
  }
97
102
 
@@ -103,11 +108,7 @@ async function main() {
103
108
  process.stderr.write(`mcp-proof: ${err.message}\n`);
104
109
  process.exit(err.exitCode || 65);
105
110
  }
106
- if (args.json) {
107
- process.stdout.write(JSON.stringify(output, null, 2) + '\n');
108
- } else {
109
- renderMulti(output);
110
- }
111
+ emitOutput(output, args);
111
112
  process.exit(exitCodeForDecision(output.decision));
112
113
  }
113
114
 
@@ -127,11 +128,7 @@ async function main() {
127
128
  process.stderr.write(`mcp-proof: ${err.message}\n`);
128
129
  process.exit(err.exitCode || 65);
129
130
  }
130
- if (args.json) {
131
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
132
- } else {
133
- renderHumanReadable(result, result.tool_count || 0, result.name || '');
134
- }
131
+ emitOutput(result, args);
135
132
  process.exit(exitCodeForDecision(result.decision));
136
133
  }
137
134
 
@@ -143,11 +140,19 @@ async function main() {
143
140
  process.stderr.write(`mcp-proof: ${err.message}\n`);
144
141
  process.exit(err.exitCode || 65);
145
142
  }
146
- if (args.json) {
147
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
148
- } else {
149
- renderHumanReadable(result, result.tool_count || 0, result.name || '');
143
+ emitOutput(result, args);
144
+ process.exit(exitCodeForDecision(result.decision));
145
+ }
146
+
147
+ if (args.execute) {
148
+ let result;
149
+ try {
150
+ result = runExecute(args);
151
+ } catch (err) {
152
+ process.stderr.write(`mcp-proof: ${err.message}\n`);
153
+ process.exit(err.exitCode || 65);
150
154
  }
155
+ emitOutput(result, args);
151
156
  process.exit(exitCodeForDecision(result.decision));
152
157
  }
153
158
 
@@ -193,11 +198,7 @@ async function main() {
193
198
  result.source = args.source || (parsed.source || '');
194
199
  result.tool_count = tools.length;
195
200
 
196
- if (args.json) {
197
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
198
- } else {
199
- renderHumanReadable(result, tools.length, result.name);
200
- }
201
+ emitOutput(result, args);
201
202
 
202
203
  process.exit(result.decision === 'allow' ? 0 : result.decision === 'warn' ? 1 : 2);
203
204
  }
@@ -206,14 +207,16 @@ function parseArgs(argv) {
206
207
  const out = {
207
208
  source: '', transport: '', bind: '', json: false, selfTest: false,
208
209
  help: false, version: false, file: '', stdio: '', config: '', skill: '',
209
- live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
210
+ execute: false, format: 'human', live: false, timeoutMs: DEFAULT_TIMEOUT_MS,
210
211
  };
211
212
  for (let i = 0; i < argv.length; i++) {
212
213
  const a = argv[i];
213
214
  switch (a) {
214
215
  case '-h': case '--help': out.help = true; break;
215
216
  case '-V': case '--version': out.version = true; break;
216
- case '--json': out.json = true; break;
217
+ case '--json': out.json = true; out.format = 'json'; break;
218
+ case '--markdown': case '--trust-card': out.format = 'markdown'; break;
219
+ case '--sarif': out.format = 'sarif'; break;
217
220
  case '--self-test': out.selfTest = true; break;
218
221
  case '--source': out.source = argv[++i] || ''; break;
219
222
  case '--transport': out.transport = argv[++i] || ''; break;
@@ -221,6 +224,10 @@ function parseArgs(argv) {
221
224
  case '--stdio': out.stdio = argv[++i] || ''; break;
222
225
  case '--config': out.config = expandHome(argv[++i] || ''); break;
223
226
  case '--skill': out.skill = expandHome(argv[++i] || ''); break;
227
+ case '--execute':
228
+ out.execute = true;
229
+ if (argv[i + 1] && !argv[i + 1].startsWith('-')) out.file = argv[++i];
230
+ break;
224
231
  case '--live': out.live = true; break;
225
232
  case '--timeout': out.timeoutMs = parseTimeout(argv[++i] || ''); break;
226
233
  default:
@@ -577,6 +584,70 @@ function worstDecision(decisions) {
577
584
  return 'allow';
578
585
  }
579
586
 
587
+ function emitOutput(result, args) {
588
+ if (args.format === 'json') {
589
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
590
+ } else if (args.format === 'sarif') {
591
+ process.stdout.write(JSON.stringify(toSarif(result), null, 2) + '\n');
592
+ } else if (args.format === 'markdown') {
593
+ process.stdout.write(toTrustCard(result) + '\n');
594
+ } else if (result.results) {
595
+ renderMulti(result);
596
+ } else {
597
+ renderHumanReadable(result, result.tool_count || 0, result.name || result.tool || '');
598
+ }
599
+ }
600
+
601
+ function toTrustCard(r) {
602
+ const verdict = String(r.decision || 'unknown').toUpperCase();
603
+ const source = r.source || r.name || 'stdin';
604
+ const findings = r.toolPoisoning && r.toolPoisoning.perTool
605
+ ? r.toolPoisoning.perTool.flatMap(t => (t.patterns || []).map(p => `- **${p.name}** (${p.severity}) — ${p.reason}`))
606
+ : r.call_scan && r.call_scan.detected ? [`- **runtime_payload** (${r.call_scan.severity}) — ${r.call_scan.reason}`] : [];
607
+ const hash = r.manifestHash || r.payloadHash || 'not emitted';
608
+ return [
609
+ '# MCP Trust Card', '',
610
+ `**Verdict: ${verdict}**`, '',
611
+ '| Field | Value |', '|---|---|',
612
+ `| Source | ${String(source).replace(/\|/g, '\\|')} |`,
613
+ `| Tools scanned | ${r.tool_count ?? 'n/a'} |`,
614
+ `| Trust score | ${r.composite ?? 'n/a'} / 10 |`,
615
+ `| Findings | ${findings.length} |`,
616
+ `| Evidence hash | ${hash} |`,
617
+ `| mcp-proof | ${require('../package.json').version} |`, '',
618
+ '## Findings', '', findings.length ? findings.join('\n') : '- No actionable findings.', '',
619
+ '> Reproduce locally with `npx mcp-proof --json`.'
620
+ ].join('\n');
621
+ }
622
+
623
+ function toSarif(r) {
624
+ const results = [];
625
+ const findings = r.toolPoisoning && r.toolPoisoning.perTool
626
+ ? r.toolPoisoning.perTool.flatMap(t => (t.patterns || []).map(p => ({ ruleId: p.name, level: p.severity === 'critical' || p.severity === 'high' ? 'error' : 'warning', message: `${p.reason} (tool: ${t.tool})` })))
627
+ : r.call_scan && r.call_scan.detected ? [{ ruleId: 'runtime_payload', level: r.call_scan.severity === 'critical' ? 'error' : 'warning', message: r.call_scan.reason }] : [];
628
+ for (const f of findings) results.push({ ruleId: f.ruleId, level: f.level, message: { text: f.message } });
629
+ return { version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json', runs: [{ tool: { driver: { name: 'mcp-proof', version: require('../package.json').version, rules: [...new Map(findings.map(f => [f.ruleId, { id: f.ruleId, shortDescription: { text: f.ruleId } }])).values()] } }, results }] };
630
+ }
631
+
632
+ function runExecute(args) {
633
+ const raw = args.file ? readFileLimited(args.file) : readStdinLimited();
634
+ if (!raw.trim()) {
635
+ throw new Error('empty input. Pipe a tools/call JSON or pass a path.');
636
+ }
637
+ let parsed;
638
+ try {
639
+ parsed = JSON.parse(raw);
640
+ } catch (err) {
641
+ const e = new Error(`invalid JSON: ${err.message}`);
642
+ e.exitCode = 65;
643
+ throw e;
644
+ }
645
+ const result = evaluateCall(parsed);
646
+ result.mode = 'execute';
647
+ result.name = args.file || 'stdin';
648
+ return result;
649
+ }
650
+
580
651
  function exitCodeForDecision(d) {
581
652
  return d === 'allow' ? 0 : d === 'warn' ? 1 : 2;
582
653
  }
package/engine.js CHANGED
@@ -634,4 +634,67 @@ function evaluate(req) {
634
634
 
635
635
  function round1(v) { return Math.round(v * 10) / 10; }
636
636
 
637
- module.exports = { evaluate, scanTools, scanSkillManifest, verifyPublisher, verifyPublisherAsync, validateTransport, levenshtein };
637
+ // ── Runtime tool-call authorization ──────────────────────────────────────────
638
+ //
639
+ // evaluateCall answers the execution-time question: given the exact tool and
640
+ // the exact arguments, is this call still authorized? It applies the same
641
+ // signatures used for static tool manifest scanning to the live payload.
642
+
643
+ function flattenArgs(args) {
644
+ const values = [];
645
+ function walk(v) {
646
+ if (v == null) return;
647
+ if (typeof v === 'string') values.push(v);
648
+ else if (Array.isArray(v)) v.forEach(walk);
649
+ else if (typeof v === 'object') Object.values(v).forEach(walk);
650
+ else values.push(String(v));
651
+ }
652
+ walk(args);
653
+ return values.join('\n');
654
+ }
655
+
656
+ function scanCall(toolName, args) {
657
+ const hay = [toolName, flattenArgs(args)].join('\n');
658
+ for (const sig of SIGNATURES) {
659
+ if (sig.re.test(hay)) {
660
+ return { detected: true, severity: sig.sev, reason: sig.reason };
661
+ }
662
+ }
663
+ if (HIDDEN_CHARS.test(hay)) {
664
+ return { detected: true, severity: 'high', reason: 'arguments contain hidden unicode characters' };
665
+ }
666
+ const b64 = hay.match(BASE64_RE);
667
+ if (b64) {
668
+ for (const m of b64) {
669
+ try {
670
+ const decoded = Buffer.from(m, 'base64').toString('utf8');
671
+ if (SUSPICIOUS_DECODED.test(decoded)) {
672
+ return { detected: true, severity: 'high', reason: 'arguments contain base64 with suspicious decoded content' };
673
+ }
674
+ } catch (_) { /* ignore */ }
675
+ }
676
+ }
677
+ return { detected: false, severity: 'low', reason: 'clean' };
678
+ }
679
+
680
+ function evaluateCall(req) {
681
+ const start = Date.now();
682
+ const toolName = req.tool || (req.params && req.params.name) || '';
683
+ const args = req.arguments || (req.params && req.params.arguments) || {};
684
+ const scan = scanCall(toolName, args);
685
+ const score = {
686
+ tool: toolName,
687
+ call_scan: scan,
688
+ reasons: [],
689
+ decision: 'allow',
690
+ };
691
+ if (scan.detected) {
692
+ score.reasons.push(`${scan.severity}: ${scan.reason}`);
693
+ if (scan.severity === 'critical') score.decision = 'block';
694
+ else score.decision = 'warn';
695
+ }
696
+ score.durationMs = Date.now() - start;
697
+ return score;
698
+ }
699
+
700
+ module.exports = { evaluate, evaluateCall, scanTools, scanCall, scanSkillManifest, verifyPublisher, verifyPublisherAsync, validateTransport, levenshtein };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-proof",
3
- "version": "0.3.6",
4
- "description": "Unbranded, free MCP (Model Context Protocol) server verifier — detects tool poisoning, typosquats, hidden unicode, base64 payloads, exposed transports, and skill-manifest attacks. Same engine as the VyriAI control plane, no account required.",
3
+ "version": "0.3.8",
4
+ "description": "Unbranded, free MCP (Model Context Protocol) server verifier with local Markdown Trust Cards and SARIF output — detects tool poisoning, typosquats, hidden unicode, base64 payloads, exposed transports, and skill-manifest attacks.",
5
5
  "main": "engine.js",
6
6
  "bin": {
7
7
  "mcp-proof": "bin/mcp-proof.js"