agent-mcp-guard 0.1.1 → 0.2.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/README.md CHANGED
@@ -14,7 +14,7 @@ Website: [chaoyue0307.github.io/mcp-guard](https://chaoyue0307.github.io/mcp-gua
14
14
  <a href="https://www.npmjs.com/package/agent-mcp-guard"><img alt="npm version" src="https://img.shields.io/npm/v/agent-mcp-guard?color=0f766e"></a>
15
15
  <a href="https://github.com/ChaoYue0307/mcp-guard/actions"><img alt="CI" src="https://github.com/ChaoYue0307/mcp-guard/actions/workflows/ci.yml/badge.svg"></a>
16
16
  <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-111827"></a>
17
- <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.1.1"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
17
+ <a href="https://github.com/ChaoYue0307/mcp-guard/releases/tag/v0.2.0"><img alt="Release" src="https://img.shields.io/github/v/release/ChaoYue0307/mcp-guard?color=7c2d12"></a>
18
18
  </p>
19
19
 
20
20
  ## Install
@@ -36,12 +36,26 @@ Generate a Markdown report:
36
36
  mcp-guard scan --format markdown --output mcp-guard-report.md
37
37
  ```
38
38
 
39
+ Generate an HTML report:
40
+
41
+ ```bash
42
+ mcp-guard scan --format html --output mcp-guard-report.html
43
+ ```
44
+
39
45
  Use in CI:
40
46
 
41
47
  ```bash
42
48
  mcp-guard scan --config .mcp.json --fail-on high
43
49
  ```
44
50
 
51
+ Use the GitHub Action:
52
+
53
+ ```yaml
54
+ - uses: ChaoYue0307/mcp-guard@v0.2.0
55
+ with:
56
+ fail-on: high
57
+ ```
58
+
45
59
  ## What It Finds
46
60
 
47
61
  | Risk | Why it matters |
@@ -98,7 +112,7 @@ MCP configs often contain sensitive local paths, internal hostnames, tokens, and
98
112
  - no config upload;
99
113
  - no external API call;
100
114
  - secret-like values redacted in reports;
101
- - text, Markdown, and JSON output for local review and CI.
115
+ - text, Markdown, HTML, and JSON output for local review and CI.
102
116
 
103
117
  ## Commercial Support
104
118
 
@@ -113,6 +127,7 @@ Service details: [docs/paid-audit.md](docs/paid-audit.md)
113
127
  ## Documentation
114
128
 
115
129
  - [Rule reference](docs/rules.md)
130
+ - [GitHub Action](docs/github-action.md)
116
131
  - [Privacy and security](docs/privacy-and-security.md)
117
132
  - [Roadmap](docs/roadmap.md)
118
133
  - [Business playbook](docs/business-playbook.md)
package/action.yml ADDED
@@ -0,0 +1,111 @@
1
+ name: mcp-guard
2
+ description: Scan MCP and AI agent tool configuration for risky commands, secrets, and broad permissions.
3
+ author: mcp-guard
4
+
5
+ branding:
6
+ icon: shield
7
+ color: green
8
+
9
+ inputs:
10
+ config:
11
+ description: Optional MCP config path to scan. Leave empty to scan default project and user config locations.
12
+ required: false
13
+ default: ""
14
+ fail-on:
15
+ description: Fail the workflow when a finding is at least this severity. Use critical, high, medium, low, or none.
16
+ required: false
17
+ default: high
18
+ output-dir:
19
+ description: Directory where reports will be written.
20
+ required: false
21
+ default: mcp-guard-report
22
+ package-version:
23
+ description: npm package version to install.
24
+ required: false
25
+ default: latest
26
+ upload-artifact:
27
+ description: Upload generated reports as a workflow artifact.
28
+ required: false
29
+ default: "true"
30
+ artifact-name:
31
+ description: Artifact name for generated reports.
32
+ required: false
33
+ default: mcp-guard-report
34
+
35
+ outputs:
36
+ markdown-report:
37
+ description: Path to the generated Markdown report.
38
+ value: ${{ steps.reports.outputs.markdown-report }}
39
+ html-report:
40
+ description: Path to the generated HTML report.
41
+ value: ${{ steps.reports.outputs.html-report }}
42
+ json-report:
43
+ description: Path to the generated JSON report.
44
+ value: ${{ steps.reports.outputs.json-report }}
45
+ exit-code:
46
+ description: mcp-guard threshold exit code.
47
+ value: ${{ steps.reports.outputs.exit-code }}
48
+
49
+ runs:
50
+ using: composite
51
+ steps:
52
+ - name: Set up Node.js
53
+ uses: actions/setup-node@v4
54
+ with:
55
+ node-version: "20"
56
+
57
+ - name: Install mcp-guard
58
+ shell: bash
59
+ env:
60
+ MCP_GUARD_PACKAGE_VERSION: ${{ inputs.package-version }}
61
+ run: npm install --global "agent-mcp-guard@${MCP_GUARD_PACKAGE_VERSION}"
62
+
63
+ - name: Generate reports
64
+ id: reports
65
+ shell: bash
66
+ env:
67
+ MCP_GUARD_CONFIG: ${{ inputs.config }}
68
+ MCP_GUARD_FAIL_ON: ${{ inputs.fail-on }}
69
+ MCP_GUARD_OUTPUT_DIR: ${{ inputs.output-dir }}
70
+ run: |
71
+ set -euo pipefail
72
+
73
+ mkdir -p "${MCP_GUARD_OUTPUT_DIR}"
74
+
75
+ scan_args=()
76
+ if [ -n "${MCP_GUARD_CONFIG}" ]; then
77
+ scan_args+=(--config "${MCP_GUARD_CONFIG}")
78
+ fi
79
+
80
+ markdown_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.md"
81
+ html_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.html"
82
+ json_report="${MCP_GUARD_OUTPUT_DIR}/mcp-guard-report.json"
83
+
84
+ mcp-guard scan "${scan_args[@]}" --format markdown --output "${markdown_report}" --fail-on none
85
+ mcp-guard scan "${scan_args[@]}" --format html --output "${html_report}" --fail-on none
86
+ mcp-guard scan "${scan_args[@]}" --format json --output "${json_report}" --fail-on none
87
+
88
+ set +e
89
+ mcp-guard scan "${scan_args[@]}" --fail-on "${MCP_GUARD_FAIL_ON}"
90
+ status="$?"
91
+ set -e
92
+
93
+ {
94
+ echo "markdown-report=${markdown_report}"
95
+ echo "html-report=${html_report}"
96
+ echo "json-report=${json_report}"
97
+ echo "exit-code=${status}"
98
+ } >> "${GITHUB_OUTPUT}"
99
+
100
+ - name: Upload report artifact
101
+ if: ${{ always() && inputs.upload-artifact == 'true' }}
102
+ uses: actions/upload-artifact@v4
103
+ with:
104
+ name: ${{ inputs.artifact-name }}
105
+ path: ${{ inputs.output-dir }}
106
+
107
+ - name: Enforce severity threshold
108
+ shell: bash
109
+ env:
110
+ MCP_GUARD_EXIT_CODE: ${{ steps.reports.outputs.exit-code }}
111
+ run: exit "${MCP_GUARD_EXIT_CODE}"
@@ -13,9 +13,10 @@ AI Agent/MCP Security Audit.
13
13
  Deliverables:
14
14
 
15
15
  - MCP server inventory;
16
- - `mcp-guard` scan report;
16
+ - `mcp-guard` Markdown, HTML, and JSON scan reports;
17
17
  - manual review of high-risk findings;
18
18
  - prioritized remediation plan;
19
+ - optional GitHub Action setup for continuous scans;
19
20
  - 60-minute hardening call;
20
21
  - optional PR with safer config changes.
21
22
 
@@ -61,4 +62,3 @@ Weak:
61
62
  - vague security interest;
62
63
  - requests for a full dashboard before any audit;
63
64
  - only free users with toy configs.
64
-
@@ -0,0 +1,68 @@
1
+ # GitHub Action
2
+
3
+ Use the `mcp-guard` action to scan MCP and AI agent tool configuration in pull requests and CI.
4
+
5
+ The action installs the published npm package, generates Markdown, HTML, and JSON reports, uploads them as a workflow artifact, then fails the job when findings meet your selected severity threshold.
6
+
7
+ ## Basic Workflow
8
+
9
+ ```yaml
10
+ name: mcp-guard
11
+
12
+ on:
13
+ pull_request:
14
+ push:
15
+ branches: [main]
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ scan:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: ChaoYue0307/mcp-guard@v0.2.0
26
+ with:
27
+ fail-on: high
28
+ ```
29
+
30
+ ## Scan a Specific Config
31
+
32
+ ```yaml
33
+ - uses: ChaoYue0307/mcp-guard@v0.2.0
34
+ with:
35
+ config: .mcp.json
36
+ fail-on: medium
37
+ ```
38
+
39
+ ## Pin the npm Package
40
+
41
+ The action defaults to `agent-mcp-guard@latest`. Pin it when you want deterministic CI behavior:
42
+
43
+ ```yaml
44
+ - uses: ChaoYue0307/mcp-guard@v0.2.0
45
+ with:
46
+ package-version: 0.2.0
47
+ fail-on: high
48
+ ```
49
+
50
+ ## Inputs
51
+
52
+ | Input | Default | Description |
53
+ | --- | --- | --- |
54
+ | `config` | empty | Optional MCP config path. Empty scans default project and user config locations. |
55
+ | `fail-on` | `high` | Fails the job for `critical`, `high`, `medium`, or `low` findings. Use `none` for report-only mode. |
56
+ | `output-dir` | `mcp-guard-report` | Directory for generated reports. |
57
+ | `package-version` | `latest` | npm package version to install. |
58
+ | `upload-artifact` | `true` | Uploads generated reports as a workflow artifact. |
59
+ | `artifact-name` | `mcp-guard-report` | Name of the uploaded artifact. |
60
+
61
+ ## Outputs
62
+
63
+ | Output | Description |
64
+ | --- | --- |
65
+ | `markdown-report` | Path to the generated Markdown report. |
66
+ | `html-report` | Path to the generated HTML report. |
67
+ | `json-report` | Path to the generated JSON report. |
68
+ | `exit-code` | `0` when below threshold, `2` when findings met the threshold. |
package/docs/roadmap.md CHANGED
@@ -5,25 +5,24 @@
5
5
  ## Now
6
6
 
7
7
  - CLI config scanning.
8
- - Text, Markdown, and redacted JSON output.
8
+ - Text, Markdown, HTML, and redacted JSON output.
9
9
  - Rules for shell wrappers, remote package runners, unpinned packages, broad filesystem access, secret-like env vars/headers, and remote MCP URLs.
10
10
  - CI usage with `--fail-on`.
11
+ - GitHub Action wrapper that uploads Markdown, HTML, and JSON reports as artifacts.
11
12
 
12
13
  ## Next
13
14
 
14
- 1. GitHub Action wrapper.
15
- 2. HTML audit report.
16
- 3. More MCP client discovery paths.
17
- 4. Rule packs mapped to MCP security best practices.
18
- 5. `mcp-guard audit` mode for client-ready reports.
15
+ 1. More MCP client discovery paths.
16
+ 2. Rule packs mapped to MCP security best practices.
17
+ 3. `mcp-guard audit` mode for client-ready reports.
18
+ 4. Policy file for approved commands, packages, directories, and remote URLs.
19
+ 5. Baseline mode: accept known findings and fail only on new risks.
19
20
 
20
21
  ## Later
21
22
 
22
- 1. Policy file: approved commands, packages, directories, and remote URLs.
23
- 2. Baseline mode: accept known findings and fail only on new risks.
24
- 3. SBOM/package metadata checks for MCP server packages.
25
- 4. Local web report viewer.
26
- 5. Hosted team dashboard only after repeated paid audit demand.
23
+ 1. SBOM/package metadata checks for MCP server packages.
24
+ 2. Local web report viewer.
25
+ 3. Hosted team dashboard only after repeated paid audit demand.
27
26
 
28
27
  ## Product Principles
29
28
 
@@ -32,4 +31,3 @@
32
31
  - Avoid noisy rules that do not change behavior.
33
32
  - Prefer workflow integration over dashboards.
34
33
  - Services first, SaaS later.
35
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mcp-guard",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Open-source CLI scanner for risky MCP server and AI agent tool configuration.",
5
5
  "type": "module",
6
6
  "homepage": "https://chaoyue0307.github.io/mcp-guard/",
@@ -40,6 +40,7 @@
40
40
  "bin",
41
41
  "src",
42
42
  "README.md",
43
+ "action.yml",
43
44
  "LICENSE",
44
45
  "SECURITY.md",
45
46
  "docs",
package/src/cli.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { scan } from "./scan.js";
4
- import { generateJsonReport, generateMarkdownReport, generateTextReport } from "./report.js";
4
+ import { generateHtmlReport, generateJsonReport, generateMarkdownReport, generateTextReport } from "./report.js";
5
5
  import { compareSeverity, severityRank } from "./severity.js";
6
6
 
7
- const VERSION = "0.1.1";
7
+ const VERSION = "0.2.0";
8
8
 
9
9
  export async function runCli(argv, io) {
10
10
  const args = argv.slice(2);
@@ -80,8 +80,8 @@ function parseScanArgs(args, defaultCwd) {
80
80
  } else if (arg === "--format" || arg === "-f") {
81
81
  options.format = readValue(args, index, arg);
82
82
  index += 1;
83
- if (!["text", "markdown", "json"].includes(options.format)) {
84
- throw new Error("--format must be one of: text, markdown, json");
83
+ if (!["text", "markdown", "json", "html"].includes(options.format)) {
84
+ throw new Error("--format must be one of: text, markdown, json, html");
85
85
  }
86
86
  } else if (arg === "--fail-on") {
87
87
  options.failOn = readValue(args, index, arg);
@@ -121,6 +121,9 @@ function renderReport(result, format) {
121
121
  if (format === "markdown") {
122
122
  return generateMarkdownReport(result);
123
123
  }
124
+ if (format === "html") {
125
+ return generateHtmlReport(result);
126
+ }
124
127
  return generateTextReport(result);
125
128
  }
126
129
 
@@ -142,7 +145,7 @@ Usage:
142
145
  Scan options:
143
146
  -c, --config <path> Scan a specific MCP config file. Can be repeated.
144
147
  -o, --output <path> Write report to a file.
145
- -f, --format <format> text, markdown, or json. Default: text.
148
+ -f, --format <format> text, markdown, json, or html. Default: text.
146
149
  --fail-on <severity> Exit 2 when finding severity is at least threshold.
147
150
  critical, high, medium, low, none. Default: none.
148
151
  --cwd <path> Working directory for project config discovery.
@@ -151,6 +154,7 @@ Scan options:
151
154
  Examples:
152
155
  mcp-guard scan
153
156
  mcp-guard scan --format markdown --output mcp-guard-report.md
157
+ mcp-guard scan --format html --output mcp-guard-report.html
154
158
  mcp-guard scan --config .mcp.json --fail-on high
155
159
  `;
156
160
  }
