@baichen_yu/mcp-guard 0.3.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/RELEASE.md +21 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +257 -0
  6. package/dist/mcp/jsonrpc.d.ts +23 -0
  7. package/dist/mcp/jsonrpc.js +124 -0
  8. package/dist/mcp/transport_stdio.d.ts +10 -0
  9. package/dist/mcp/transport_stdio.js +69 -0
  10. package/dist/mcp/types.d.ts +75 -0
  11. package/dist/mcp/types.js +1 -0
  12. package/dist/registry/validator.d.ts +20 -0
  13. package/dist/registry/validator.js +98 -0
  14. package/dist/report/json.d.ts +1 -0
  15. package/dist/report/json.js +8 -0
  16. package/dist/report/markdown.d.ts +2 -0
  17. package/dist/report/markdown.js +61 -0
  18. package/dist/report/sarif.d.ts +2 -0
  19. package/dist/report/sarif.js +33 -0
  20. package/dist/scan/scanner.d.ts +16 -0
  21. package/dist/scan/scanner.js +86 -0
  22. package/dist/security/profiles.d.ts +6 -0
  23. package/dist/security/profiles.js +25 -0
  24. package/dist/security/rules/path_traversal.d.ts +2 -0
  25. package/dist/security/rules/path_traversal.js +24 -0
  26. package/dist/security/rules/raw_args.d.ts +2 -0
  27. package/dist/security/rules/raw_args.js +24 -0
  28. package/dist/security/rules/shell_injection.d.ts +2 -0
  29. package/dist/security/rules/shell_injection.js +23 -0
  30. package/dist/security/scorer.d.ts +5 -0
  31. package/dist/security/scorer.js +4 -0
  32. package/dist/tests/contract/call_tool.d.ts +2 -0
  33. package/dist/tests/contract/call_tool.js +14 -0
  34. package/dist/tests/contract/cancellation.d.ts +2 -0
  35. package/dist/tests/contract/cancellation.js +22 -0
  36. package/dist/tests/contract/error_shapes.d.ts +2 -0
  37. package/dist/tests/contract/error_shapes.js +25 -0
  38. package/dist/tests/contract/large_payload.d.ts +2 -0
  39. package/dist/tests/contract/large_payload.js +15 -0
  40. package/dist/tests/contract/list_tools.d.ts +5 -0
  41. package/dist/tests/contract/list_tools.js +14 -0
  42. package/dist/tests/contract/timeout.d.ts +2 -0
  43. package/dist/tests/contract/timeout.js +25 -0
  44. package/dist/validate/schema_lints.d.ts +2 -0
  45. package/dist/validate/schema_lints.js +65 -0
  46. package/docs/assets/demo.gif +1 -0
  47. package/docs/cli.md +38 -0
  48. package/docs/github-action.md +23 -0
  49. package/docs/index.md +75 -0
  50. package/docs/quickstart.md +34 -0
  51. package/docs/releasing.md +39 -0
  52. package/docs/rules.md +18 -0
  53. package/docs/security-model.md +14 -0
  54. package/package.json +64 -0
@@ -0,0 +1,75 @@
1
+ export type JsonRpcId = number;
2
+ export interface JsonRpcRequest {
3
+ jsonrpc: '2.0';
4
+ id: JsonRpcId;
5
+ method: string;
6
+ params?: unknown;
7
+ }
8
+ export interface JsonRpcSuccess {
9
+ jsonrpc: '2.0';
10
+ id: JsonRpcId;
11
+ result: unknown;
12
+ }
13
+ export interface JsonRpcErrorShape {
14
+ code: number;
15
+ message: string;
16
+ data?: unknown;
17
+ }
18
+ export interface JsonRpcError {
19
+ jsonrpc: '2.0';
20
+ id: JsonRpcId;
21
+ error: JsonRpcErrorShape;
22
+ }
23
+ export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;
24
+ export interface ToolDescriptor {
25
+ name: string;
26
+ description?: string;
27
+ inputSchema: Record<string, unknown>;
28
+ }
29
+ export interface ServerConfig {
30
+ stdioCommand?: string;
31
+ httpUrl?: string;
32
+ cwd?: string;
33
+ env?: Record<string, string>;
34
+ timeoutMs?: number;
35
+ silent?: boolean;
36
+ }
37
+ export type Severity = 'low' | 'medium' | 'high';
38
+ export interface Finding {
39
+ severity: Severity;
40
+ ruleId: string;
41
+ message: string;
42
+ evidence: string;
43
+ remediation: string;
44
+ toolName?: string;
45
+ }
46
+ export interface TestResult {
47
+ name: string;
48
+ passed: boolean;
49
+ durationMs: number;
50
+ details?: string;
51
+ }
52
+ export interface Report {
53
+ server: {
54
+ target: string;
55
+ transport: 'stdio' | 'http';
56
+ protocolVersion?: string;
57
+ };
58
+ tools: ToolDescriptor[];
59
+ findings: Finding[];
60
+ tests: TestResult[];
61
+ score: number;
62
+ scoreBreakdown: string[];
63
+ generatedAt: string;
64
+ }
65
+ export interface NormalizedError {
66
+ code: number;
67
+ message: string;
68
+ data?: unknown;
69
+ }
70
+ export interface RpcClient {
71
+ request<T>(method: string, params?: unknown, timeoutOverrideMs?: number): Promise<T>;
72
+ normalizeError(error: unknown): NormalizedError;
73
+ close(): Promise<void>;
74
+ }
75
+ export type Profile = 'default' | 'strict' | 'paranoid';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ export interface RegistryIssue {
2
+ severity: 'error' | 'warning';
3
+ message: string;
4
+ line: number;
5
+ }
6
+ export interface RegistryEntry {
7
+ name?: string;
8
+ repo?: string;
9
+ transport?: string;
10
+ install?: string;
11
+ run?: string;
12
+ tags?: string[];
13
+ permissions?: string[];
14
+ riskNotes?: string;
15
+ securityNotes?: string;
16
+ [key: string]: unknown;
17
+ }
18
+ export declare function parseRegistry(raw: string): RegistryEntry[];
19
+ export declare function lintRegistry(raw: string): RegistryIssue[];
20
+ export declare function verifyRegistry(raw: string, sample: number): RegistryIssue[];
@@ -0,0 +1,98 @@
1
+ import yaml from 'js-yaml';
2
+ function lineOf(raw, needle, fromLine = 1) {
3
+ const lines = raw.split('\n');
4
+ for (let i = Math.max(0, fromLine - 1); i < lines.length; i += 1) {
5
+ if (lines[i].includes(needle))
6
+ return i + 1;
7
+ }
8
+ return 1;
9
+ }
10
+ export function parseRegistry(raw) {
11
+ const parsed = yaml.load(raw);
12
+ return (parsed?.servers ?? []);
13
+ }
14
+ function validRepo(value) {
15
+ if (!value.includes(' ')) {
16
+ if (value.startsWith('http://') || value.startsWith('https://')) {
17
+ return /^https?:\/\/[^\s]+$/.test(value);
18
+ }
19
+ return true;
20
+ }
21
+ return false;
22
+ }
23
+ export function lintRegistry(raw) {
24
+ const entries = parseRegistry(raw);
25
+ const issues = [];
26
+ const seenNames = new Set();
27
+ const allowedFields = new Set(['name', 'repo', 'transport', 'install', 'run', 'tags', 'permissions', 'riskNotes', 'securityNotes']);
28
+ entries.forEach((entry, index) => {
29
+ const anchor = `- name: ${entry.name ?? ''}`;
30
+ const baseLine = lineOf(raw, anchor);
31
+ const required = ['name', 'repo', 'transport', 'install', 'run', 'tags', 'permissions'];
32
+ for (const field of required) {
33
+ if (!entry[field] || (Array.isArray(entry[field]) && entry[field].length === 0)) {
34
+ issues.push({
35
+ severity: 'error',
36
+ message: `Entry #${index + 1} missing required field: ${field}`,
37
+ line: lineOf(raw, `${String(field)}:`, baseLine)
38
+ });
39
+ }
40
+ }
41
+ if (typeof entry.name === 'string') {
42
+ if (seenNames.has(entry.name)) {
43
+ issues.push({
44
+ severity: 'error',
45
+ message: `Duplicate server name: ${entry.name}`,
46
+ line: baseLine
47
+ });
48
+ }
49
+ seenNames.add(entry.name);
50
+ }
51
+ if (typeof entry.repo === 'string' && !validRepo(entry.repo)) {
52
+ issues.push({
53
+ severity: 'error',
54
+ message: `Entry #${index + 1} has invalid repo value: ${entry.repo}`,
55
+ line: lineOf(raw, 'repo:', baseLine)
56
+ });
57
+ }
58
+ for (const key of Object.keys(entry)) {
59
+ if (!allowedFields.has(key)) {
60
+ issues.push({
61
+ severity: 'warning',
62
+ message: `Entry #${index + 1} has unknown field: ${key}`,
63
+ line: lineOf(raw, `${key}:`, baseLine)
64
+ });
65
+ }
66
+ }
67
+ if (entry.run && /(rm -rf|curl\s+.*\|\s*sh)/i.test(String(entry.run))) {
68
+ issues.push({
69
+ severity: 'warning',
70
+ message: `Entry #${index + 1} run command appears unsafe`,
71
+ line: lineOf(raw, 'run:', baseLine)
72
+ });
73
+ }
74
+ });
75
+ return issues;
76
+ }
77
+ export function verifyRegistry(raw, sample) {
78
+ const entries = parseRegistry(raw).slice(0, sample);
79
+ const issues = [];
80
+ entries.forEach((entry, index) => {
81
+ const baseLine = lineOf(raw, `- name: ${entry.name ?? ''}`);
82
+ if (!entry.riskNotes && !entry.securityNotes) {
83
+ issues.push({
84
+ severity: 'warning',
85
+ message: `Entry #${index + 1} should include riskNotes or securityNotes`,
86
+ line: baseLine
87
+ });
88
+ }
89
+ if (!entry.permissions || entry.permissions.length === 0) {
90
+ issues.push({
91
+ severity: 'warning',
92
+ message: `Entry #${index + 1} should declare permissions`,
93
+ line: lineOf(raw, 'permissions:', baseLine)
94
+ });
95
+ }
96
+ });
97
+ return issues;
98
+ }
@@ -0,0 +1 @@
1
+ export declare function writeJsonReport<T>(report: T, outDir: string, fileName?: string): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export async function writeJsonReport(report, outDir, fileName = 'report.json') {
4
+ await mkdir(outDir, { recursive: true });
5
+ const output = join(outDir, fileName);
6
+ await writeFile(output, JSON.stringify(report, null, 2), 'utf8');
7
+ return output;
8
+ }
@@ -0,0 +1,2 @@
1
+ import { Report } from '../mcp/types.js';
2
+ export declare function writeMarkdownReport(report: Report, outDir: string, fileName?: string): Promise<string>;
@@ -0,0 +1,61 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export async function writeMarkdownReport(report, outDir, fileName = 'report.md') {
4
+ await mkdir(outDir, { recursive: true });
5
+ const output = join(outDir, fileName);
6
+ const passedTests = report.tests.filter((t) => t.passed).length;
7
+ const passRate = report.tests.length === 0 ? 'n/a' : `${passedTests}/${report.tests.length}`;
8
+ const lines = [];
9
+ lines.push('# MCP Guard Report');
10
+ lines.push('');
11
+ lines.push('## Summary');
12
+ lines.push('');
13
+ lines.push(`- **Risk score:** ${report.score}/100`);
14
+ lines.push(`- **Key findings:** ${report.findings.length}`);
15
+ lines.push(`- **Contract tests:** ${passRate}`);
16
+ lines.push(`- **Generated:** ${report.generatedAt}`);
17
+ lines.push(`- **Target:** \`${report.server.target}\` (${report.server.transport})`);
18
+ lines.push('');
19
+ lines.push('## Tool Inventory');
20
+ lines.push('');
21
+ lines.push('| Name | Description |');
22
+ lines.push('| --- | --- |');
23
+ for (const tool of report.tools) {
24
+ lines.push(`| ${tool.name} | ${tool.description ?? ''} |`);
25
+ }
26
+ lines.push('');
27
+ lines.push('## Findings by Severity');
28
+ lines.push('');
29
+ for (const severity of ['high', 'medium', 'low']) {
30
+ const group = report.findings.filter((finding) => finding.severity === severity);
31
+ lines.push(`### ${severity.toUpperCase()} (${group.length})`);
32
+ if (group.length === 0) {
33
+ lines.push('- None');
34
+ }
35
+ else {
36
+ for (const finding of group) {
37
+ lines.push(`- **${finding.ruleId}**: ${finding.message}`);
38
+ lines.push(` - Remediation: ${finding.remediation}`);
39
+ }
40
+ }
41
+ lines.push('');
42
+ }
43
+ lines.push('## Contract Test Results');
44
+ lines.push('');
45
+ for (const test of report.tests) {
46
+ lines.push(`- ${test.passed ? '✅' : '❌'} ${test.name} (${test.durationMs} ms): ${test.details ?? ''}`);
47
+ }
48
+ lines.push('');
49
+ lines.push('## Explain Score');
50
+ lines.push('');
51
+ if (report.scoreBreakdown.length === 0) {
52
+ lines.push('- No penalties applied.');
53
+ }
54
+ else {
55
+ for (const item of report.scoreBreakdown) {
56
+ lines.push(`- ${item}`);
57
+ }
58
+ }
59
+ await writeFile(output, `${lines.join('\n')}\n`, 'utf8');
60
+ return output;
61
+ }
@@ -0,0 +1,2 @@
1
+ import { Finding } from '../mcp/types.js';
2
+ export declare function writeSarif(findings: Finding[], outputFile: string): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ function levelForSeverity(severity) {
3
+ if (severity === 'high')
4
+ return 'error';
5
+ if (severity === 'medium')
6
+ return 'warning';
7
+ return 'note';
8
+ }
9
+ export async function writeSarif(findings, outputFile) {
10
+ const sarif = {
11
+ version: '2.1.0',
12
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
13
+ runs: [
14
+ {
15
+ tool: {
16
+ driver: {
17
+ name: 'mcp-guard',
18
+ rules: findings.map((finding) => ({
19
+ id: finding.ruleId,
20
+ shortDescription: { text: finding.message }
21
+ }))
22
+ }
23
+ },
24
+ results: findings.map((finding) => ({
25
+ ruleId: finding.ruleId,
26
+ level: levelForSeverity(finding.severity),
27
+ message: { text: `${finding.message} Remediation: ${finding.remediation}` }
28
+ }))
29
+ }
30
+ ]
31
+ };
32
+ await writeFile(outputFile, JSON.stringify(sarif, null, 2), 'utf8');
33
+ }
@@ -0,0 +1,16 @@
1
+ export interface DiscoveredServer {
2
+ source: 'claude_desktop' | 'cursor';
3
+ path: string;
4
+ name: string;
5
+ transport: 'stdio' | 'http' | 'unknown';
6
+ command?: string;
7
+ rawCommand?: string;
8
+ permissions?: string[];
9
+ reachable?: boolean;
10
+ findingsCount?: number;
11
+ }
12
+ export interface ScanResult {
13
+ scannedPaths: string[];
14
+ servers: DiscoveredServer[];
15
+ }
16
+ export declare function scanConfigs(repoPath?: string, directPath?: string): Promise<ScanResult>;
@@ -0,0 +1,86 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ const knownConfigNames = ['claude_desktop_config.json', 'claude_desktop_config.jsonc', 'cursor_mcp.json', '.cursor/mcp.json'];
4
+ function sortServers(servers) {
5
+ return [...servers].sort((a, b) => {
6
+ const bySource = a.source.localeCompare(b.source);
7
+ if (bySource !== 0)
8
+ return bySource;
9
+ const byName = a.name.localeCompare(b.name);
10
+ if (byName !== 0)
11
+ return byName;
12
+ return a.path.localeCompare(b.path);
13
+ });
14
+ }
15
+ function stripComments(content) {
16
+ return content.replace(/^\s*\/\/.*$/gm, '');
17
+ }
18
+ function redact(text) {
19
+ return text
20
+ .replace(/(token|api[_-]?key|secret)\s*[:=]\s*["']?[^\s"',}]+/gi, '$1=<redacted>')
21
+ .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer <redacted>');
22
+ }
23
+ function detectTransport(server) {
24
+ if (typeof server.command === 'string')
25
+ return 'stdio';
26
+ if (typeof server.url === 'string' || typeof server.httpUrl === 'string')
27
+ return 'http';
28
+ return 'unknown';
29
+ }
30
+ function extractServers(raw, source, path) {
31
+ const content = stripComments(raw);
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(content);
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const mcpServers = (parsed.mcpServers ?? parsed.servers ?? {});
40
+ return Object.entries(mcpServers).map(([name, value]) => ({
41
+ source,
42
+ path,
43
+ name,
44
+ transport: detectTransport(value),
45
+ rawCommand: typeof value.command === 'string' ? String(value.command) : undefined,
46
+ command: typeof value.command === 'string' ? redact(value.command) : undefined,
47
+ permissions: Array.isArray(value.permissions) ? value.permissions.map((p) => redact(String(p))) : []
48
+ }));
49
+ }
50
+ export async function scanConfigs(repoPath, directPath) {
51
+ const scannedPaths = [];
52
+ const servers = [];
53
+ if (directPath) {
54
+ const absolute = resolve(directPath);
55
+ const raw = await readFile(absolute, 'utf8');
56
+ scannedPaths.push(absolute);
57
+ const source = absolute.toLowerCase().includes('cursor') ? 'cursor' : 'claude_desktop';
58
+ servers.push(...extractServers(raw, source, absolute));
59
+ return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
60
+ }
61
+ if (!repoPath) {
62
+ return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
63
+ }
64
+ const root = resolve(repoPath);
65
+ const queue = [root];
66
+ while (queue.length > 0) {
67
+ const current = queue.shift();
68
+ const entries = await readdir(current, { withFileTypes: true });
69
+ for (const entry of entries) {
70
+ const fullPath = join(current, entry.name);
71
+ if (entry.isDirectory()) {
72
+ if (entry.name === 'node_modules' || entry.name === '.git')
73
+ continue;
74
+ queue.push(fullPath);
75
+ continue;
76
+ }
77
+ if (knownConfigNames.some((name) => fullPath.endsWith(name))) {
78
+ const raw = await readFile(fullPath, 'utf8');
79
+ scannedPaths.push(fullPath);
80
+ const source = fullPath.toLowerCase().includes('cursor') ? 'cursor' : 'claude_desktop';
81
+ servers.push(...extractServers(raw, source, fullPath));
82
+ }
83
+ }
84
+ }
85
+ return { scannedPaths: [...scannedPaths].sort(), servers: sortServers(servers) };
86
+ }
@@ -0,0 +1,6 @@
1
+ import { Finding, Profile } from '../mcp/types.js';
2
+ export declare function scoreFindings(findings: Finding[], profile: Profile): {
3
+ score: number;
4
+ breakdown: string[];
5
+ };
6
+ export declare function tuneFindingsForProfile(findings: Finding[], profile: Profile): Finding[];
@@ -0,0 +1,25 @@
1
+ const severityPenaltyByProfile = {
2
+ default: { low: 5, medium: 12, high: 25 },
3
+ strict: { low: 8, medium: 15, high: 28 },
4
+ paranoid: { low: 10, medium: 20, high: 40 }
5
+ };
6
+ export function scoreFindings(findings, profile) {
7
+ const weights = severityPenaltyByProfile[profile];
8
+ const penalties = findings.map((finding) => `${finding.ruleId} (${finding.severity}) -${weights[finding.severity]}`);
9
+ const total = findings.reduce((sum, finding) => sum + weights[finding.severity], 0);
10
+ return {
11
+ score: Math.max(0, 100 - total),
12
+ breakdown: penalties
13
+ };
14
+ }
15
+ export function tuneFindingsForProfile(findings, profile) {
16
+ if (profile === 'paranoid') {
17
+ return findings.map((finding) => {
18
+ if (finding.ruleId === 'security.shell_injection' || finding.ruleId === 'security.raw_args') {
19
+ return { ...finding, severity: 'high' };
20
+ }
21
+ return finding;
22
+ });
23
+ }
24
+ return findings;
25
+ }
@@ -0,0 +1,2 @@
1
+ import { Finding, ToolDescriptor } from '../../mcp/types.js';
2
+ export declare function pathTraversalRule(tools: ToolDescriptor[]): Finding[];
@@ -0,0 +1,24 @@
1
+ export function pathTraversalRule(tools) {
2
+ const findings = [];
3
+ for (const tool of tools) {
4
+ const schema = tool.inputSchema;
5
+ const properties = (schema.properties ?? {});
6
+ for (const [name, value] of Object.entries(properties)) {
7
+ if (!/(path|file)/i.test(name))
8
+ continue;
9
+ const prop = (value ?? {});
10
+ const hasConstraint = 'pattern' in prop || 'enum' in prop;
11
+ if (!hasConstraint) {
12
+ findings.push({
13
+ severity: 'high',
14
+ ruleId: 'security.path_traversal',
15
+ message: `Path-like parameter ${name} on tool ${tool.name} lacks constraints`,
16
+ evidence: JSON.stringify(prop),
17
+ remediation: 'Add strict pattern (allowlist base path) or enum constraints.',
18
+ toolName: tool.name
19
+ });
20
+ }
21
+ }
22
+ }
23
+ return findings;
24
+ }
@@ -0,0 +1,2 @@
1
+ import { Finding, ToolDescriptor } from '../../mcp/types.js';
2
+ export declare function rawArgsRule(tools: ToolDescriptor[]): Finding[];
@@ -0,0 +1,24 @@
1
+ export function rawArgsRule(tools) {
2
+ const findings = [];
3
+ for (const tool of tools) {
4
+ const schema = tool.inputSchema;
5
+ const properties = (schema.properties ?? {});
6
+ for (const [name, value] of Object.entries(properties)) {
7
+ if (!/(argv|flags)/i.test(name))
8
+ continue;
9
+ const prop = (value ?? {});
10
+ const items = (prop.items ?? {});
11
+ if (prop.type === 'array' && items.type === 'string' && !('enum' in items) && !('maxItems' in prop)) {
12
+ findings.push({
13
+ severity: 'medium',
14
+ ruleId: 'security.raw_args',
15
+ message: `Raw CLI args array ${name} on tool ${tool.name} is unbounded`,
16
+ evidence: JSON.stringify(prop),
17
+ remediation: 'Constrain allowed values and maxItems for argv/flags arrays.',
18
+ toolName: tool.name
19
+ });
20
+ }
21
+ }
22
+ }
23
+ return findings;
24
+ }
@@ -0,0 +1,2 @@
1
+ import { Finding, ToolDescriptor } from '../../mcp/types.js';
2
+ export declare function shellInjectionRule(tools: ToolDescriptor[]): Finding[];
@@ -0,0 +1,23 @@
1
+ export function shellInjectionRule(tools) {
2
+ const findings = [];
3
+ for (const tool of tools) {
4
+ const schema = tool.inputSchema;
5
+ const properties = (schema.properties ?? {});
6
+ for (const [name, value] of Object.entries(properties)) {
7
+ if (!/(args|command|shell)/i.test(name))
8
+ continue;
9
+ const prop = (value ?? {});
10
+ if (prop.type === 'string' && !('enum' in prop)) {
11
+ findings.push({
12
+ severity: 'high',
13
+ ruleId: 'security.shell_injection',
14
+ message: `Potential command injection param ${name} on tool ${tool.name}`,
15
+ evidence: JSON.stringify(prop),
16
+ remediation: 'Use enum allowlists and never pass through raw shell commands.',
17
+ toolName: tool.name
18
+ });
19
+ }
20
+ }
21
+ }
22
+ return findings;
23
+ }
@@ -0,0 +1,5 @@
1
+ import { Finding, Profile } from '../mcp/types.js';
2
+ export declare function computeRiskScore(findings: Finding[], profile: Profile): {
3
+ score: number;
4
+ breakdown: string[];
5
+ };
@@ -0,0 +1,4 @@
1
+ import { scoreFindings } from './profiles.js';
2
+ export function computeRiskScore(findings, profile) {
3
+ return scoreFindings(findings, profile);
4
+ }
@@ -0,0 +1,2 @@
1
+ import { RpcClient, TestResult } from '../../mcp/types.js';
2
+ export declare function runCallToolTest(client: RpcClient): Promise<TestResult>;
@@ -0,0 +1,14 @@
1
+ export async function runCallToolTest(client) {
2
+ const start = Date.now();
3
+ const response = await client.request('tools/call', {
4
+ name: 'hello',
5
+ arguments: { name: 'guard' }
6
+ });
7
+ const passed = typeof response.content === 'string' && response.content.includes('guard');
8
+ return {
9
+ name: 'call_tool',
10
+ passed,
11
+ durationMs: Date.now() - start,
12
+ details: passed ? response.content : 'Unexpected response format'
13
+ };
14
+ }
@@ -0,0 +1,2 @@
1
+ import { RpcClient, TestResult } from '../../mcp/types.js';
2
+ export declare function runCancellationTest(client: RpcClient): Promise<TestResult>;
@@ -0,0 +1,22 @@
1
+ export async function runCancellationTest(client) {
2
+ const start = Date.now();
3
+ try {
4
+ const result = await client.request('tools/cancel', { requestId: 42 });
5
+ return {
6
+ name: 'cancellation_behavior',
7
+ passed: result.status === 'cancelled',
8
+ durationMs: Date.now() - start,
9
+ details: JSON.stringify(result)
10
+ };
11
+ }
12
+ catch (error) {
13
+ const normalized = client.normalizeError(error);
14
+ const isNotSupported = normalized.code === -32601 || /not supported/i.test(normalized.message);
15
+ return {
16
+ name: 'cancellation_behavior',
17
+ passed: isNotSupported,
18
+ durationMs: Date.now() - start,
19
+ details: isNotSupported ? 'Cancellation not supported by fixture server (accepted)' : JSON.stringify(normalized)
20
+ };
21
+ }
22
+ }
@@ -0,0 +1,2 @@
1
+ import { RpcClient, TestResult } from '../../mcp/types.js';
2
+ export declare function runErrorShapesTest(client: RpcClient): Promise<TestResult>;
@@ -0,0 +1,25 @@
1
+ export async function runErrorShapesTest(client) {
2
+ const start = Date.now();
3
+ try {
4
+ await client.request('tools/call', {
5
+ name: 'hello',
6
+ arguments: {}
7
+ });
8
+ return {
9
+ name: 'error_shapes',
10
+ passed: false,
11
+ durationMs: Date.now() - start,
12
+ details: 'Expected an error but call succeeded'
13
+ };
14
+ }
15
+ catch (error) {
16
+ const normalized = client.normalizeError(error);
17
+ const passed = Number.isInteger(normalized.code) && typeof normalized.message === 'string';
18
+ return {
19
+ name: 'error_shapes',
20
+ passed,
21
+ durationMs: Date.now() - start,
22
+ details: JSON.stringify(normalized)
23
+ };
24
+ }
25
+ }
@@ -0,0 +1,2 @@
1
+ import { RpcClient, TestResult } from '../../mcp/types.js';
2
+ export declare function runLargePayloadTest(client: RpcClient): Promise<TestResult>;
@@ -0,0 +1,15 @@
1
+ export async function runLargePayloadTest(client) {
2
+ const start = Date.now();
3
+ const text = 'x'.repeat(50_000);
4
+ const response = await client.request('tools/call', {
5
+ name: 'echo',
6
+ arguments: { text }
7
+ }, 3000);
8
+ const passed = response.content.length === text.length;
9
+ return {
10
+ name: 'large_payload',
11
+ passed,
12
+ durationMs: Date.now() - start,
13
+ details: `echoed_length=${response.content.length}`
14
+ };
15
+ }
@@ -0,0 +1,5 @@
1
+ import { RpcClient, ToolDescriptor, TestResult } from '../../mcp/types.js';
2
+ export declare function runListToolsTest(client: RpcClient): Promise<{
3
+ result: TestResult;
4
+ tools: ToolDescriptor[];
5
+ }>;