Skip to main content
Glama

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).

Related MCP server: Ticket Queue MCP Server

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI assistants to search and retrieve support tickets, add notes, close tickets, and access SLA policies through MCP tools, resources, and prompts over Streamable HTTP.
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables AI clients to access and manage an internal support ticket queue through MCP tools, resources, and prompts, including searching and viewing tickets, adding comments, closing tickets with confirmation, reading knowledge base articles, and viewing queue summaries over OAuth-secured Streamable HTTP.
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables role-gated management of ops desk contacts, cases, and tasks via MCP, with tool schemas and availability filtered per role to enforce permission boundaries, over stdio or Streamable HTTP.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables AI assistants to query and operate a help desk and service desk through MCP over HTTP, including tickets, requester communications, internal notes, attachments, stage and SLA history, time entries, clients, requesters, desks, knowledge base, contracts, and billing/satisfaction reports.
    MIT