agent-mcp-guard 0.1.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "agent-mcp-guard",
3
+ "version": "0.1.0",
4
+ "description": "Open-source CLI scanner for risky MCP server and AI agent tool configuration.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mcp-guard": "bin/mcp-guard.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "scan:example": "node ./bin/mcp-guard.js scan --config examples/unsafe-claude_desktop_config.json --output examples/sample-report.md",
12
+ "release:check": "node ./scripts/release-check.js",
13
+ "launch:github": "node ./scripts/launch-github.js",
14
+ "publish:npm": "node ./scripts/publish-npm.js",
15
+ "start": "node ./bin/mcp-guard.js"
16
+ },
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "ai-agent",
24
+ "security",
25
+ "cli",
26
+ "scanner",
27
+ "devsecops"
28
+ ],
29
+ "author": "",
30
+ "license": "Apache-2.0",
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "README.md",
35
+ "LICENSE",
36
+ "docs",
37
+ "examples"
38
+ ]
39
+ }
package/src/cli.js ADDED
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { scan } from "./scan.js";
4
+ import { generateJsonReport, generateMarkdownReport, generateTextReport } from "./report.js";
5
+ import { compareSeverity, severityRank } from "./severity.js";
6
+
7
+ const VERSION = "0.1.0";
8
+
9
+ export async function runCli(argv, io) {
10
+ const args = argv.slice(2);
11
+ const command = args[0];
12
+
13
+ if (!command || command === "help" || command === "--help" || command === "-h") {
14
+ io.stdout.write(helpText());
15
+ return 0;
16
+ }
17
+
18
+ if (command === "version" || command === "--version" || command === "-v") {
19
+ io.stdout.write(`${VERSION}\n`);
20
+ return 0;
21
+ }
22
+
23
+ if (command !== "scan") {
24
+ io.stderr.write(`Unknown command: ${command}\n\n`);
25
+ io.stderr.write(helpText());
26
+ process.exitCode = 1;
27
+ return 1;
28
+ }
29
+
30
+ if (args.includes("--help") || args.includes("-h")) {
31
+ io.stdout.write(helpText());
32
+ return 0;
33
+ }
34
+
35
+ const options = parseScanArgs(args.slice(1), io.cwd);
36
+ const result = await scan({
37
+ cwd: options.cwd,
38
+ env: io.env,
39
+ configPaths: options.configPaths,
40
+ includeDefaults: options.includeDefaults,
41
+ toolVersion: VERSION
42
+ });
43
+
44
+ const report = renderReport(result, options.format);
45
+ if (options.outputPath) {
46
+ await fs.mkdir(path.dirname(options.outputPath), { recursive: true });
47
+ await fs.writeFile(options.outputPath, report, "utf8");
48
+ io.stdout.write(`Wrote ${options.format} report to ${options.outputPath}\n`);
49
+ io.stdout.write(generateTextReport(result));
50
+ } else {
51
+ io.stdout.write(report);
52
+ }
53
+
54
+ if (options.failOn !== "none" && shouldFail(result, options.failOn)) {
55
+ process.exitCode = 2;
56
+ return 2;
57
+ }
58
+
59
+ return 0;
60
+ }
61
+
62
+ function parseScanArgs(args, defaultCwd) {
63
+ const options = {
64
+ cwd: defaultCwd,
65
+ configPaths: [],
66
+ includeDefaults: true,
67
+ outputPath: "",
68
+ format: "text",
69
+ failOn: "none"
70
+ };
71
+
72
+ for (let index = 0; index < args.length; index += 1) {
73
+ const arg = args[index];
74
+ if (arg === "--config" || arg === "-c") {
75
+ options.configPaths.push(resolveInputPath(readValue(args, index, arg), options.cwd));
76
+ index += 1;
77
+ } else if (arg === "--output" || arg === "-o") {
78
+ options.outputPath = resolveInputPath(readValue(args, index, arg), options.cwd);
79
+ index += 1;
80
+ } else if (arg === "--format" || arg === "-f") {
81
+ options.format = readValue(args, index, arg);
82
+ index += 1;
83
+ if (!["text", "markdown", "json"].includes(options.format)) {
84
+ throw new Error("--format must be one of: text, markdown, json");
85
+ }
86
+ } else if (arg === "--fail-on") {
87
+ options.failOn = readValue(args, index, arg);
88
+ index += 1;
89
+ if (!["critical", "high", "medium", "low", "none"].includes(options.failOn)) {
90
+ throw new Error("--fail-on must be one of: critical, high, medium, low, none");
91
+ }
92
+ } else if (arg === "--cwd") {
93
+ options.cwd = path.resolve(readValue(args, index, arg));
94
+ index += 1;
95
+ } else if (arg === "--no-defaults") {
96
+ options.includeDefaults = false;
97
+ } else {
98
+ throw new Error(`Unknown scan option: ${arg}`);
99
+ }
100
+ }
101
+
102
+ return options;
103
+ }
104
+
105
+ function readValue(args, index, optionName) {
106
+ const value = args[index + 1];
107
+ if (!value || value.startsWith("--")) {
108
+ throw new Error(`${optionName} requires a value`);
109
+ }
110
+ return value;
111
+ }
112
+
113
+ function resolveInputPath(value, cwd) {
114
+ return path.isAbsolute(value) ? value : path.resolve(cwd, value);
115
+ }
116
+
117
+ function renderReport(result, format) {
118
+ if (format === "json") {
119
+ return `${generateJsonReport(result)}\n`;
120
+ }
121
+ if (format === "markdown") {
122
+ return generateMarkdownReport(result);
123
+ }
124
+ return generateTextReport(result);
125
+ }
126
+
127
+ function shouldFail(result, failOn) {
128
+ const threshold = severityRank(failOn);
129
+ return result.findings.some((finding) => compareSeverity(finding.severity, threshold) >= 0);
130
+ }
131
+
132
+ function helpText() {
133
+ return `mcp-guard ${VERSION}
134
+
135
+ Open-source scanner for risky MCP server and AI agent tool configuration.
136
+
137
+ Usage:
138
+ mcp-guard scan [options]
139
+ mcp-guard version
140
+ mcp-guard help
141
+
142
+ Scan options:
143
+ -c, --config <path> Scan a specific MCP config file. Can be repeated.
144
+ -o, --output <path> Write report to a file.
145
+ -f, --format <format> text, markdown, or json. Default: text.
146
+ --fail-on <severity> Exit 2 when finding severity is at least threshold.
147
+ critical, high, medium, low, none. Default: none.
148
+ --cwd <path> Working directory for project config discovery.
149
+ --no-defaults Only scan paths passed with --config.
150
+
151
+ Examples:
152
+ mcp-guard scan
153
+ mcp-guard scan --format markdown --output mcp-guard-report.md
154
+ mcp-guard scan --config .mcp.json --fail-on high
155
+ `;
156
+ }
package/src/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ export async function loadConfigFile(filePath) {
4
+ const content = await fs.readFile(filePath, "utf8");
5
+ try {
6
+ return JSON.parse(content);
7
+ } catch (error) {
8
+ const message = error instanceof Error ? error.message : String(error);
9
+ throw new Error(`Invalid JSON: ${message}`);
10
+ }
11
+ }
12
+
13
+ export function extractServers(config, configPath) {
14
+ const serversBlock = config?.mcpServers ?? config?.servers;
15
+ if (!serversBlock || typeof serversBlock !== "object" || Array.isArray(serversBlock)) {
16
+ return [];
17
+ }
18
+
19
+ return Object.entries(serversBlock).map(([name, raw]) => normalizeServer(name, raw, configPath));
20
+ }
21
+
22
+ function normalizeServer(name, raw, configPath) {
23
+ const server = raw && typeof raw === "object" ? raw : {};
24
+ return {
25
+ name,
26
+ configPath,
27
+ command: normalizeString(server.command),
28
+ args: normalizeArgs(server.args),
29
+ env: normalizeEnv(server.env),
30
+ cwd: normalizeString(server.cwd),
31
+ url: normalizeString(server.url),
32
+ headers: normalizeEnv(server.headers),
33
+ raw: server
34
+ };
35
+ }
36
+
37
+ function normalizeString(value) {
38
+ return typeof value === "string" ? value : "";
39
+ }
40
+
41
+ function normalizeArgs(value) {
42
+ if (Array.isArray(value)) {
43
+ return value.map((item) => String(item));
44
+ }
45
+ if (typeof value === "string") {
46
+ return [value];
47
+ }
48
+ return [];
49
+ }
50
+
51
+ function normalizeEnv(value) {
52
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
53
+ return {};
54
+ }
55
+
56
+ return Object.fromEntries(
57
+ Object.entries(value).map(([key, item]) => [key, item == null ? "" : String(item)])
58
+ );
59
+ }
60
+
@@ -0,0 +1,39 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export async function discoverConfigFiles({ cwd, env }) {
6
+ const home = env.HOME || env.USERPROFILE || os.homedir();
7
+ const candidates = uniquePaths([
8
+ path.join(cwd, ".mcp.json"),
9
+ path.join(cwd, "mcp.json"),
10
+ path.join(cwd, ".cursor", "mcp.json"),
11
+ path.join(home, ".cursor", "mcp.json"),
12
+ path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"),
13
+ path.join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json"),
14
+ path.join(home, ".config", "Claude", "claude_desktop_config.json"),
15
+ path.join(home, ".config", "claude", "claude_desktop_config.json")
16
+ ]);
17
+
18
+ const found = [];
19
+ for (const candidate of candidates) {
20
+ if (await isReadableFile(candidate)) {
21
+ found.push(candidate);
22
+ }
23
+ }
24
+ return found;
25
+ }
26
+
27
+ async function isReadableFile(filePath) {
28
+ try {
29
+ const stat = await fs.stat(filePath);
30
+ return stat.isFile();
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function uniquePaths(paths) {
37
+ return [...new Set(paths.map((item) => path.resolve(item)))];
38
+ }
39
+
package/src/redact.js ADDED
@@ -0,0 +1,28 @@
1
+ const SECRET_NAME_PATTERN = /(api[_-]?key|token|secret|password|passwd|private[_-]?key|client[_-]?secret|access[_-]?key|auth|credential|session|jwt|bearer|oauth)/i;
2
+
3
+ export function isSecretLikeName(name) {
4
+ return SECRET_NAME_PATTERN.test(name);
5
+ }
6
+
7
+ export function redactValue(value) {
8
+ if (!value) return "<empty>";
9
+ const text = String(value);
10
+ if (looksLikeVariableReference(text)) {
11
+ return text;
12
+ }
13
+ if (text.length <= 8) {
14
+ return "<redacted>";
15
+ }
16
+ return `${text.slice(0, 3)}...${text.slice(-3)} (${text.length} chars)`;
17
+ }
18
+
19
+ export function redactEnv(env) {
20
+ return Object.fromEntries(
21
+ Object.entries(env).map(([key, value]) => [key, isSecretLikeName(key) ? redactValue(value) : "<set>"])
22
+ );
23
+ }
24
+
25
+ function looksLikeVariableReference(value) {
26
+ return /^\$\{?[A-Z0-9_]+\}?$/i.test(value);
27
+ }
28
+
package/src/report.js ADDED
@@ -0,0 +1,136 @@
1
+ import { redactEnv } from "./redact.js";
2
+
3
+ export function generateTextReport(result) {
4
+ const lines = [];
5
+ lines.push("mcp-guard scan report");
6
+ lines.push(`Generated: ${result.metadata.generatedAt}`);
7
+ lines.push(`Scanned files: ${result.summary.scannedFileCount}`);
8
+ lines.push(`MCP servers: ${result.summary.serverCount}`);
9
+ lines.push(`Findings: ${result.summary.findingCount}`);
10
+ lines.push(`Risk score: ${result.summary.riskScore}`);
11
+ lines.push(`Critical: ${result.summary.counts.critical} High: ${result.summary.counts.high} Medium: ${result.summary.counts.medium} Low: ${result.summary.counts.low}`);
12
+ lines.push("");
13
+
14
+ if (result.scannedFiles.length > 0) {
15
+ lines.push("Scanned config files:");
16
+ for (const file of result.scannedFiles) {
17
+ lines.push(`- ${displayPath(file, result.metadata.cwd)}`);
18
+ }
19
+ lines.push("");
20
+ }
21
+
22
+ if (result.findings.length === 0) {
23
+ lines.push("No findings.");
24
+ return `${lines.join("\n")}\n`;
25
+ }
26
+
27
+ lines.push("Findings:");
28
+ for (const finding of result.findings) {
29
+ lines.push(`- [${finding.severity.toUpperCase()}] ${finding.id} ${finding.title}`);
30
+ lines.push(` Server: ${finding.serverName}`);
31
+ lines.push(` Evidence: ${finding.evidence}`);
32
+ lines.push(` Fix: ${finding.recommendation}`);
33
+ }
34
+
35
+ return `${lines.join("\n")}\n`;
36
+ }
37
+
38
+ export function generateMarkdownReport(result) {
39
+ const lines = [];
40
+ lines.push("# mcp-guard Scan Report");
41
+ lines.push("");
42
+ lines.push(`Generated: ${result.metadata.generatedAt}`);
43
+ lines.push("");
44
+ lines.push("## Summary");
45
+ lines.push("");
46
+ lines.push(`- Scanned files: ${result.summary.scannedFileCount}`);
47
+ lines.push(`- MCP servers: ${result.summary.serverCount}`);
48
+ lines.push(`- Findings: ${result.summary.findingCount}`);
49
+ lines.push(`- Risk score: ${result.summary.riskScore}`);
50
+ lines.push(`- Critical: ${result.summary.counts.critical}`);
51
+ lines.push(`- High: ${result.summary.counts.high}`);
52
+ lines.push(`- Medium: ${result.summary.counts.medium}`);
53
+ lines.push(`- Low: ${result.summary.counts.low}`);
54
+ lines.push("");
55
+
56
+ lines.push("## Scanned Files");
57
+ lines.push("");
58
+ if (result.scannedFiles.length === 0) {
59
+ lines.push("- None found");
60
+ } else {
61
+ for (const file of result.scannedFiles) {
62
+ lines.push(`- \`${displayPath(file, result.metadata.cwd)}\``);
63
+ }
64
+ }
65
+ lines.push("");
66
+
67
+ lines.push("## MCP Server Inventory");
68
+ lines.push("");
69
+ if (result.servers.length === 0) {
70
+ lines.push("- No MCP servers found.");
71
+ } else {
72
+ lines.push("| Server | Command | Args | CWD | URL | Env |");
73
+ lines.push("| --- | --- | --- | --- | --- | --- |");
74
+ for (const server of result.servers) {
75
+ const env = Object.entries(redactEnv(server.env)).map(([key, value]) => `${key}=${value}`).join("<br>");
76
+ lines.push(`| ${cell(server.name)} | ${cell(server.command || "-")} | ${cell(server.args.join(" ") || "-")} | ${cell(server.cwd || "-")} | ${cell(server.url || "-")} | ${cell(env || "-")} |`);
77
+ }
78
+ }
79
+ lines.push("");
80
+
81
+ lines.push("## Findings");
82
+ lines.push("");
83
+ if (result.findings.length === 0) {
84
+ lines.push("No findings.");
85
+ } else {
86
+ lines.push("| Severity | Rule | Server | Finding | Evidence | Recommendation |");
87
+ lines.push("| --- | --- | --- | --- | --- | --- |");
88
+ for (const finding of result.findings) {
89
+ lines.push(`| ${cell(finding.severity)} | ${cell(finding.id)} | ${cell(finding.serverName)} | ${cell(finding.title)} | ${cell(finding.evidence)} | ${cell(finding.recommendation)} |`);
90
+ }
91
+ }
92
+ lines.push("");
93
+
94
+ lines.push("## Notes");
95
+ lines.push("");
96
+ lines.push("- This report is an assistive security review, not a guarantee that all issues were found.");
97
+ lines.push("- Secret-like values are redacted by default.");
98
+ lines.push("- Review each MCP server before granting access to files, shells, SaaS accounts, or production systems.");
99
+ lines.push("");
100
+
101
+ return `${lines.join("\n")}\n`;
102
+ }
103
+
104
+ export function generateJsonReport(result) {
105
+ return JSON.stringify(sanitizeResult(result), null, 2);
106
+ }
107
+
108
+ function cell(value) {
109
+ return String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
110
+ }
111
+
112
+ function displayPath(filePath, cwd) {
113
+ if (!filePath || !cwd) return filePath;
114
+ if (filePath === cwd) return ".";
115
+ if (filePath.startsWith(`${cwd}/`)) return filePath.slice(cwd.length + 1);
116
+ return filePath;
117
+ }
118
+
119
+ function sanitizeResult(result) {
120
+ return {
121
+ metadata: result.metadata,
122
+ scannedFiles: result.scannedFiles,
123
+ servers: result.servers.map((server) => ({
124
+ name: server.name,
125
+ configPath: server.configPath,
126
+ command: server.command,
127
+ args: server.args,
128
+ env: redactEnv(server.env),
129
+ cwd: server.cwd,
130
+ url: server.url,
131
+ headers: redactEnv(server.headers)
132
+ })),
133
+ findings: result.findings,
134
+ summary: result.summary
135
+ };
136
+ }