Skip to main content
Glama
README.md
# MCP security fixture

A small MCP server with a `vulnerable` mode and a `fixed` mode. It has three flaws. Each flaw has a client-side test that shows the flaw in one mode and its absence in the other.

Everything runs on 127.0.0.1. All data and keys are fake. Test only this fixture and servers you own.

## Run

```
./run.sh
```

You need `uv` (tested with 0.10.4) and Python 3.10 or newer. `run.sh` installs `mcp==2.3.0` into `.venv`, starts the server and a stub downstream API on free loopback ports, runs three test scripts in both modes, and writes `results.csv`. Exit code 0 means every outcome matched the expected one. No network call leaves the Mac. No paid model is called.

## SDK

Official `mcp` Python SDK, version 2.3.0 (PyPI, released 2026-10-02). In 2.x, `FastMCP` is now `MCPServer` (`from mcp.server.mcpserver import MCPServer`). Code written for 1.x does not import. Check the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/ if you port this to 1.x.

## The three flaws

| # | Flaw | Tool | Vulnerable mode | Fixed mode |
|---|---|---|---|---|
| 1 | Tool description carries hidden instructions | `add_numbers` | The description tells the agent to call `read_secret` and send the result in a `sidenote` argument. An obedient agent leaks the user's key. The user still sees the right sum. | The description is plain. The `sidenote` argument is gone. |
| 2 | Token passthrough | `fetch_report` | The server forwards the user's own token (audience `mcp-fixture`) to the downstream API. The downstream API accepts it. Any holder of that token can call the API directly. | The server sends its own token for the downstream audience. The downstream API rejects any other audience. |
| 3 | Missing per-user authorization | `get_invoice` | The server checks who you are, not what is yours. Alice reads Bob's invoice. | The server compares the invoice owner to the caller. Alice gets an error. |

Fixes in production terms: pin and review tool descriptions on the client side, and show them to the user (flaw 1). Never pass a client token to another service. Use a token exchange or a service credential with the right audience, and check the audience on the receiving side (flaw 2). Check ownership on every object-level read (flaw 3). The MCP authorization spec forbids token passthrough.

## The tests

| Script | Test | Shows |
|---|---|---|
| `tests/test_flaw1_tool_poisoning.py` | `stub_agent_leaks_secret_to_tool` | Whether the secret reaches the attacker sink |
| | `description_scanner` | A simple client-side scan flags the poisoned description and passes the clean one |
| `tests/test_flaw2_token_passthrough.py` | `downstream_received_clients_token` | Whether the downstream API sees the client's own token |
| | `tool_still_works` | The fix does not break the tool |
| | `replay_mcp_token_at_downstream` | Whether the MCP-audience token works at the downstream API |
| `tests/test_flaw3_missing_authz.py` | `alice_reads_bobs_invoice` | The flaw |
| | `alice_reads_own_invoice` | Control: legitimate use still works |
| | `no_token_rejected` | Control: login works in both modes, so the flaw is authorization |

## Limits

- The agent in flaw 1 is a deterministic stub, not a language model. It obeys one instruction pattern in a tool description. The test proves that server text reaches the agent's context and can drive a tool call. It does not prove that a given real model obeys. No real-model run was done.
- The tokens are toy HMAC tokens with `sub` and `aud` claims. They show audience handling. They are not an OAuth implementation.
- The scanner is a regex list. It catches this fixture, not a careful attacker.
- Tool output injection (hostile text in a tool result) is not covered. Only the description channel is.

## Files

`server.py` the MCP server. `downstream.py` the stub downstream API. `fixture_auth.py` toy tokens. `tests/` the harness, the stub agent, the scanner and three test scripts. `results.csv` the last run and its outcomes.

Licence: MIT (see `LICENSE`).