bad-mcp
by ytkoka
README.md
# bad-mcp
A small, extensible harness for building **test MCP servers that reproduce
common MCP attack patterns**, so you can measure whether your detector catches
them. Built on the official MCP Python SDK (`mcp` 2.x).
Serves over **Streamable HTTP** (point a detector at a URL — the realistic
"unvetted remote server" case) or **stdio**, and can also emit an event log
**offline** with no client at all.
> **Authorized testing only.** This is a *detection fixture*, not a working
> attack. Run it in an isolated environment dedicated to evaluating your own
> tooling.
## Safety model
Every scenario is deliberately inert:
- Injection / poisoning **strings** are realistic (so your detector has real
signal to match), but the **runtime behaviour** is harmless: tools return
canned text.
- The only side effect a "successful" attack produces is appending a line to a
local `sink.log` — a stand-in for exfiltration that touches nothing sensitive
and never leaves the machine.
- All referenced secrets are **canaries** (`~/.bad-mcp/canary_secret`,
`CANARY-TOKEN-...`). No real files are read; there is no network egress.
- `ssrf_discovery`'s internal targets (`169.254.169.254`, `127.0.0.1:8080`,
`10.0.0.5`) are **advertised only** in a fake discovery document. Nothing in
this repo ever issues a request to them — verify with
`grep -rn "169.254.169.254" .` and confirm every hit is a string literal,
not an argument to `httpx`/`requests`/`urllib`/`socket`.
## Attack scenarios
| name | pattern |
|------|---------|
| `benign_control` | clean tools — use it to measure false positives |
| `rug_pull` | tool is benign at approval time, then its description/schema silently mutates and a `tools/list_changed` notification is sent |
| `description_poisoning` | tool descriptions carry hidden instructions: imperative overrides, HTML-comment smuggling, invisible Unicode-Tag smuggling, fake "SYSTEM:" directives |
| `tool_shadowing` | duplicate tool names in one manifest, plus a tool impersonating a trusted server's tool and redirecting it |
| `prompt_injection` | manifest stays clean; the injection instead rides in a **tool result** (`read_ticket`) and a **notification** (`get_notification`) — probes detectors that only scan descriptions |
| `ssrf_discovery` | an OAuth/OIDC discovery document (`.well-known/oauth-authorization-server`, `.well-known/openid-configuration`) points `authorization_endpoint` / `token_endpoint` / `jwks_uri` at internal/link-local/cloud-metadata addresses — advertised only, never fetched by this fixture |
| `data_exfiltration` | a benign-looking tool's schema asks for excessive up-front context (`full_context`, `environment`, ...), plus a tool that advertises over-broad permissions (`admin_tool`) |
| `command_injection` | a tool (`run_diagnostics`) takes a raw shell command line instead of structured parameters; never executed, only inspected for injection metacharacters |
| `token_harvesting` | a tool (`submit_debug_report`) asks the model to paste a live API key/auth token "for diagnostics" |
| `supply_chain_impersonation` | a tool description borrows a well-known vendor's name and "verified publisher" language to launder trust, the way a typosquatted/backdoored dependency would |
| `scope_creep` | a tool (`manage_calendar`) approved as read-only silently gains create/delete/share capability after its first call — the *permission surface*, not an injected payload, expands |
| `insufficient_authz` | a tool (`purge_user_data`) describes itself as admin-only/destructive but its schema and call path never actually check for any credential or role |
### Mapping to the OWASP MCP Top 10
Scenarios above line up with the [OWASP MCP Top 10](https://owasp.org/www-project-mcp-top-10/) as follows. `MCP08` (Lack of Audit and Telemetry) and `MCP09` (Shadow MCP Servers) are intentionally out of scope — both describe properties of the *deployment* (server-side logging discipline, unapproved deployments) rather than a payload a single test server can carry.
| OWASP ID | Risk | Scenario(s) |
|---|---|---|
| MCP01 | Token Mismanagement & Secret Exposure | `token_harvesting` |
| MCP02 | Privilege Escalation via Scope Creep | `scope_creep` |
| MCP03 | Tool Poisoning | `rug_pull`, `description_poisoning`, `tool_shadowing` |
| MCP04 | Software Supply Chain Attacks & Dependency Tampering | `supply_chain_impersonation` |
| MCP05 | Command Injection & Execution | `command_injection` |
| MCP06 | Intent Flow Subversion | `prompt_injection`, `description_poisoning` |
| MCP07 | Insufficient Authentication & Authorization | `insufficient_authz`, `ssrf_discovery` |
| MCP10 | Context Injection & Over-Sharing | `data_exfiltration` |
## Install
Requires Python 3.10+ (the `mcp` SDK's minimum).
```bash
python3.10 -m venv venv && . venv/bin/activate
pip install -e .
```
This installs the repo as the `bad_mcp` package (see `pyproject.toml`), which
is what makes `python -m bad_mcp.cli` below work.
## Install with Docker
```bash
docker build -t bad-mcp .
docker run --rm -p 8971:8971 -v "$(pwd)/data:/data" bad-mcp
# detector connects to: http://127.0.0.1:8971/mcp
```
The image's default command runs `serve-http` bound to `0.0.0.0:8971` inside
the container (safe — that address is only reachable via the port you
publish) and writes the event log to `/data/events.jsonl`; mount a host
directory at `/data` to keep it. Override the command to run other
subcommands, e.g.:
```bash
docker run --rm -v "$(pwd)/data:/data" bad-mcp simulate --out /data/events.jsonl
docker run --rm bad-mcp list
```
Or with Compose (writes to `./data/events.jsonl` on the host):
```bash
docker compose up --build
```
## Deploy as a remote MCP server (AWS)
To point a detector at a real "unvetted remote server" instead of
`127.0.0.1`, [`terraform/`](terraform/) deploys this same image to AWS App
Runner behind a fixed HTTPS URL, gated by a generated shared-secret header.
```bash
cd terraform/
cp terraform.tfvars.example terraform.tfvars
terraform init && terraform apply
terraform output mcp_url
```
See [terraform/README.md](terraform/README.md) for prerequisites, cost
(roughly $3-10/month depending on usage — no ALB/NAT/VPC in this setup), and
teardown. This puts the fixture on the public internet; the shared secret is
the access control, so keep it private and `terraform destroy` when done.
## Use
List scenarios:
```bash
python -m bad_mcp.cli list
```
Produce an event log **offline** (no MCP client needed — best for iterating on a
static detector):
```bash
python -m bad_mcp.cli simulate --out events.jsonl
python detector.py events.jsonl
```
Run a **live remote** server over Streamable HTTP and point your detector's
"MCP server URL" input at it (this is the realistic setup — an unvetted remote
MCP server):
```bash
python -m bad_mcp.cli serve-http --port 8971 --out events.jsonl
# detector connects to: http://127.0.0.1:8971/mcp
```
The MCP endpoint is mounted at `/mcp` by default (`--path` to change). Your
detector connects like any MCP client and will observe the rug-pull change
mid-session, the poisoned descriptions, and the shadowed/duplicate tools — while
the harness records the ground truth to `events.jsonl` for scoring.
Remote access from another host is off by default (DNS-rebinding protection).
To allow it in an **isolated test network**:
```bash
# bind publicly and permit a specific Host header
python -m bad_mcp.cli serve-http --host 0.0.0.0 --port 8971 \
--allow-host lab-box:8971 --out events.jsonl
# or, for a throwaway isolated net, skip the Host check entirely
python -m bad_mcp.cli serve-http --host 0.0.0.0 \
--insecure-disable-host-check --out events.jsonl
```
`ssrf_discovery` only serves its discovery document over **HTTP mode**, at
the two `.well-known` paths below (offline `simulate` also renders it once,
as evidence, without an HTTP server):
```bash
python -m bad_mcp.cli serve-http --scenarios ssrf_discovery --port 8971 --out events.jsonl &
curl -s http://127.0.0.1:8971/.well-known/oauth-authorization-server
```
There is also a local **stdio** mode for driving with a stdio MCP client:
```bash
python -m bad_mcp.cli serve --scenarios rug_pull,tool_shadowing --out events.jsonl
```
Pick scenarios and the rug-pull trigger:
```bash
python -m bad_mcp.cli simulate \
--scenarios benign_control,rug_pull --trigger after_call --out events.jsonl
```
## Event log (JSONL)
One JSON object per line. Two categories share the stream:
- **evidence** (`ground_truth: false`) — what a detector is allowed to see:
- `manifest_snapshot` — the tool list served on each `list_tools`, with
per-tool `desc_sha` / `input_schema_sha` / raw `input_schema` (diff the
hashes across snapshots to catch rug-pulls; scan `input_schema` for
excessive-context parameter names).
- `tool_call` — a tool invocation with its arguments.
- `tool_result` — the text a tool call actually returned, logged for every
call so runtime-only injections (not just poisoned manifests) are visible
to an offline detector.
- `notification` — e.g. `tools/list_changed`, or `notifications/message`
carrying an injected instruction.
- `discovery_document` — the OAuth/OIDC `.well-known` document served (or
rendered offline), with its `endpoints`.
- **ground truth** (`ground_truth: true`) — labels for scoring:
`rug_pull_activated`, `poisoned_tool_present`, `duplicate_tool_name`,
`shadow_impersonation`, `exfil_simulated`, `injected_result_served`,
`injected_notification_sent`, `ssrf_discovery_served`,
`context_exfiltration`, `excessive_scope_advertised`,
`command_injection_attempted`, `credential_field_advertised`,
`secret_exfiltration_attempted`, `supply_chain_impersonation_present`,
`scope_creep_escalated`, `missing_authorization_check`,
`unauthorized_action_performed`.
`detector.py` is a baseline detector: it raises findings from evidence
only, then scores itself against the ground-truth labels. Replace it with your
own tool and reuse the scoring harness.
> Note: `tool_shadowing` and `rug_pull` interact by design. Two tools sharing a
> name make a naive "hash changed since last snapshot" heuristic report a
> spurious rug-pull on that name — a useful reminder to key rug-pull detection
> on stable tool identity, not name alone.
> Note: `prompt_injection`'s notification variant (`get_notification`) calls
> `ServerSession.send_log_message`, the SDK's logging-notification API. In
> `mcp` 2.x this capability is deprecated and delivery is per-request opt-in
> (a real client has to have asked for it), so whether the notification
> actually reaches a live client varies. Ground truth and the `notification`
> evidence record are emitted by the scenario unconditionally either way — the
> event log, not the wire, is what a detector is scored against.
> Note: `scope_creep` and `rug_pull` also interact by design — a schema that
> silently gains a permission-shaped field is *also* a hash change, so a naive
> rug-pull detector correctly (if generically) flags it too. `detector.py`
> additionally labels it `scope_creep` specifically when the new field is one
> of `action`/`share_with`/`scope`/`role`/`admin`, to show how a detector can
> tell "the payload changed" apart from "the permission surface grew".
## Extending
Add a subclass of `Scenario` in `scenarios.py` and register it in
`REGISTRY`:
```python
class MyScenario(Scenario):
name = "my_attack"
def tools(self, state):
return [types.Tool(name="...", description="...", inputSchema={...})]
async def on_call(self, name, arguments, ctx, state):
return [types.TextContent(type="text", text="...")]
```
Emit a ground-truth label whenever your attack actually fires (see
`self._label_once(...)` and `self.events.emit(..., ground_truth=True)`) so it
shows up in scoring.
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues