mcpshield
A deliberately attackable MCP server, a hardened twin that shares the same code, and an attack suite that measures which defenses actually stop which attacks.
Every attack here is a structured tool call with a malformed argument — a stray quote
where a search term goes, a ../ in a path, someone else's ticket id, a tampered JWT, a
burst of requests, an oversized string. None of it is persuasive text aimed at a model: the
point is to test the server's own authorisation and input handling, not to see whether an
LLM can be talked into something.
What's here
A multi-tenant helpdesk (src/mcpshield/server.py) exposed over MCP with 11 defenses, each
behind its own switch (src/mcpshield/defenses.py): JWT algorithm/claims validation, per-tool
scopes, tenant isolation, parameterised SQL, path confinement, a taint policy (an identity
that has read sensitive data can't then send externally or delete), rate limiting, input/output
limits, error scrubbing, and audit logging. Defenses.none() is the server most tutorials
produce; Defenses.all() is the same tools and the same behaviour for legitimate calls, with
every switch closed.
21 attacks (src/mcpshield/attacks.py) across auth, authorisation, tenant isolation,
injection, resource limits, information disclosure and exfiltration, each with a concrete,
checkable assertion (a canary string absent from the response, a specific error, a rate-limit
rejection) — never "the response looks suspicious."
src/mcpshield/ablation.py runs the suite under every defense turned off, every defense on,
and every defense on except one, so the report says exactly which switch stops which
attack, which attacks no defense here blocks (a gap), and which attacks succeed even with
every defense off (not testing anything).
Results
| defense removed | attacks this alone lets through |
|---|---|
jwt_alg |
alg_none |
jwt_claims |
expired_token, missing_exp, wrong_audience, wrong_issuer |
scopes |
delete_without_scope, internal_note_without_scope, external_notify_without_scope |
tenant_isolation |
cross_tenant_read, cross_tenant_delete |
sql_params |
sql_injection_union |
path_confine |
cross_tenant_doc, path_traversal_secrets |
taint_policy |
exfil_after_sensitive_read |
limits |
oversized_query, oversized_comment, oversized_limit |
rate_limit |
burst_requests |
error_scrub |
(none alone) |
audit_log |
(none alone) |
Attacks every defense still misses (gaps): none Attacks blocked even with every defense off (not testing anything): no_token, wrong_signature
| attack | category | probes | none | all | jwt_alg | jwt_claims | scopes | tenant_isolation | sql_params | path_confine | taint_policy | limits | rate_limit | error_scrub | audit_log |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| no_token | auth | - | x | x | x | x | x | x | x | x | x | x | x | x | x |
| wrong_signature | auth | - | x | x | x | x | x | x | x | x | x | x | x | x | x |
| alg_none | auth | jwt_alg | . | x | . | x | x | x | x | x | x | x | x | x | x |
| expired_token | auth | jwt_claims | . | x | x | . | x | x | x | x | x | x | x | x | x |
| missing_exp | auth | jwt_claims | . | x | x | . | x | x | x | x | x | x | x | x | x |
| wrong_audience | auth | jwt_claims | . | x | x | . | x | x | x | x | x | x | x | x | x |
| wrong_issuer | auth | jwt_claims | . | x | x | . | x | x | x | x | x | x | x | x | x |
| delete_without_scope | authz | scopes | . | x | x | x | . | x | x | x | x | x | x | x | x |
| internal_note_without_scope | authz | scopes | . | x | x | x | . | x | x | x | x | x | x | x | x |
| cross_tenant_read | tenant | tenant_isolation | . | x | x | x | x | . | x | x | x | x | x | x | x |
| cross_tenant_delete | tenant | tenant_isolation | . | x | x | x | x | . | x | x | x | x | x | x | x |
| cross_tenant_doc | tenant | path_confine | . | x | x | x | x | x | x | . | x | x | x | x | x |
| sql_injection_union | injection | sql_params | . | x | x | x | x | x | . | x | x | x | x | x | x |
| sql_syntax_error_disclosure | disclosure | sql_params,error_scrub | . | x | x | x | x | x | x | x | x | x | x | x | x |
| path_traversal_secrets | injection | path_confine | . | x | x | x | x | x | x | . | x | x | x | x | x |
| oversized_query | limits | limits | . | x | x | x | x | x | x | x | x | . | x | x | x |
| oversized_comment | limits | limits | . | x | x | x | x | x | x | x | x | . | x | x | x |
| oversized_limit | limits | limits | . | x | x | x | x | x | x | x | x | . | x | x | x |
| burst_requests | limits | rate_limit | . | x | x | x | x | x | x | x | x | x | . | x | x |
| external_notify_without_scope | exfil | scopes | . | x | x | x | . | x | x | x | x | x | x | x | x |
| exfil_after_sensitive_read | exfil | taint_policy | . | x | x | x | x | x | x | x | . | x | x | x | x |
Quick start
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
pytest -q # 71 tests
mcpshield demo --check # hardened blocks everything, naive doesn't
mcpshield serve --defenses all --port 8000 # run it
mcpshield attack --defenses all # attack suite against a fresh hardened server
mcpshield attack --defenses none # same suite against a fresh naive server
mcpshield ablation --out ablation.md # the full matrix (~6-9 min: 13 server configs x 21 attacks)
--defenses takes none, all, all-minus:a,b, or only:a,b.
With Docker:
docker compose run --rm test # test suite in the image
docker compose run --rm demo # the self-check
docker compose run --rm attack # attack suite against the hardened server
docker compose --profile ablation run --rm ablation # the full matrix
A real bug the suite found in itself
The first version of the attack suite gave every attack the same default identity
("agent"), and RateLimiter/the taint policy key their state on identity. burst_requests
deliberately exhausts that identity's rate-limit bucket; any later attack reusing the default
identity then came back "blocked" by the rate limiter rather than by the defense it was
meant to test. Under all-minus-scopes, external_notify_without_scope showed as blocked
even though removing scopes should have let it through — the rate limiter was masking a
real gap. Fixed by giving every attack a fresh random identity by default, with an explicit
shared identity only where an attack deliberately needs one across several calls
(exfil_after_sensitive_read). Caught by comparing the ablation numbers against what each
attack's own logic predicted, not by assuming the first run was correct.
Limitations
- The attack surface is one helpdesk app. The defenses are general (JWT validation, scopes, tenant isolation, parameterised queries, path confinement, rate limiting) but only tested against this server's seven tools.
- The taint policy has no expiry. Once an identity has read internal/sensitive data, it is blocked from external sends and deletes for the lifetime of the server process, not for a bounded window. Simpler to reason about and to test; a production version would want a TTL.
- The rate limiter is in-process and per-identity, not distributed. It stops one identity from flooding the server; it does not stop many identities, or a single identity rotating tokens.
- Prompt-injection defenses aren't in scope here.
taint_policytracks which identities have read sensitive data (a field in the database) and restricts their later calls — a real, structural access-control rule. It is not a defense against a model being talked into misusing tools by persuasive text embedded in tool output. An earlier design of this project tried to demonstrate that with realistic payload text (a fake exfiltration address, "don't tell the user" instructions); that content was rightly refused during development, and the project was redesigned around structural attacks only. Whether a model calling these tools can be manipulated by injected text is a different, harder problem this repo doesn't evaluate. - Timings are real but not optimised. Each attack opens its own MCP session (full initialize handshake) rather than reusing a connection, so a full 21-attack run takes 20-40 seconds and the 13-config ablation takes several minutes. Fine for CI, not tuned for speed.
License
MIT