BerkantACUN/guardmcp

Security scanner for MCP (Model Context Protocol) servers and configs — tool poisoning, secret leaks, rug-pull detection, insecure transport.

TypeScript

1

76 commits

updated Sep 24, 2026

See the code

See what people are saying

README

guardmcp

Security 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.

CI npm License: Apache-2.0

Status: core scanner + 33 rules + live introspection (--live covers all three MCP surfaces: tools, prompts, resources) + rug-pull pinning (pin) + GitHub Action, all CI-verified — and now on npm, see Installation.

Why

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.

What it catches

$ 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:

CategoryRulesCatches
Secrets & supply chainMCPG-101, 102, 104, 105, and (--registry) 106Hardcoded 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
TransportMCPG-401–404Plain 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–204Hidden imperative instructions in tool descriptions, invisible/bidi Unicode, cross-server tool shadowing, covert exfiltration parameters
Prompt poisoning (--live)MCPG-205–206The 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–803A 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–210Poisoned 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
ScopeMCPG-301, and (--live) 302–303Filesystem-root-scoped servers, unconstrained inputs on exec-shaped tools, destructive tools with no confirmation hint
Integrity (rug-pull)MCPG-501–502A server's launch command or its real tool definitions changing since you last pinned it — see Rug-pull pinning
Namespace (--live)MCPG-901–902Two 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
GovernanceMCPG-601–602A 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)
AuditMCPG-701, and (--live) 702Telemetry 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.

OWASP MCP Top 10 coverage

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.

CategoryRiskguardmcp rules
✅MCP01Token Mismanagement & Secret ExposureMCPG-101, MCPG-102, MCPG-209, MCPG-210, MCPG-401, MCPG-402, MCPG-801
✅MCP02Privilege Escalation via Scope CreepMCPG-209, MCPG-210, MCPG-301, MCPG-302, MCPG-403
✅MCP03Tool PoisoningMCPG-201, MCPG-202, MCPG-203, MCPG-204, MCPG-205, MCPG-206, MCPG-207, MCPG-208, MCPG-502, MCPG-901, MCPG-902
✅MCP04Software Supply Chain Attacks & Dependency TamperingMCPG-105, MCPG-106, MCPG-501, MCPG-502
✅MCP05Command Injection & ExecutionMCPG-104, MCPG-802
✅MCP06Intent Flow SubversionMCPG-203, MCPG-205, MCPG-303, MCPG-803, MCPG-901, MCPG-902
✅MCP07Insufficient Authentication & AuthorizationMCPG-401, MCPG-402, MCPG-404
✅MCP08Lack of Audit and TelemetryMCPG-701, MCPG-702
✅MCP09Shadow MCP ServersMCPG-601, MCPG-602
✅MCP10Context Injection & Over-SharingMCPG-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.

Research

Installation

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

CLI

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.

Remote servers, and the request guardmcp will not make

--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.

Inventory — what do I actually have?

$ 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.

CI in one command

$ 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.

Adopting this on a repository that already has findings

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 introspection (--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):

  • Opt-in only — never runs on a default guardmcp scan.
  • Listing only — 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.
  • Environment is scrubbed — a spawned stdio server gets an OS-appropriate safelist (PATH/HOME/etc.) plus only the env entries its own config declares, never this process's full environment.
  • Hard timeout, both layers — the MCP SDK's own per-request timeout, plus an outer timeout here that force-closes the connection (and kills the process) regardless.
  • Remote servers go through a connect policy first — private/cloud-metadata addresses, and cleartext 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.

Proxy — watching a live session (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)
  • Transparent. Bytes are piped through unchanged in both directions; the proxy observes a copy. Malformed JSON, non-UTF-8 bytes and plain-text chatter are forwarded as-is and logged as invalid — never a crash, never a dropped message.
  • One record per message — direction, kind (request / notification / response / error / invalid), method, id, and request-to-response latency. On stderr by default; with --log <file> as JSONL (full message included), and stderr then only carries findings.
  • The log is not a leak. The file is created owner-only (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.
  • Every 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.
  • Invisible to the client. The wrapped server's exit code is passed through (128 + signal number if it was killed by a signal, 127 if it could not be started), and SIGINT / SIGTERM / SIGHUP are forwarded to it rather than killing the proxy first.

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.

Why pinning matters more than it looks — the shape of this ecosystem

A snapshot of the official MCP registry taken 2026-09-07, 3,945 latest-version servers:

countshare
reachable only as a remote HTTP endpoint3,54490%
shipping an installable package56714%

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.

Rug-pull pinning

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:

RuleComparesCatches
MCPG-501command/args/url (config-level)Someone edited the config itself
MCPG-502Real 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.

GitHub Action reference

InputDefaultDescription
paths(auto-discover)Space-separated config paths
fail-onhighMinimum severity that fails the step
rules / ignore-rule—Comma-separated rule ID filters
sarif-outputguardmcp-results.sarifWhere to write the report
working-directory(cwd)Directory to scan from
livefalseConnect to every stdio server and scan its real tools (spawns locally in the runner)
live-timeout10000Per-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.

Development

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.

Security

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.

License

Apache-2.0 — see LICENSE.

ai-agents
claude
cursor
mcp
model-context-protocol
owasp
security
security-scanner
supply-chain-security
tool-poisoning

Contributors

BerkantACUN

55 commits

claude

21 commits

BerkantACUN/guardmcp

Security scanner for MCP (Model Context Protocol) servers and configs — tool poisoning, secret leaks, rug-pull detection, insecure transport.

TypeScript

1

76 commits

updated Sep 24, 2026

See the code

See what people are saying

README

guardmcp

Security 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.

CI npm License: Apache-2.0

Status: core scanner + 33 rules + live introspection (--live covers all three MCP surfaces: tools, prompts, resources) + rug-pull pinning (pin) + GitHub Action, all CI-verified — and now on npm, see Installation.

Why

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.

What it catches

$ 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:

CategoryRulesCatches
Secrets & supply chainMCPG-101, 102, 104, 105, and (--registry) 106Hardcoded 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
TransportMCPG-401–404Plain 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–204Hidden imperative instructions in tool descriptions, invisible/bidi Unicode, cross-server tool shadowing, covert exfiltration parameters
Prompt poisoning (--live)MCPG-205–206The 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–803A 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–210Poisoned 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
ScopeMCPG-301, and (--live) 302–303Filesystem-root-scoped servers, unconstrained inputs on exec-shaped tools, destructive tools with no confirmation hint
Integrity (rug-pull)MCPG-501–502A server's launch command or its real tool definitions changing since you last pinned it — see Rug-pull pinning
Namespace (--live)MCPG-901–902Two 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
GovernanceMCPG-601–602A 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)
AuditMCPG-701, and (--live) 702Telemetry 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.

