PluginWorld
Mc

mcpshield

MCP

Exposes a multi-tenant helpdesk over MCP, enabling tool-based ticket, document, and notification operations while applying configurable authorization, tenant-isolation, input-handling, and rate-limit defenses.

@muhammadshayan124 · MIT License · updated today

SECURITY

B

SCORE

60

STARS

▲ 0

PLUG IN

git clone https://github.com/muhammadshayan124/mcpshield.git

See the README to configure this MCP server

README

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

SIMILAR PLUGINS