Skip to main content
Glama
jbisaccia-9

mcp-gate

by jbisaccia-9
README.md
# mcp-gate

![ci](https://github.com/jbisaccia-9/mcp-gate/actions/workflows/ci.yml/badge.svg)

**A path is a request. The roots boundary is the guarantee.**

An MCP file-access server enforces which files a model may touch through *roots* — a
set of authorized directories. `mcp-gate` demonstrates the difference between
*telling* the server to stay inside its roots and *enforcing* it: the same attack
suite is run against a version that only has an instruction, and a version that
canonicalizes every path and checks it against the boundary before any read. One
leaks; one holds. Both directions are enforced in CI.

This is an independent implementation of concepts from Anthropic's advanced MCP
material, written to showcase three things explicitly:

| Capability | Where it shows up |
|---|---|
| **Advanced MCP server** | `src/mcp_gate/server.py` — a `FastMCP` server exposing typed tools (`list_roots`, `read_file`, `list_dir`, `summarize_file`), with structured errors and server-initiated **sampling** (`ctx.session.create_message`) so the server can ask the *client* to run the model. |
| **Logs & progress** | Every tool streams MCP **logging notifications** (`ctx.info`) and **progress** (`ctx.report_progress`) as it runs — including logging the exact moment a request is blocked at the boundary. `src/mcp_gate/client.py` shows the matching `logging_callback` / `sampling_callback`. |
| **Access control** | `src/mcp_gate/boundary.py` — the guarantee: `..` traversal, absolute paths, symlink escapes, and `%2e`-encoded traversal are all normalized *before* the roots check. This is the gate. |

## How it works

```mermaid
flowchart TD
    C[MCP client / model] -- "read_file(path)" --> S[FastMCP server]
    S --> M{mode}

    M -- prompt --> I["system prompt says: stay inside roots"]
    I --> O1[open path as given]
    O1 --> L["LEAK: out-of-bounds secret served"]

    M -- boundary --> D["1. unquote — defuse %2e%2e"]
    D --> A["2. abspath + realpath — collapse .. and follow symlinks"]
    A --> R{"3. inside an authorized root?"}
    R -- yes --> F[open file] --> OK[served]
    R -- no --> X["AccessError: path escapes authorized roots"]
    X --> LOG["ctx.info log + progress notification"]

    subgraph EVAL["eval suite — both halves must hold"]
        AT["5 attacks: direct, ../, absolute, symlink, %2e"] --> B1["boundary_no_escape = 1.0"]
        AT --> B2["prompt_escape_demonstrated = 1.0"]
        B1 & B2 --> CI{CI}
        CI -- "0 escapes AND leak still shown" --> PASS[PASS]
        CI -- "either fails" --> FAIL["FAIL: vacuous or broken"]
    end
```

The `prompt` lane is a worst-case control on purpose: if it ever *stops* leaking,
the "secure" result proves nothing, so CI fails on that too.

## The two modes

| Mode | How it decides | Result |
|---|---|---|
| `prompt` (insecure control) | A system-prompt instruction says "only read files under the roots," but the path is opened as given. | **Leaks** — the naive path-handler is a worst-case control, on purpose. |
| `boundary` (enforced) | Each path is decoded, `realpath`-resolved (collapsing `..` and following symlinks), then required to sit inside an authorized root. | **Holds** — out-of-bounds requests are refused before any file is opened. |

### The attack suite

| Attack | `prompt` mode | `boundary` mode |
|---|---|---|
| `direct_ask` (a legitimate in-root file) | served ✓ | served ✓ |
| `dotdot_traversal` (`../out_of_bounds/secret.txt`) | **leaks** | blocked |
| `absolute_path` (absolute path to the secret) | **leaks** | blocked |
| `symlink_escape` (a symlink inside the sandbox → outside) | **leaks** | blocked |
| `encoded_traversal` (`%2e%2e/...`) | **leaks** | blocked |

`direct_ask` succeeding in *both* modes is the deceptive part: a quick manual test
of prompt-layer security looks safe, then fails under an attack you didn't try.

## The gate (CI-enforced, both directions)

```
python -m mcp_gate gate boundary   # exit 0 only if 0 escapes
python -m mcp_gate gate prompt      # exit 0 only if the leak is still demonstrated
python -m mcp_gate suite            # both halves must hold
```

`gate prompt` failing to leak would mean the demo has gone vacuous (the "secure"
result is meaningless if the insecure one also passes), so CI treats *that* as a
build failure too.

## Quickstart

```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"

pytest -q
python -m mcp_gate gate boundary
python -m mcp_gate gate prompt
python -m mcp_gate suite
```

Run the live MCP server + client demo (needs `pip install -e ".[demo]"` and an
`ANTHROPIC_API_KEY` in your environment for the sampling tool):

```bash
python -m mcp_gate serve ./data/sandbox      # start the server over stdio
python -m mcp_gate.client ./data/sandbox     # drive it: logs, progress, sampling, a blocked attack
```

See [`RESULTS.md`](RESULTS.md) for captured output — every block there is real
command output, regenerated by `scripts/make_results.py`, never hand-edited.

## Notes

- All data under `data/` is fictional; `data/out_of_bounds/secret.txt` is a labeled
  stand-in for "a file the server must never serve."
- Part of the `-gate` family: nothing ships until it passes a gate.