Security scanner for MCP (Model Context Protocol) servers and configs — tool poisoning, secret leaks, rug-pull detection, insecure transport.
See the codeSecurity scanner for MCP (Model Context Protocol) servers and configs — hardcoded secrets, tool poisoning, insecure transport, unrestricted permissions. Terminal, JSON, or schema-validated SARIF output for direct GitHub Code Scanning integration.
Status: core scanner + 33 rules + live introspection (
--livecovers all three MCP surfaces: tools, prompts, resources) + rug-pull pinning (pin) + GitHub Action, all CI-verified — and now on npm, see Installation.
MCP servers ship with real security gaps — one academic scanning study puts it at 66-72% of servers having at least one issue — and the tool description a server advertises is read by the LLM, not by the human who approved installing it. That's the whole attack surface of tool poisoning: a sentence you'll never scroll to can carry an instruction the model will follow.
Existing scanners are either closed-source enterprise platforms (Snyk Agent Scan, Cisco AI Defense) or small single-maintainer projects with narrow coverage. Neither Snyk nor Cisco's scanner emits SARIF — the format that plugs straight into GitHub's Code Scanning tab. guardmcp does, and validates its own output against the official SARIF 2.1.0 schema in CI so that claim isn't just a README line.
$ guardmcp scan .mcp.json
.mcp.json
CRITICAL MCPG-101 Hardcoded GitHub token found in "example-server" server config.
7:25 ghp_…yz12
Fix: Move this value to an environment variable or secret manager reference,
then rotate the exposed credential — it must be treated as compromised once committed.
MEDIUM MCPG-105 "example-server" server launches "some-mcp-server" without a
pinned version — every run may fetch a different, unreviewed release.
5:22
Fix: Pin to a specific version: "some-mcp-server@<version>". A publish under
the same "latest" tag can silently change what code runs on your machine.
1 critical, 1 medium — 2 finding(s) across 1 file(s)
That's a real run against a real (synthetic) fixture in this repo — tests/fixtures/configs/malicious/leaked-github-token.json, not a mockup.
33 rules, grouped here by what they catch:
| Category | Rules | Catches |
|---|---|---|
| Secrets & supply chain | MCPG-101, 102, 104, 105, and (--registry) 106 | Hardcoded provider keys (GitHub/Anthropic/AWS/Slack/JWT), high-entropy unknown secrets, curl | sh-style fetch-and-execute, unpinned package versions, and — asked of the npm registry itself — a server launched from a package its own registry marks deprecated |
| Transport | MCPG-401–404 | Plain http://, disabled TLS verification, SSRF-reachable (private/metadata) targets, and — under --live, from evidence rather than inference — remote endpoints that serve an unauthenticated client |
Tool poisoning (--live) | MCPG-201–204 | Hidden imperative instructions in tool descriptions, invisible/bidi Unicode, cross-server tool shadowing, covert exfiltration parameters |
Prompt poisoning (--live) | MCPG-205–206 | The same two attacks on the prompt surface — a prompt is instructions by design, so a smuggled directive is less conspicuous there than in a tool description |
Declaration (--live) | MCPG-801–803 | A credential parameter mirrored into an HTTP header (x-mcp-header, which the spec warns against by name), a header name carrying a CRLF, and a display title that hides what the invoked name does |
Resources (--live) | MCPG-207–210 | Poisoned resource metadata, and — with no equivalent on the other surfaces — a resource URI that points at credentials (~/.ssh/id_rsa), a filesystem root, or cloud metadata. A resource points somewhere, so what it points at is checkable regardless of what it claims to be — and MCPG-210 covers resource templates, where file:///{path} is arbitrary file read advertised as a feature |
| Scope | MCPG-301, and (--live) 302–303 | Filesystem-root-scoped servers, unconstrained inputs on exec-shaped tools, destructive tools with no confirmation hint |
| Integrity (rug-pull) | MCPG-501–502 | A server's launch command or its real tool definitions changing since you last pinned it — see Rug-pull pinning |
Namespace (--live) | MCPG-901–902 | Two servers offering the same tool name — MCP does not namespace them, so which one the model reaches depends on the client's merge order — and a name built from lookalike characters (Cyrillic а, Greek ο) that renders identically to a tool on another server while being a different string to every comparison the client makes |
| Governance | MCPG-601–602 | A server configured machine-wide that the project never declared — it loads with the same reach as reviewed servers, unreviewed — and a "local" server launched listening on every network interface (--host 0.0.0.0, a Docker port published without a host address) |
| Audit | MCPG-701, and (--live) 702 | Telemetry switched off in a committed config (in env or as launch arguments), and — from the protocol rather than the config — a server that can change things while declaring no logging capability, so nothing it does can be reported |
Full catalog: docs/rules/. Design doc + rule rationale + competitive analysis: mcp-guard-plan.md.
Findings are mapped to the OWASP MCP Top 10 (v0.1) so a result means something to a reviewer who has never heard of this tool — and so a scan can be read against a published standard rather than a private rule numbering.
10 of 10 OWASP MCP Top 10 categories have at least one rule.
| Category | Risk | guardmcp rules | |
|---|---|---|---|
| ✅ | MCP01 | Token Mismanagement & Secret Exposure | MCPG-101, MCPG-102, MCPG-209, MCPG-210, MCPG-401, MCPG-402, MCPG-801 |
| ✅ | MCP02 | Privilege Escalation via Scope Creep | MCPG-209, MCPG-210, MCPG-301, MCPG-302, MCPG-403 |
| ✅ | MCP03 | Tool Poisoning | MCPG-201, MCPG-202, MCPG-203, MCPG-204, MCPG-205, MCPG-206, MCPG-207, MCPG-208, MCPG-502, MCPG-901, MCPG-902 |
| ✅ | MCP04 | Software Supply Chain Attacks & Dependency Tampering | MCPG-105, MCPG-106, MCPG-501, MCPG-502 |
| ✅ | MCP05 | Command Injection & Execution | MCPG-104, MCPG-802 |
| ✅ | MCP06 | Intent Flow Subversion | MCPG-203, MCPG-205, MCPG-303, MCPG-803, MCPG-901, MCPG-902 |
| ✅ | MCP07 | Insufficient Authentication & Authorization | MCPG-401, MCPG-402, MCPG-404 |
| ✅ | MCP08 | Lack of Audit and Telemetry | MCPG-701, MCPG-702 |
| ✅ | MCP09 | Shadow MCP Servers | MCPG-601, MCPG-602 |
| ✅ | MCP10 | Context Injection & Over-Sharing | MCPG-204, MCPG-207, MCPG-209, MCPG-210, MCPG-801 |
Mapped against 165fe0f of the OWASP list.
The list is a v0.1 beta that moves under its own label, and independent tools have already
ended up with numbering that does not line up — so the SARIF taxonomy pins the exact commit
this mapping was drafted against (taxonomies[0].properties.specCommit), which makes a
disagreement about a category settleable by fetching that tree rather than by argument.
Every finding carries its OWASP category in the SARIF output — as a first-class
taxonomies
entry with per-rule relationships, plus properties.tags so the categories show up as
filter chips in GitHub's Code Scanning UI.
"Covered" means every category has at least one rule, not that every risk in it is detectable. MCP08 and MCP09 are mostly organisational controls (log pipelines, asset registries, network discovery); docs/owasp/MCP08-MCP09.md lists item by item what guardmcp checks there and what is out of a scanner's reach.
registry.modelcontextprotocol.io, rendered to a client config and run through guardmcp's static rules. Generated by scripts/registry-scan.mjs, with the raw snapshot and findings alongside so the numbers can be re-derived.1. npx (no install):
npx guardmcp scan
2. Global install:
npm install -g guardmcp
guardmcp scan
3. GitHub Action, via a tagged release:
- uses: BerkantACUN/guardmcp@v0.2.1
with:
fail-on: high
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: guardmcp-results.sarif
4. Run from source (for contributing, or to try an unreleased change):
git clone https://github.com/BerkantACUN/guardmcp.git
cd guardmcp && npm install && npm run build
node dist/cli/index.js scan
guardmcp scan [paths...]
--format human|json|sarif default: human
--output <file> write report to a file instead of stdout
--fail-on <severity> info|low|medium|high|critical, default: high
--rules <ids> run only these rule IDs (comma-separated)
--ignore-rule <ids> skip these rule IDs
--baseline <file> suppress findings already accepted (see below)
--registry ask npm about every launched package; report deprecated ones (MCPG-106)
--live connect to every stdio server and scan its REAL tools (MCPG-2xx/3xx)
--live-timeout <ms> per-server timeout for --live, default: 10000
--lock <file> enable rug-pull drift checks (MCPG-501/502); defaults to
.mcpguard-lock.json in cwd if present (see below)
guardmcp baseline [paths...]
--output <file> baseline file path, default: .mcpguard-baseline.json
--force overwrite an existing baseline
--live record findings about each server's REAL tools too
guardmcp pin [paths...]
--live also connect and pin each server's REAL tool list, not just its config
--live-timeout <ms> per-server timeout for --live, default: 10000
--output <file> lock file path, default: .mcpguard-lock.json
guardmcp proxy [options] -- <command> [args...]
--log <file> append every JSON-RPC message as JSONL instead of logging to stderr
--sarif <file> on exit, write the session's findings as SARIF
--name <name> server name used in findings (default: derived from the command)
With no [paths], both commands auto-discover project-level (.mcp.json, .vscode/mcp.json) and global (Claude Desktop, Cursor, Windsurf) configs across Windows/macOS/Linux.
--live dials remote (Streamable HTTP) servers as well as stdio ones. It
refuses two cases by default:
metadata http http://169.254.169.254/latest/meta-data/
could not connect — refused to connect — "169.254.169.254" is a private-network
or cloud-metadata address. Connecting would make guardmcp itself issue a request
to internal infrastructure — the thing MCPG-403 exists to report.
leaky http http://api.example.com/mcp
could not connect — refused to connect — this endpoint is unencrypted http://
and the config attaches credential headers to it. Connecting would transmit your
own credentials in the clear — the thing MCPG-401 exists to report.
The principle: guardmcp never performs the unsafe act it exists to warn
about. --live is where a static finding becomes an action this process
takes, so a scanner that can be pointed at 169.254.169.254 by a config file
is an SSRF primitive wearing a security tool's name.
Both are overridable with --live-allow-unsafe — scanning your own internal
server is legitimate, it just has to be a decision rather than a default.
Loopback is exempt: a server on localhost is the ordinary development case.
$ guardmcp inventory --live
.mcp.json (project)
api stdio node dist/server.js
tools 3 read_file, delete_all_records, search_docs
prompts 1 review_code
resources 2 project-readme, deploy-key
broken stdio nope-does-not-exist
could not connect — MCP error -32000: Connection closed
remote http https://api.example.com/mcp
not introspected
3 servers across 1 config — 3 tools, 1 prompt, 2 resources
scan answers "is any of this dangerous". inventory answers "what is any of
this" — the question you have before you have a security question, and the one
MCP09
is really about: you cannot review a server you do not know you have.
It never exits non-zero on content. It reports; it does not judge. Note the
three distinct states above — a server that answered, one that could not be
reached, and one that was never asked. "Advertises no tools" and "we did not
ask" are different facts, and blurring them is what makes an inventory useless.
--format json for machine consumption.
$ guardmcp init
Wrote .github/workflows/guardmcp.yml
Scans on every push and pull request, uploads findings to the Security tab, and
fails the build at your chosen severity. It requests security-events: write —
without that permission the scan runs and the findings silently never arrive,
which is the usual way this setup fails.
It does not enable --live, because that spawns each server's launch
command and is a decision a repository owner should make knowingly rather than
inherit from a generator.
Turning a scanner on for the first time usually produces a red build and a list nobody has time for, and the scanner comes back out. Record what is already there, then gate on what gets added:
guardmcp baseline # writes .mcpguard-baseline.json
guardmcp scan --baseline .mcpguard-baseline.json
The second command reports only findings that appeared after the baseline was taken, and exits non-zero only for those — so CI is green on day one and red the first time someone adds a hardcoded token.
The baseline is a list of accepted risks, so it is written to be reviewed rather than trusted:
{
"version": "1",
"generatedAt": "2026-09-09T11:36:06.750Z",
"entries": [
{
"fingerprint": "9c2632b396c66079",
"ruleId": "MCPG-101",
"severity": "critical",
"logicalPath": "/mcpServers/github/env/GITHUB_PERSONAL_ACCESS_TOKEN",
"message": "Hardcoded GitHub token found in \"github\" server config."
}
]
}
A file of bare hashes would be approved in a pull request without anyone
knowing what it accepted. This one cannot be. Entries are sorted by fingerprint
so regenerating produces a readable diff, and an existing baseline is never
overwritten without --force.
Fingerprints are computed from rule + file + logical JSON path, not line/column — a baseline survives an unrelated reformat elsewhere in the file instead of silently re-flagging everything.
--live)$ guardmcp scan --live
Connects to every server in your config — stdio servers by launching them, remote ones over Streamable HTTP (see Remote servers for the two requests it refuses) — lists their real tools, prompts, resources and resource templates, and runs the live rules (MCPG-2xx/3xx/8xx/9xx) against what each server actually advertises — not just what's visible in the config file. A malicious tool description doesn't live in .mcp.json; it lives on the server, and a config-only scanner can never see it.
Security constraints this runs under (see src/live/introspect.ts):
guardmcp scan.tools/list, prompts/list, resources/list and resources/templates/list; never tools/call, prompts/get or resources/read. Discovering what a tool claims to do must never mean actually doing it.PATH/HOME/etc.) plus only the env entries its own config declares, never this process's full environment.http:// carrying credential headers, are refused unless --live-allow-unsafe is given. Remote servers are dialled over Streamable HTTP only; the legacy HTTP+SSE transport is not supported by --live.guardmcp proxy)scan --live asks a server for its tools once. proxy sits between your MCP
client and a stdio server for the whole session — Wireshark for MCP — and
watches every message go by. Replace the server's command in your client config
with guardmcp proxy -- <command>:
{
"mcpServers": {
"memory": {
"command": "guardmcp",
"args": ["proxy", "--log", "/tmp/guardmcp-memory.jsonl",
"--", "npx", "-y", "@modelcontextprotocol/server-memory@2026.8.31"]
}
}
}
Ready-to-copy configs for Claude Desktop (macOS and Windows), Cursor and Claude Code, with where each file goes and how to install guardmcp so the client can launch it: examples/proxy/.
[guardmcp proxy] → client→server request tools/list #2
[guardmcp proxy] ← server→client response tools/list #2 (14 ms)
[guardmcp proxy] ⚠ MCPG-201 critical: Tool "search_docs" on server "fixture-server" has a description containing 2 instruction-like phrase(s) ... (live:fixture-server/search_docs)
invalid — never a crash, never a dropped
message.--log <file> as JSONL (full message included), and
stderr then only carries findings.0600), strings
under credential-named keys (token, password, apiKey, authorization,
...) and known-provider secrets anywhere in a message are redacted in the log
copy — the forwarded traffic is untouched. If the disk falls behind, records
are dropped and counted instead of stalling the session or growing memory.
Treat the log as sensitive anyway: redaction is pattern-based.tools/list response is scanned with the same MCPG-2xx/3xx/8xx/9xx
tool rules scan --live runs, including later pages of a paginated listing
(the whole listing is rescanned each page, so a shadowing tool is caught
whichever page it arrives on) and a tool list that changes mid-session. --sarif <file> writes the
session's distinct findings on exit.Unlike --live, the proxy does not scrub the server's environment: it is
running the server for your client, exactly as the client would, not probing
it.
A snapshot of the official MCP registry taken 2026-09-07, 3,945 latest-version servers:
| count | share | |
|---|---|---|
| reachable only as a remote HTTP endpoint | 3,544 | 90% |
| shipping an installable package | 567 | 14% |
For nine servers in ten there is no version to pin, no lockfile, and no reinstall step. The provider can change what a tool does for every user at once, silently, and the config file on your disk stays byte-identical.
The same snapshot: 59 hostnames are claimed by more than one registry namespace, one of them by 213. Namespace verification proves who published the listing. It does not prove who controls the running service, and it cannot tell you the service changed after you approved it.
So hashing what a server actually advertises is not a nicety here. For most of the ecosystem it is the only control that exists.
The classic MCP supply-chain attack: a server you reviewed once keeps the same name and the same-looking config, but what actually runs changes — an unpinned npx some-mcp-server silently fetches a new release with a different tool description, or someone quietly edits the launch command. guardmcp pin snapshots the current state; a later scan flags any drift.
guardmcp pin --live # snapshot config + real tool list into .mcpguard-lock.json
git add .mcpguard-lock.json && git commit -m "chore: pin MCP servers"
# ...later, in CI or locally...
guardmcp scan --live # auto-detects .mcpguard-lock.json, flags drift
Two independent checks, because they catch different things:
| Rule | Compares | Catches |
|---|---|---|
| MCPG-501 | command/args/url (config-level) | Someone edited the config itself |
| MCPG-502 | Real tools/list output (--live only) | The config is untouched, but a new package version behaves differently |
Env/header values are deliberately excluded from the config-level hash — only variable names — so routine secret rotation never trips a false "drift" alert.
| Input | Default | Description |
|---|---|---|
paths | (auto-discover) | Space-separated config paths |
fail-on | high | Minimum severity that fails the step |
rules / ignore-rule | — | Comma-separated rule ID filters |
sarif-output | guardmcp-results.sarif | Where to write the report |
working-directory | (cwd) | Directory to scan from |
live | false | Connect to every stdio server and scan its real tools (spawns locally in the runner) |
live-timeout | 10000 | Per-server timeout (ms) for live |
lock | (auto-detect) | Path to .mcpguard-lock.json; defaults to one in working-directory if present |
Outputs: sarif-path, exit-code.
npm install
npm run dev -- --version # run CLI from source (tsx)
npm test # vitest
npm run verify # typecheck + lint + build + test w/ coverage (what CI runs)
npm run bench # build, then time `scan` over 1,000 generated configs
300+ tests, coverage enforced at 80% (statements/branches/functions/lines) in vitest.config.ts. Every rule has a malicious fixture, a benign false-positive-regression fixture, and — where applicable — a test for the exact line/column it reports. --live/pin are tested against a real spawned MCP server (built on @modelcontextprotocol/sdk, tests/fixtures/live-servers/), not a mock transport.
See SECURITY.md for reporting a vulnerability in guardmcp itself, and for responsible disclosure guidance if a scan surfaces a real issue in a server you don't own.
Apache-2.0 — see LICENSE.
55 commits
21 commits
TypeScript
94.0%
JavaScript
6.0%
Security scanner for MCP (Model Context Protocol) servers and configs — tool poisoning, secret leaks, rug-pull detection, insecure transport.
See the codeSecurity scanner for MCP (Model Context Protocol) servers and configs — hardcoded secrets, tool poisoning, insecure transport, unrestricted permissions. Terminal, JSON, or schema-validated SARIF output for direct GitHub Code Scanning integration.
Status: core scanner + 33 rules + live introspection (
--livecovers all three MCP surfaces: tools, prompts, resources) + rug-pull pinning (pin) + GitHub Action, all CI-verified — and now on npm, see Installation.
MCP servers ship with real security gaps — one academic scanning study puts it at 66-72% of servers having at least one issue — and the tool description a server advertises is read by the LLM, not by the human who approved installing it. That's the whole attack surface of tool poisoning: a sentence you'll never scroll to can carry an instruction the model will follow.
Existing scanners are either closed-source enterprise platforms (Snyk Agent Scan, Cisco AI Defense) or small single-maintainer projects with narrow coverage. Neither Snyk nor Cisco's scanner emits SARIF — the format that plugs straight into GitHub's Code Scanning tab. guardmcp does, and validates its own output against the official SARIF 2.1.0 schema in CI so that claim isn't just a README line.
$ guardmcp scan .mcp.json
.mcp.json
CRITICAL MCPG-101 Hardcoded GitHub token found in "example-server" server config.
7:25 ghp_…yz12
Fix: Move this value to an environment variable or secret manager reference,
then rotate the exposed credential — it must be treated as compromised once committed.
MEDIUM MCPG-105 "example-server" server launches "some-mcp-server" without a
pinned version — every run may fetch a different, unreviewed release.
5:22
Fix: Pin to a specific version: "some-mcp-server@<version>". A publish under
the same "latest" tag can silently change what code runs on your machine.
1 critical, 1 medium — 2 finding(s) across 1 file(s)
That's a real run against a real (synthetic) fixture in this repo — tests/fixtures/configs/malicious/leaked-github-token.json, not a mockup.
33 rules, grouped here by what they catch:
| Category | Rules | Catches |
|---|---|---|
| Secrets & supply chain | MCPG-101, 102, 104, 105, and (--registry) 106 | Hardcoded provider keys (GitHub/Anthropic/AWS/Slack/JWT), high-entropy unknown secrets, curl | sh-style fetch-and-execute, unpinned package versions, and — asked of the npm registry itself — a server launched from a package its own registry marks deprecated |
| Transport | MCPG-401–404 | Plain http://, disabled TLS verification, SSRF-reachable (private/metadata) targets, and — under --live, from evidence rather than inference — remote endpoints that serve an unauthenticated client |
Tool poisoning (--live) | MCPG-201–204 | Hidden imperative instructions in tool descriptions, invisible/bidi Unicode, cross-server tool shadowing, covert exfiltration parameters |
Prompt poisoning (--live) | MCPG-205–206 | The same two attacks on the prompt surface — a prompt is instructions by design, so a smuggled directive is less conspicuous there than in a tool description |
Declaration (--live) | MCPG-801–803 | A credential parameter mirrored into an HTTP header (x-mcp-header, which the spec warns against by name), a header name carrying a CRLF, and a display title that hides what the invoked name does |
Resources (--live) | MCPG-207–210 | Poisoned resource metadata, and — with no equivalent on the other surfaces — a resource URI that points at credentials (~/.ssh/id_rsa), a filesystem root, or cloud metadata. A resource points somewhere, so what it points at is checkable regardless of what it claims to be — and MCPG-210 covers resource templates, where file:///{path} is arbitrary file read advertised as a feature |
| Scope | MCPG-301, and (--live) 302–303 | Filesystem-root-scoped servers, unconstrained inputs on exec-shaped tools, destructive tools with no confirmation hint |
| Integrity (rug-pull) | MCPG-501–502 | A server's launch command or its real tool definitions changing since you last pinned it — see Rug-pull pinning |
Namespace (--live) | MCPG-901–902 | Two servers offering the same tool name — MCP does not namespace them, so which one the model reaches depends on the client's merge order — and a name built from lookalike characters (Cyrillic а, Greek ο) that renders identically to a tool on another server while being a different string to every comparison the client makes |
| Governance | MCPG-601–602 | A server configured machine-wide that the project never declared — it loads with the same reach as reviewed servers, unreviewed — and a "local" server launched listening on every network interface (--host 0.0.0.0, a Docker port published without a host address) |
| Audit | MCPG-701, and (--live) 702 | Telemetry switched off in a committed config (in env or as launch arguments), and — from the protocol rather than the config — a server that can change things while declaring no logging capability, so nothing it does can be reported |
Full catalog: docs/rules/. Design doc + rule rationale + competitive analysis: mcp-guard-plan.md.
Findings are mapped to the OWASP MCP Top 10 (v0.1) so a result means something to a reviewer who has never heard of this tool — and so a scan can be read against a published standard rather than a private rule numbering.
10 of 10 OWASP MCP Top 10 categories have at least one rule.
| Category | Risk | guardmcp rules | |
|---|---|---|---|
| ✅ | MCP01 | Token Mismanagement & Secret Exposure | MCPG-101, MCPG-102, MCPG-209, MCPG-210, MCPG-401, MCPG-402, MCPG-801 |
| ✅ | MCP02 | Privilege Escalation via Scope Creep | MCPG-209, MCPG-210, MCPG-301, MCPG-302, MCPG-403 |
| ✅ | MCP03 | Tool Poisoning | MCPG-201, MCPG-202, MCPG-203, MCPG-204, MCPG-205, MCPG-206, MCPG-207, MCPG-208, MCPG-502, MCPG-901, MCPG-902 |
| ✅ | MCP04 | Software Supply Chain Attacks & Dependency Tampering | MCPG-105, MCPG-106, MCPG-501, MCPG-502 |
| ✅ | MCP05 | Command Injection & Execution | MCPG-104, MCPG-802 |
| ✅ | MCP06 | Intent Flow Subversion | MCPG-203, MCPG-205, MCPG-303, MCPG-803, MCPG-901, MCPG-902 |
| ✅ | MCP07 | Insufficient Authentication & Authorization | MCPG-401, MCPG-402, MCPG-404 |
| ✅ | MCP08 | Lack of Audit and Telemetry | MCPG-701, MCPG-702 |
| ✅ | MCP09 | Shadow MCP Servers | MCPG-601, MCPG-602 |
| ✅ | MCP10 | Context Injection & Over-Sharing | MCPG-204, MCPG-207, MCPG-209, MCPG-210, MCPG-801 |
Mapped against 165fe0f of the OWASP list.
The list is a v0.1 beta that moves under its own label, and independent tools have already
ended up with numbering that does not line up — so the SARIF taxonomy pins the exact commit
this mapping was drafted against (taxonomies[0].properties.specCommit), which makes a
disagreement about a category settleable by fetching that tree rather than by argument.
Every finding carries its OWASP category in the SARIF output — as a first-class
taxonomies
entry with per-rule relationships, plus properties.tags so the categories show up as
filter chips in GitHub's Code Scanning UI.
"Covered" means every category has at least one rule, not that every risk in it is detectable. MCP08 and MCP09 are mostly organisational controls (log pipelines, asset registries, network discovery); docs/owasp/MCP08-MCP09.md lists item by item what guardmcp checks there and what is out of a scanner's reach.
registry.modelcontextprotocol.io, rendered to a client config and run through guardmcp's static rules. Generated by scripts/registry-scan.mjs, with the raw snapshot and findings alongside so the numbers can be re-derived.1. npx (no install):
npx guardmcp scan
2. Global install:
npm install -g guardmcp
guardmcp scan
3. GitHub Action, via a tagged release:
- uses: BerkantACUN/guardmcp@v0.2.1
with:
fail-on: high
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: guardmcp-results.sarif
4. Run from source (for contributing, or to try an unreleased change):
git clone https://github.com/BerkantACUN/guardmcp.git
cd guardmcp && npm install && npm run build
node dist/cli/index.js scan
guardmcp scan [paths...]
--format human|json|sarif default: human
--output <file> write report to a file instead of stdout
--fail-on <severity> info|low|medium|high|critical, default: high
--rules <ids> run only these rule IDs (comma-separated)
--ignore-rule <ids> skip these rule IDs
--baseline <file> suppress findings already accepted (see below)
--registry ask npm about every launched package; report deprecated ones (MCPG-106)
--live connect to every stdio server and scan its REAL tools (MCPG-2xx/3xx)
--live-timeout <ms> per-server timeout for --live, default: 10000
--lock <file> enable rug-pull drift checks (MCPG-501/502); defaults to
.mcpguard-lock.json in cwd if present (see below)
guardmcp baseline [paths...]
--output <file> baseline file path, default: .mcpguard-baseline.json
--force overwrite an existing baseline
--live record findings about each server's REAL tools too
guardmcp pin [paths...]
--live also connect and pin each server's REAL tool list, not just its config
--live-timeout <ms> per-server timeout for --live, default: 10000
--output <file> lock file path, default: .mcpguard-lock.json
guardmcp proxy [options] -- <command> [args...]
--log <file> append every JSON-RPC message as JSONL instead of logging to stderr
--sarif <file> on exit, write the session's findings as SARIF
--name <name> server name used in findings (default: derived from the command)
With no [paths], both commands auto-discover project-level (.mcp.json, .vscode/mcp.json) and global (Claude Desktop, Cursor, Windsurf) configs across Windows/macOS/Linux.
--live dials remote (Streamable HTTP) servers as well as stdio ones. It
refuses two cases by default:
metadata http http://169.254.169.254/latest/meta-data/
could not connect — refused to connect — "169.254.169.254" is a private-network
or cloud-metadata address. Connecting would make guardmcp itself issue a request
to internal infrastructure — the thing MCPG-403 exists to report.
leaky http http://api.example.com/mcp
could not connect — refused to connect — this endpoint is unencrypted http://
and the config attaches credential headers to it. Connecting would transmit your
own credentials in the clear — the thing MCPG-401 exists to report.
The principle: guardmcp never performs the unsafe act it exists to warn
about. --live is where a static finding becomes an action this process
takes, so a scanner that can be pointed at 169.254.169.254 by a config file
is an SSRF primitive wearing a security tool's name.
Both are overridable with --live-allow-unsafe — scanning your own internal
server is legitimate, it just has to be a decision rather than a default.
Loopback is exempt: a server on localhost is the ordinary development case.
$ guardmcp inventory --live
.mcp.json (project)
api stdio node dist/server.js
tools 3 read_file, delete_all_records, search_docs
prompts 1 review_code
resources 2 project-readme, deploy-key
broken stdio nope-does-not-exist
could not connect — MCP error -32000: Connection closed
remote http https://api.example.com/mcp
not introspected
3 servers across 1 config — 3 tools, 1 prompt, 2 resources
scan answers "is any of this dangerous". inventory answers "what is any of
this" — the question you have before you have a security question, and the one
MCP09
is really about: you cannot review a server you do not know you have.
It never exits non-zero on content. It reports; it does not judge. Note the
three distinct states above — a server that answered, one that could not be
reached, and one that was never asked. "Advertises no tools" and "we did not
ask" are different facts, and blurring them is what makes an inventory useless.
--format json for machine consumption.
$ guardmcp init
Wrote .github/workflows/guardmcp.yml
Scans on every push and pull request, uploads findings to the Security tab, and
fails the build at your chosen severity. It requests security-events: write —
without that permission the scan runs and the findings silently never arrive,
which is the usual way this setup fails.
It does not enable --live, because that spawns each server's launch
command and is a decision a repository owner should make knowingly rather than
inherit from a generator.
Turning a scanner on for the first time usually produces a red build and a list nobody has time for, and the scanner comes back out. Record what is already there, then gate on what gets added:
guardmcp baseline # writes .mcpguard-baseline.json
guardmcp scan --baseline .mcpguard-baseline.json
The second command reports only findings that appeared after the baseline was taken, and exits non-zero only for those — so CI is green on day one and red the first time someone adds a hardcoded token.
The baseline is a list of accepted risks, so it is written to be reviewed rather than trusted:
{
"version": "1",
"generatedAt": "2026-09-09T11:36:06.750Z",
"entries": [
{
"fingerprint": "9c2632b396c66079",
"ruleId": "MCPG-101",
"severity": "critical",
"logicalPath": "/mcpServers/github/env/GITHUB_PERSONAL_ACCESS_TOKEN",
"message": "Hardcoded GitHub token found in \"github\" server config."
}
]
}
A file of bare hashes would be approved in a pull request without anyone
knowing what it accepted. This one cannot be. Entries are sorted by fingerprint
so regenerating produces a readable diff, and an existing baseline is never
overwritten without --force.
Fingerprints are computed from rule + file + logical JSON path, not line/column — a baseline survives an unrelated reformat elsewhere in the file instead of silently re-flagging everything.
--live)$ guardmcp scan --live
Connects to every server in your config — stdio servers by launching them, remote ones over Streamable HTTP (see Remote servers for the two requests it refuses) — lists their real tools, prompts, resources and resource templates, and runs the live rules (MCPG-2xx/3xx/8xx/9xx) against what each server actually advertises — not just what's visible in the config file. A malicious tool description doesn't live in .mcp.json; it lives on the server, and a config-only scanner can never see it.
Security constraints this runs under (see src/live/introspect.ts):
guardmcp scan.tools/list, prompts/list, resources/list and resources/templates/list; never tools/call, prompts/get or resources/read. Discovering what a tool claims to do must never mean actually doing it.PATH/HOME/etc.) plus only the env entries its own config declares, never this process's full environment.http:// carrying credential headers, are refused unless --live-allow-unsafe is given. Remote servers are dialled over Streamable HTTP only; the legacy HTTP+SSE transport is not supported by --live.guardmcp proxy)scan --live asks a server for its tools once. proxy sits between your MCP
client and a stdio server for the whole session — Wireshark for MCP — and
watches every message go by. Replace the server's command in your client config
with guardmcp proxy -- <command>:
{
"mcpServers": {
"memory": {
"command": "guardmcp",
"args": ["proxy", "--log", "/tmp/guardmcp-memory.jsonl",
"--", "npx", "-y", "@modelcontextprotocol/server-memory@2026.8.31"]
}
}
}
Ready-to-copy configs for Claude Desktop (macOS and Windows), Cursor and Claude Code, with where each file goes and how to install guardmcp so the client can launch it: examples/proxy/.
[guardmcp proxy] → client→server request tools/list #2
[guardmcp proxy] ← server→client response tools/list #2 (14 ms)
[guardmcp proxy] ⚠ MCPG-201 critical: Tool "search_docs" on server "fixture-server" has a description containing 2 instruction-like phrase(s) ... (live:fixture-server/search_docs)
invalid — never a crash, never a dropped
message.--log <file> as JSONL (full message included), and
stderr then only carries findings.0600), strings
under credential-named keys (token, password, apiKey, authorization,
...) and known-provider secrets anywhere in a message are redacted in the log
copy — the forwarded traffic is untouched. If the disk falls behind, records
are dropped and counted instead of stalling the session or growing memory.
Treat the log as sensitive anyway: redaction is pattern-based.tools/list response is scanned with the same MCPG-2xx/3xx/8xx/9xx
tool rules scan --live runs, including later pages of a paginated listing
(the whole listing is rescanned each page, so a shadowing tool is caught
whichever page it arrives on) and a tool list that changes mid-session. --sarif <file> writes the
session's distinct findings on exit.Unlike --live, the proxy does not scrub the server's environment: it is
running the server for your client, exactly as the client would, not probing
it.
A snapshot of the official MCP registry taken 2026-09-07, 3,945 latest-version servers:
| count | share | |
|---|---|---|
| reachable only as a remote HTTP endpoint | 3,544 | 90% |
| shipping an installable package | 567 | 14% |
For nine servers in ten there is no version to pin, no lockfile, and no reinstall step. The provider can change what a tool does for every user at once, silently, and the config file on your disk stays byte-identical.
The same snapshot: 59 hostnames are claimed by more than one registry namespace, one of them by 213. Namespace verification proves who published the listing. It does not prove who controls the running service, and it cannot tell you the service changed after you approved it.
So hashing what a server actually advertises is not a nicety here. For most of the ecosystem it is the only control that exists.
The classic MCP supply-chain attack: a server you reviewed once keeps the same name and the same-looking config, but what actually runs changes — an unpinned npx some-mcp-server silently fetches a new release with a different tool description, or someone quietly edits the launch command. guardmcp pin snapshots the current state; a later scan flags any drift.
guardmcp pin --live # snapshot config + real tool list into .mcpguard-lock.json
git add .mcpguard-lock.json && git commit -m "chore: pin MCP servers"
# ...later, in CI or locally...
guardmcp scan --live # auto-detects .mcpguard-lock.json, flags drift
Two independent checks, because they catch different things:
| Rule | Compares | Catches |
|---|---|---|
| MCPG-501 | command/args/url (config-level) | Someone edited the config itself |
| MCPG-502 | Real tools/list output (--live only) | The config is untouched, but a new package version behaves differently |
Env/header values are deliberately excluded from the config-level hash — only variable names — so routine secret rotation never trips a false "drift" alert.
| Input | Default | Description |
|---|---|---|
paths | (auto-discover) | Space-separated config paths |
fail-on | high | Minimum severity that fails the step |
rules / ignore-rule | — | Comma-separated rule ID filters |
sarif-output | guardmcp-results.sarif | Where to write the report |
working-directory | (cwd) | Directory to scan from |
live | false | Connect to every stdio server and scan its real tools (spawns locally in the runner) |
live-timeout | 10000 | Per-server timeout (ms) for live |
lock | (auto-detect) | Path to .mcpguard-lock.json; defaults to one in working-directory if present |
Outputs: sarif-path, exit-code.
npm install
npm run dev -- --version # run CLI from source (tsx)
npm test # vitest
npm run verify # typecheck + lint + build + test w/ coverage (what CI runs)
npm run bench # build, then time `scan` over 1,000 generated configs
300+ tests, coverage enforced at 80% (statements/branches/functions/lines) in vitest.config.ts. Every rule has a malicious fixture, a benign false-positive-regression fixture, and — where applicable — a test for the exact line/column it reports. --live/pin are tested against a real spawned MCP server (built on @modelcontextprotocol/sdk, tests/fixtures/live-servers/), not a mock transport.
See SECURITY.md for reporting a vulnerability in guardmcp itself, and for responsible disclosure guidance if a scan surfaces a real issue in a server you don't own.
Apache-2.0 — see LICENSE.
55 commits
21 commits
TypeScript
94.0%
JavaScript
6.0%