Releases: DINAKAR-S/Agentic-MCP-Scanner
Release list
mcpvuln 0.3.0
The headline: fourteen new rules for the MCP vulnerability classes disclosed as CVEs
during 2025 and 2026, calibrated on 55 third-party MCP servers (840,905 lines) chosen
for being obscure rather than popular, and a second public fixture corpus that scores
them. The paper's 22-instance corpus is untouched and still scores exactly as published:
precision 1.000, recall 0.864, F1 0.927.
Detection
- SSRF from a tool's URL argument (
web.ssrf.tool_argument_url), the shape of
CVE-2025-65513, CVE-2026-26118, CVE-2026-27826 and TRA-2025-36. The rule runs only in
files that define MCP tools and show no private-address or allow-list check. - Argument injection into
git,kubectl,docker,sshand similar
(web.argument_injection.cli_list), the shape of CVE-2025-68144 in Anthropic's
mcp-server-git. An argument list that passes"--"before the caller's values is exempt. - Prefix-only path containment (
web.path_traversal.startswith_containment),
CVE-2025-66689 and CVE-2025-53109/53110:.startswith(root)in a file that never
canonicalises a path. - DNS-rebinding protection explicitly disabled
(mcp.dns_rebinding.protection_disabled), CVE-2025-66414 and CVE-2025-66416. Three of
the 55 real servers do this. An informational sibling flags an HTTP transport with no
transport-security settings in the file at all. - All-interface bind with no authentication in sight
(mcp.network_exposure.bind_all_interfaces), CVE-2026-23744 and CVE-2025-49596. - One HTTP transport shared by every client (
mcp.session.shared_http_transport),
CVE-2026-25536. - JavaScript command injection through
execSyncon a template literal or a
concatenation (web.cmd_injection.js_exec_interpolated; CVE-2025-53355, CVE-2025-53967,
CVE-2026-0755) andspawnwithshell: true(web.cmd_injection.spawn_shell_true). - A shell started through an argument list,
["bash", "-lc", command]
(agent.excessive_agency.shell_argv). - Line jumping, cross-server tool shadowing and invisible Unicode in tool
metadata (mcp.line_jumping.*,mcp.tool_shadowing.*,mcp.hidden_unicode.*), after
Trail of Bits and Invariant Labs. subprocess ... shell=Trueis now found behind nested parentheses, which a real server
used (subprocess.run(subprocess.list2cmdline(cmd), ..., shell=True)), and
asyncio.create_subprocess_shellis treated as the same sink. Both were misses on a
third-party deliberately vulnerable file.- A rug-pull rule was written and removed. It matched
await client.listTools(),
which is how every correct client lists tools: 26 hits on the official SDK, all false.
A rule that fires on all correct code is not a detection.docs/cve-coverage.mdlists
it and the other classes that deliberately have no rule.
Precision, measured on code nobody wrote for this tool
The first run of the previous rule set on the 55 servers reported 103 findings; adjudicated
by hand, 6 were plausible defects. Three idioms accounted for most of the rest, and each
is now excluded explicitly rather than by lowering a threshold:
f"... IN ({','.join('?' * n)})"andf"... FROM {TABLE_CONSTANT}"are not SQL
injection; nor is'%'inside a quoted string (strftime('%s')) the formatting
operator. Identifier interpolation intoSET,SAVEPOINT,INSTALLand other
statements that cannot take a bound parameter is now a separate informational rule.- MD5 or SHA-1 as a content fingerprint or cache key is informational; the reportable rule
needs a password, credential or signature in the same expression. - A UTF-8 byte-order mark at the start of a
.csfile is not a hidden instruction. The
analyzer strips a leading BOM before matching, and the invisible-Unicode rule is anchored
to the line so code that strips those characters is not reported for naming them. - A method definition called
exec(protected exec(sql: string)) is not dynamic
execution.
After the changes the same 55 servers (1,162,650 lines once the largest finished) report
30 findings, of which 22 were judged plausible defects on manual review: three
servers with DNS-rebinding protection switched off, two bound to every interface with no
authentication, a committed 64-hex API key in a docker-compose.yml, a shell-as-a-tool
server, a subprocess.run(list2cmdline(cmd), shell=True), and a short-link resolver that
follows redirects from any host that merely contains the expected domain. Plausible
means "worth a human's time", not "confirmed exploitable"; the eight remaining are HTTP
wrappers over a fixed base URL and a WeChat-mandated SHA-1.
On the 285,463-line benign corpus the raw count went from 21 to 38 (0.013 per 100
lines, under the 0.05 CI budget) and the reportable count from 1 to 3. All three are
recorded in benchmark/corpus.json: the official fetch server retrieving whatever URL the
model supplies, shell=True on an argument list in the SDK's CLI, and the SDK's own
backward-compatibility fallback that constructs TransportSecuritySettings with protection
off. Two further hits went away for the right reason: the fixed git server rejects
option-shaped targets three lines above the call the rule matches, so the rule now stands
down in any file that checks for a leading -.
Analyzer
- Rules can carry two file-level gates:
requires, a regex that must match somewhere
in the file, andabsent, one that must match nowhere. A single-site regex cannot say
"this file defines MCP tools" or "this file never calls realpath"; a gate can.
--self-checkvalidates them. - A leading UTF-8 byte-order mark is stripped before matching.
- A hang found by the pre-release stress pass, present since 0.1.0. The
cross-tenant cache rule began with an unbounded\w*in front of a literal, which is
quadratic; a 2 MB single-line file made one scan run for over ten minutes. The prefix
is now bounded, and a test feeds every rule six hostile files (a 400 KB line, 2,000
nested parentheses, 10,000 alternating quotes, a 10,000-placeholder f-string, 10,000
repeated<IMPORTANT>tags, 50,000 zero-width characters) with a two-second ceiling
each.
Benchmark
- A second fixture corpus,
demo/vulnerable-2026anddemo/safe-2026, with
demo/ground-truth-2026.jsonnaming the CVE each of its 16 instances mirrors.
python benchmark/score_demo.py --corpus 2026scores it (16 TP, 0 FP, 0 FN) and
--corpus paperscores the frozen 22-instance set. CI runs both and requires the fixed
halves to be silent.
Packaging
Dockerfileand a release workflow that publishesghcr.io/dinakar-s/mcpvulnto
GitHub Packages on everyv*tag and attaches the sdist and wheel to the release.CITATION.cff: the author's name is Dinakar, family name S.- 216 tests, up from 154.
v0.2.1 - packaging fix
Packaging only. No change to detection, scoring or reporting; 0.2.0 and 0.2.1 behave identically.
Fixes the PyPI page showing only a one-line summary instead of the README. python -m build builds the wheel from the sdist, and the README lived only at the repository root, so it was absent at wheel-build time and setup.py fell back to the short description without complaining. PyPI metadata is immutable per version, so fixing it required a release.
- A PyPI-facing README now ships inside the package directory, with absolute links since relative ones do not resolve on PyPI. Wheel metadata goes from 2,330 to 9,967 characters.
setup.pyrejects a stub shorter than 500 characters rather than degrading quietly again.- Project URLs expanded from two to eight: homepage, source, issues, changelog, benchmark, contributing, security policy, release notes.
- Classifiers added for Python 3.13, console environment, testing topic and typing.
- Corrects the rule count in the README, which still said twenty-seven after two cross-tenant rules were added. It is 29.
pip install --upgrade mcpvulnv0.2.0 - 160x fewer false positives, reproducible scoring
Precision 1.000, Recall 0.864, F1 0.927 against a public ground truth, and 23 findings across 285,463 lines of clean code, down from 3,642.
Precision, recall and F1
demo/ground-truth.json documents 22 planted vulnerabilities across all four taxonomy layers. demo/safe is the same code with every one fixed, so it contributes only true negatives and precision is measured directly rather than sampled.
python benchmark/score_demo.py| TP | FP | FN | Precision | Recall | F1 | |
|---|---|---|---|---|---|---|
| All layers | 19 | 0 | 3 | 1.000 | 0.864 | 0.927 |
| LLM | 1 | 0 | 0 | 1.000 | 1.000 | 1.000 |
| Traditional web | 5 | 0 | 0 | 1.000 | 1.000 | 1.000 |
| MCP | 11 | 0 | 1 | 1.000 | 0.917 | 0.957 |
| Agentic AI | 2 | 0 | 2 | 1.000 | 0.500 | 0.667 |
The three misses are all properties of protocol state rather than of any line of code, which is why the Agentic AI layer scores lowest. These fixtures carry one clean instance of each class, so read this as evidence that the rules fire and discriminate, not as an estimate of recall on unseen code.
On 285,463 lines of clean, officially maintained MCP code, findings went from 3,642 to 23. A 160x reduction, and the one finding above the reporting threshold is a genuine bug in the official SDK.
Try it in thirty seconds
The repository ships the same MCP server twice, with every vulnerability in the first one fixed in the second.
pip install -e "mcp-scan[all]"
mcpvuln demo/vulnerable # 20 findings, 12 categories, all four layers
mcpvuln demo/safe # 0 findingsNo API key. No network. The second command is the one that matters: any scanner finds planted bugs, but one that also fires on the corrected version is not measuring anything.
What changed
Detection
- Fixed a regular expression responsible for 2,061 of 3,642 findings.
wget .* | shused an unescaped|, which in a regular expression is alternation rather than a shell pipe, so the alternative' sh'matched the word "should". - Fixed four duplicate dictionary keys that silently discarded four pattern lists: 33 keys written, 29 surviving, 11 expressions never executed.
- Removed twenty unanchored bare-substring patterns (
latest,http://,open(,while True) that fired on comments, documentation and URLs. - Made case-insensitivity opt-in per pattern. Applied globally,
DES\s*\(matchedincludes(. - Matching is whole-file rather than line-by-line, and comments, docstrings and prose are suppressed.
- Every finding carries a confidence score used for ranking, filtering and the informational threshold.
- Added suppression directives:
# mcpvuln: ignore-file,# mcpvuln: ignore,# mcpvuln: ignore[rule.id].
Scoring is now computed, not generated
- CVSS v4.0 base scores come from the
cvssimplementation of the FIRST specification. The same finding scores identically on every run; previously a language model was asked to write a score in prose. - SSVC is evaluated as the published decision tree.
Exploitationis never reported asactive, because a source scanner observes code, not exploitation in the wild.
Architecture
- New scan contract: a versioned JSON document that is the only thing crossing a stage boundary. Save it with
--json, diff it across commits, score it offline. SecurityAnalysisTeamis nowSecurityAnalysisPipeline. The previous class presented the stages as a coordinating multi-agent team, but the coordinating model was constructed and never invoked, andOPENAI_API_KEYappeared nowhere in the repository. The old name remains as a deprecated alias.- Detection requires no API key and no network. Only
--narrativeand--threat-intelreach out, and both degrade to a warning without their key. - Removed
findings[:20], which silently discarded 99.4% of findings on a repository of any size.
Testing
- 140 tests, from zero. Every defect above is held down by a regression test.
- CI on Linux and Windows across Python 3.9, 3.11 and 3.12, plus a benchmark job that fails if the false-positive rate exceeds 0.05 per 100 LOC.
- New benchmark with a pinned benign corpus, so precision is measurable rather than assumed.
- Fixed an install broken since the first release:
setup.pyreadREADME.mdfrom the working directory rather than relative to itself, so the documented install failed on a fresh clone.
Benchmark
| Version | Findings on 285,463 clean lines | Per 100 LOC |
|---|---|---|
| v0.1.0 | 3,642 | 1.28 |
| v0.2.0 | 23 | 0.008 |
Corpus: modelcontextprotocol/python-sdk at d060b36 and modelcontextprotocol/servers at d73f99e. Reproduce with python benchmark/run_benchmark.py --fetch.
Upgrading from 0.1.0
SecurityAnalysisTeam still works but warns. Reports are written with --out rather than to the working directory. Full notes in CHANGELOG.md.
v0.1.0 - initial release (superseded)
Initial release: regular-expression detection with language-model reporting.
Tagged so that the version evaluated in the accompanying paper stays reproducible. Not recommended for use. Measured at 1.28 findings per 100 lines of clean code, of which an adjudicated sample of twenty-five contained no true positives.
Use v0.2.0, which reduces that by 160x.