package/src/report.js CHANGED
@@ -105,6 +105,362 @@ export function generateJsonReport(result) {
105
105
  return JSON.stringify(sanitizeResult(result), null, 2);
106
106
  }
107
107
 
108
+ export function generateHtmlReport(result) {
109
+ const safeResult = sanitizeResult(result);
110
+ const riskTone = riskToneForScore(safeResult.summary.riskScore);
111
+ const findings = safeResult.findings;
112
+ const servers = safeResult.servers;
113
+
114
+ return `<!doctype html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="utf-8">
118
+ <meta name="viewport" content="width=device-width, initial-scale=1">
119
+ <title>mcp-guard Scan Report</title>
120
+ <style>
121
+ :root {
122
+ color-scheme: light;
123
+ --bg: #f8fafc;
124
+ --panel: #ffffff;
125
+ --ink: #111827;
126
+ --muted: #5b6575;
127
+ --line: #d9e2ec;
128
+ --soft: #eef4f7;
129
+ --critical: #b91c1c;
130
+ --high: #c2410c;
131
+ --medium: #a16207;
132
+ --low: #0f766e;
133
+ --info: #1d4ed8;
134
+ --shadow: 0 20px 55px rgba(15, 23, 42, 0.08);
135
+ }
136
+
137
+ * { box-sizing: border-box; }
138
+
139
+ body {
140
+ margin: 0;
141
+ background: var(--bg);
142
+ color: var(--ink);
143
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
144
+ line-height: 1.5;
145
+ }
146
+
147
+ main {
148
+ width: min(1120px, calc(100% - 32px));
149
+ margin: 0 auto;
150
+ padding: 28px 0 48px;
151
+ }
152
+
153
+ .hero {
154
+ background: #ffffff;
155
+ border: 1px solid var(--line);
156
+ border-radius: 8px;
157
+ box-shadow: var(--shadow);
158
+ padding: 28px;
159
+ display: grid;
160
+ grid-template-columns: minmax(0, 1fr) 240px;
161
+ gap: 24px;
162
+ align-items: stretch;
163
+ }
164
+
165
+ .eyebrow {
166
+ margin: 0 0 8px;
167
+ color: var(--info);
168
+ font-size: 13px;
169
+ font-weight: 700;
170
+ letter-spacing: 0;
171
+ text-transform: uppercase;
172
+ }
173
+
174
+ h1, h2 {
175
+ letter-spacing: 0;
176
+ line-height: 1.08;
177
+ }
178
+
179
+ h1 {
180
+ margin: 0;
181
+ font-size: 34px;
182
+ }
183
+
184
+ h2 {
185
+ margin: 0 0 14px;
186
+ font-size: 21px;
187
+ }
188
+
189
+ .lead {
190
+ max-width: 720px;
191
+ margin: 12px 0 0;
192
+ color: var(--muted);
193
+ font-size: 16px;
194
+ }
195
+
196
+ .scorecard {
197
+ border-radius: 8px;
198
+ border: 1px solid var(--line);
199
+ background: var(--soft);
200
+ padding: 18px;
201
+ min-height: 176px;
202
+ display: flex;
203
+ flex-direction: column;
204
+ justify-content: space-between;
205
+ }
206
+
207
+ .score-label {
208
+ margin: 0;
209
+ color: var(--muted);
210
+ font-size: 13px;
211
+ font-weight: 700;
212
+ text-transform: uppercase;
213
+ }
214
+
215
+ .score-value {
216
+ margin: 8px 0;
217
+ font-size: 58px;
218
+ font-weight: 800;
219
+ line-height: 1;
220
+ color: var(--${riskTone});
221
+ }
222
+
223
+ .score-caption {
224
+ margin: 0;
225
+ color: var(--muted);
226
+ font-size: 14px;
227
+ }
228
+
229
+ .grid {
230
+ display: grid;
231
+ grid-template-columns: repeat(4, minmax(0, 1fr));
232
+ gap: 12px;
233
+ margin: 18px 0 0;
234
+ }
235
+
236
+ .metric {
237
+ background: var(--panel);
238
+ border: 1px solid var(--line);
239
+ border-radius: 8px;
240
+ padding: 14px;
241
+ }
242
+
243
+ .metric strong {
244
+ display: block;
245
+ font-size: 24px;
246
+ line-height: 1.1;
247
+ }
248
+
249
+ .metric span {
250
+ display: block;
251
+ margin-top: 6px;
252
+ color: var(--muted);
253
+ font-size: 13px;
254
+ }
255
+
256
+ section {
257
+ margin-top: 22px;
258
+ background: var(--panel);
259
+ border: 1px solid var(--line);
260
+ border-radius: 8px;
261
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04);
262
+ padding: 22px;
263
+ }
264
+
265
+ .severity-row {
266
+ display: grid;
267
+ grid-template-columns: repeat(4, minmax(0, 1fr));
268
+ gap: 10px;
269
+ }
270
+
271
+ .severity {
272
+ border: 1px solid var(--line);
273
+ border-radius: 8px;
274
+ padding: 12px;
275
+ min-height: 78px;
276
+ }
277
+
278
+ .severity b {
279
+ display: block;
280
+ font-size: 22px;
281
+ line-height: 1.1;
282
+ }
283
+
284
+ .severity span {
285
+ display: block;
286
+ margin-top: 6px;
287
+ color: var(--muted);
288
+ font-size: 13px;
289
+ text-transform: capitalize;
290
+ }
291
+
292
+ .critical { color: var(--critical); }
293
+ .high { color: var(--high); }
294
+ .medium { color: var(--medium); }
295
+ .low { color: var(--low); }
296
+
297
+ .table-wrap {
298
+ width: 100%;
299
+ overflow-x: auto;
300
+ border: 1px solid var(--line);
301
+ border-radius: 8px;
302
+ }
303
+
304
+ table {
305
+ width: 100%;
306
+ min-width: 760px;
307
+ border-collapse: collapse;
308
+ background: var(--panel);
309
+ }
310
+
311
+ th, td {
312
+ padding: 12px 14px;
313
+ border-bottom: 1px solid var(--line);
314
+ text-align: left;
315
+ vertical-align: top;
316
+ font-size: 14px;
317
+ }
318
+
319
+ th {
320
+ color: #374151;
321
+ background: #f1f5f9;
322
+ font-size: 12px;
323
+ text-transform: uppercase;
324
+ }
325
+
326
+ tr:last-child td { border-bottom: 0; }
327
+
328
+ code {
329
+ padding: 2px 5px;
330
+ border-radius: 5px;
331
+ background: #eef2f7;
332
+ color: #111827;
333
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
334
+ font-size: 0.92em;
335
+ white-space: normal;
336
+ overflow-wrap: anywhere;
337
+ }
338
+
339
+ .pill {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ min-height: 24px;
343
+ padding: 3px 8px;
344
+ border-radius: 999px;
345
+ background: #f1f5f9;
346
+ font-size: 12px;
347
+ font-weight: 700;
348
+ text-transform: uppercase;
349
+ }
350
+
351
+ .pill.critical { background: #fee2e2; }
352
+ .pill.high { background: #ffedd5; }
353
+ .pill.medium { background: #fef3c7; }
354
+ .pill.low { background: #ccfbf1; }
355
+
356
+ .empty {
357
+ margin: 0;
358
+ color: var(--muted);
359
+ border: 1px dashed var(--line);
360
+ border-radius: 8px;
361
+ padding: 16px;
362
+ background: #fbfdff;
363
+ }
364
+
365
+ .notes {
366
+ color: var(--muted);
367
+ font-size: 14px;
368
+ }
369
+
370
+ .notes ul {
371
+ margin: 10px 0 0;
372
+ padding-left: 18px;
373
+ }
374
+
375
+ @media (max-width: 780px) {
376
+ main {
377
+ width: min(100% - 20px, 1120px);
378
+ padding-top: 10px;
379
+ }
380
+
381
+ .hero {
382
+ grid-template-columns: 1fr;
383
+ padding: 20px;
384
+ }
385
+
386
+ h1 { font-size: 28px; }
387
+
388
+ .grid,
389
+ .severity-row {
390
+ grid-template-columns: repeat(2, minmax(0, 1fr));
391
+ }
392
+ }
393
+
394
+ @media (max-width: 460px) {
395
+ .grid,
396
+ .severity-row {
397
+ grid-template-columns: 1fr;
398
+ }
399
+ }
400
+ </style>
401
+ </head>
402
+ <body>
403
+ <main>
404
+ <header class="hero">
405
+ <div>
406
+ <p class="eyebrow">mcp-guard scan report</p>
407
+ <h1>AI agent tool risk review</h1>
408
+ <p class="lead">Local-first review of MCP server configuration, startup commands, remote endpoints, filesystem scope, and secret-like values.</p>
409
+ <div class="grid">
410
+ ${metric("Scanned files", safeResult.summary.scannedFileCount)}
411
+ ${metric("MCP servers", safeResult.summary.serverCount)}
412
+ ${metric("Findings", safeResult.summary.findingCount)}
413
+ ${metric("Generated", formatDate(safeResult.metadata.generatedAt))}
414
+ </div>
415
+ </div>
416
+ <aside class="scorecard" aria-label="Risk score">
417
+ <div>
418
+ <p class="score-label">Risk score</p>
419
+ <p class="score-value">${escapeHtml(safeResult.summary.riskScore)}</p>
420
+ </div>
421
+ <p class="score-caption">${escapeHtml(riskCaption(safeResult.summary.riskScore))}</p>
422
+ </aside>
423
+ </header>
424
+
425
+ <section>
426
+ <h2>Severity Summary</h2>
427
+ <div class="severity-row">
428
+ ${severityCard("critical", safeResult.summary.counts.critical)}
429
+ ${severityCard("high", safeResult.summary.counts.high)}
430
+ ${severityCard("medium", safeResult.summary.counts.medium)}
431
+ ${severityCard("low", safeResult.summary.counts.low)}
432
+ </div>
433
+ </section>
434
+
435
+ <section>
436
+ <h2>Scanned Files</h2>
437
+ ${renderScannedFiles(safeResult)}
438
+ </section>
439
+
440
+ <section>
441
+ <h2>MCP Server Inventory</h2>
442
+ ${renderServerTable(servers, safeResult.metadata.cwd)}
443
+ </section>
444
+
445
+ <section>
446
+ <h2>Findings</h2>
447
+ ${renderFindingsTable(findings)}
448
+ </section>
449
+
450
+ <section class="notes">
451
+ <h2>Review Notes</h2>
452
+ <ul>
453
+ <li>Secret-like values are redacted before rendering this report.</li>
454
+ <li>Review each server before granting access to files, shells, SaaS accounts, or production systems.</li>
455
+ <li>This report assists security review and does not guarantee that every issue was found.</li>
456
+ </ul>
457
+ </section>
458
+ </main>
459
+ </body>
460
+ </html>
461
+ `;
462
+ }
463
+
108
464
  function cell(value) {
109
465
  return String(value).replaceAll("|", "\\|").replaceAll("\n", "<br>");
110
466
  }
@@ -134,3 +490,108 @@ function sanitizeResult(result) {
134
490
  summary: result.summary
135
491
  };
136
492
  }
493
+
494
+ function metric(label, value) {
495
+ return `<div class="metric"><strong>${escapeHtml(value)}</strong><span>${escapeHtml(label)}</span></div>`;
496
+ }
497
+
498
+ function severityCard(severity, count) {
499
+ return `<div class="severity ${severity}"><b>${escapeHtml(count)}</b><span>${escapeHtml(severity)}</span></div>`;
500
+ }
501
+
502
+ function renderScannedFiles(result) {
503
+ if (result.scannedFiles.length === 0) {
504
+ return `<p class="empty">No MCP config files were found.</p>`;
505
+ }
506
+
507
+ const items = result.scannedFiles
508
+ .map((file) => `<tr><td><code>${escapeHtml(displayPath(file, result.metadata.cwd))}</code></td></tr>`)
509
+ .join("");
510
+
511
+ return `<div class="table-wrap"><table><thead><tr><th>Path</th></tr></thead><tbody>${items}</tbody></table></div>`;
512
+ }
513
+
514
+ function renderServerTable(servers, cwd) {
515
+ if (servers.length === 0) {
516
+ return `<p class="empty">No MCP servers were found.</p>`;
517
+ }
518
+
519
+ const rows = servers.map((server) => {
520
+ const env = kvList(server.env);
521
+ const headers = kvList(server.headers);
522
+ return `<tr>
523
+ <td><strong>${escapeHtml(server.name)}</strong><br><code>${escapeHtml(displayPath(server.configPath, cwd))}</code></td>
524
+ <td>${codeOrDash(server.command)}</td>
525
+ <td>${codeOrDash(server.args.join(" "))}</td>
526
+ <td>${codeOrDash(server.cwd)}</td>
527
+ <td>${codeOrDash(server.url)}</td>
528
+ <td>${env || "-"}</td>
529
+ <td>${headers || "-"}</td>
530
+ </tr>`;
531
+ }).join("");
532
+
533
+ return `<div class="table-wrap"><table>
534
+ <thead><tr><th>Server</th><th>Command</th><th>Args</th><th>CWD</th><th>URL</th><th>Env</th><th>Headers</th></tr></thead>
535
+ <tbody>${rows}</tbody>
536
+ </table></div>`;
537
+ }
538
+
539
+ function renderFindingsTable(findings) {
540
+ if (findings.length === 0) {
541
+ return `<p class="empty">No findings. Keep reviewing new MCP servers and agent tools before adding them.</p>`;
542
+ }
543
+
544
+ const rows = findings.map((finding) => `<tr>
545
+ <td><span class="pill ${escapeHtml(finding.severity)}">${escapeHtml(finding.severity)}</span></td>
546
+ <td><code>${escapeHtml(finding.id)}</code></td>
547
+ <td>${escapeHtml(finding.serverName)}</td>
548
+ <td>${escapeHtml(finding.title)}</td>
549
+ <td>${codeOrDash(finding.evidence)}</td>
550
+ <td>${escapeHtml(finding.recommendation)}</td>
551
+ </tr>`).join("");
552
+
553
+ return `<div class="table-wrap"><table>
554
+ <thead><tr><th>Severity</th><th>Rule</th><th>Server</th><th>Finding</th><th>Evidence</th><th>Recommendation</th></tr></thead>
555
+ <tbody>${rows}</tbody>
556
+ </table></div>`;
557
+ }
558
+
559
+ function kvList(record) {
560
+ return Object.entries(record || {})
561
+ .map(([key, value]) => `<code>${escapeHtml(key)}=${escapeHtml(value)}</code>`)
562
+ .join("<br>");
563
+ }
564
+
565
+ function codeOrDash(value) {
566
+ if (!value) return "-";
567
+ return `<code>${escapeHtml(value)}</code>`;
568
+ }
569
+
570
+ function formatDate(value) {
571
+ const date = new Date(value);
572
+ if (Number.isNaN(date.getTime())) return value;
573
+ return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
574
+ }
575
+
576
+ function riskToneForScore(score) {
577
+ if (score >= 80) return "critical";
578
+ if (score >= 50) return "high";
579
+ if (score >= 20) return "medium";
580
+ return "low";
581
+ }
582
+
583
+ function riskCaption(score) {
584
+ if (score >= 80) return "Critical review recommended before enabling these tools.";
585
+ if (score >= 50) return "High risk configuration; review before team use.";
586
+ if (score >= 20) return "Moderate risk; confirm the intended permission scope.";
587
+ return "Low risk based on the current rule set.";
588
+ }
589
+
590
+ function escapeHtml(value) {
591
+ return String(value ?? "")
592
+ .replaceAll("&", "&amp;")
593
+ .replaceAll("<", "&lt;")
594
+ .replaceAll(">", "&gt;")
595
+ .replaceAll('"', "&quot;")
596
+ .replaceAll("'", "&#39;");
597
+ }