OWASP MCP Top 10 coverage

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.

CategoryRiskguardmcp rules
✅MCP01Token Mismanagement & Secret ExposureMCPG-101, MCPG-102, MCPG-209, MCPG-210, MCPG-401, MCPG-402, MCPG-801
✅MCP02Privilege Escalation via Scope CreepMCPG-209, MCPG-210, MCPG-301, MCPG-302, MCPG-403
✅MCP03Tool PoisoningMCPG-201, MCPG-202, MCPG-203, MCPG-204, MCPG-205, MCPG-206, MCPG-207, MCPG-208, MCPG-502, MCPG-901, MCPG-902
✅MCP04Software Supply Chain Attacks & Dependency TamperingMCPG-105, MCPG-106, MCPG-501, MCPG-502
✅MCP05Command Injection & ExecutionMCPG-104, MCPG-802
✅MCP06Intent Flow SubversionMCPG-203, MCPG-205, MCPG-303, MCPG-803, MCPG-901, MCPG-902
✅MCP07Insufficient Authentication & AuthorizationMCPG-401, MCPG-402, MCPG-404
✅MCP08Lack of Audit and TelemetryMCPG-701, MCPG-702
✅MCP09Shadow MCP ServersMCPG-601, MCPG-602
✅MCP10Context Injection & Over-SharingMCPG-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.

Research

Installation

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

CLI

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.

Remote servers, and the request guardmcp will not make

--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.

Inventory — what do I actually have?

$ 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.

CI in one command

$ 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.

Adopting this on a repository that already has findings

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 introspection (--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):

  • Opt-in only — never runs on a default guardmcp scan.
  • Listing only — 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.
  • Environment is scrubbed — a spawned stdio server gets an OS-appropriate safelist (PATH/HOME/etc.) plus only the env entries its own config declares, never this process's full environment.
  • Hard timeout, both layers — the MCP SDK's own per-request timeout, plus an outer timeout here that force-closes the connection (and kills the process) regardless.
  • Remote servers go through a connect policy first — private/cloud-metadata addresses, and cleartext 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.

Proxy — watching a live session (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)
  • Transparent. Bytes are piped through unchanged in both directions; the proxy observes a copy. Malformed JSON, non-UTF-8 bytes and plain-text chatter are forwarded as-is and logged as invalid — never a crash, never a dropped message.
  • One record per message — direction, kind (request / notification / response / error / invalid), method, id, and request-to-response latency. On stderr by default; with --log <file> as JSONL (full message included), and stderr then only carries findings.
  • The log is not a leak. The file is created owner-only (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.
  • Every 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.
  • Invisible to the client. The wrapped server's exit code is passed through (128 + signal number if it was killed by a signal, 127 if it could not be started), and SIGINT / SIGTERM / SIGHUP are forwarded to it rather than killing the proxy first.

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.

Why pinning matters more than it looks — the shape of this ecosystem

A snapshot of the official MCP registry taken 2026-09-07, 3,945 latest-version servers:

countshare
reachable only as a remote HTTP endpoint3,54490%
shipping an installable package56714%

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.

Rug-pull pinning

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:

RuleComparesCatches
MCPG-501command/args/url (config-level)Someone edited the config itself
MCPG-502Real 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.

GitHub Action reference

InputDefaultDescription
paths(auto-discover)Space-separated config paths
fail-onhighMinimum severity that fails the step
rules / ignore-rule—Comma-separated rule ID filters
sarif-outputguardmcp-results.sarifWhere to write the report
working-directory(cwd)Directory to scan from
livefalseConnect to every stdio server and scan its real tools (spawns locally in the runner)
live-timeout10000Per-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.

Development

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.

Security

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.

License

Apache-2.0 — see LICENSE.

ai-agents
claude
cursor
mcp
model-context-protocol
owasp
security
security-scanner
supply-chain-security
tool-poisoning

Contributors

BerkantACUN

55 commits

claude

21 commits

Languages

TypeScript

94.0%

JavaScript

6.0%