mcpshield
README.md
# 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
<!-- ABLATION_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
```bash
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
```
```bash
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:
```bash
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_policy` tracks 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
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues