Skip to main content
Glama
iNileshW

Licensing Hardening MCP Server

by iNileshW
README.md
# Harden the local-authority licensing assistant's MCP server

**Local authority — licensing team.** The assistant answers questions about taxi and
premises licences — "is licence 9003 active, and what did the last inspection say?" —
so officers don't dig through the case system by hand. This MCP server is how it
reaches the licensing database. A colleague "improved" it before they left; it works,
it demos cleanly, and it carries every native MCP weakness from this afternoon. Work
through it on your own.

## Get it running

Plain Python — no Docker, no Git.

```
pip install -r requirements.txt
python seed.py            # builds licensing.db (includes a planted inspection note)
```

See the attacks land before you fix anything:

```
python exploit.py         # SQL injection + over-exposure of home addresses / DBS status
python poisoning_demo.py  # a tool whose DESCRIPTION carries hidden instructions
```

## Your task

Harden `server.py` so the native attacks fail while legitimate use still works.
Work through them in this order:

1. **Result injection** — `read_inspection_note` hands raw note text back to the
   model. Return it labelled as untrusted data, and rely on a system-prompt rule that
   labelled data is content, never command. (Licensee 3's note is an instruction to
   suspend a licence — the note-reader and `suspend_licence`, each harmless, compose
   into a suspension.)
2. **Tool poisoning** — `format_reference`'s description contains hidden instructions.
   Review every description; a server you do not trust should not reach the model's
   context. Neutralise it.
3. **Confused deputy** — `suspend_licence` is state-changing and unscoped. Put it
   behind a scope check, and reject any bearer token whose `aud` is not this server
   (the Week 5 discipline).
4. **Inherited** — parameterise `find_licensee` and return only the columns the
   assistant needs (never `home_address`, `dbs_status`, or `inspection_notes`).
5. **Audit** — append every tool call to a log.

You are done when `poisoning_demo.py` finds nothing in your descriptions and
`exploit.py`'s payloads are rejected, while a normal `find_licensee("Aisha")` still
returns a result.

## Stretch

- Add a real `aud` check: decode the presented token and refuse a foreign audience.
- With two servers connected to one host, how would a malicious second server reach
  this one's `suspend_licence`? Write down the control that stops it.

## Files

| File | Contents |
|---|---|
| `server.py` | The vulnerable MCP server — the thing you harden. |
| `seed.py` | Builds `licensing.db` — the `licensees` and `licences` tables. |
| `exploit.py` | Proves the inherited flaw (SQL injection + over-exposure). |
| `poisoning_demo.py` | Prints the model-facing descriptions; flags the poisoned one. |
| `requirements.txt` | `fastmcp`. |

## Questions worth asking yourself

- Which of your fixes are MCP-specific, and which are ordinary secure coding?
- A description you did not write reached your model. Whose job is it to have caught
  that — the server author, the host, or the person who approved it?