MCP Zero-Trust Gateway
A security gateway between an LLM agent and the MCP (Model Context Protocol) tool servers it uses, built on one principle: an MCP server's manifest is attacker-controlled and therefore unverifiable, but its syscalls are not.
Every tool is inspected before admission, profiled under strace in a hardened
container to see what it actually does, checked against what it claimed, and
then confined per call at the kernel level. It runs entirely on your machine —
the trust store is a local SQLite file and no telemetry leaves the host.
The problem
An agent discovers tools by reading each server's self-description
(tools/list), then calls them with its own privileges. It treats that
description as true.
Three things follow, and each is a real attack:
- A description is instruction-adjacent text inside the model's context, read on every turn. Hide instructions there and the agent obeys them.
- A tool response re-enters the agent's reasoning as a trusted observation. Whoever controls retrieved content controls the agent's next move.
- Tools are installed like packages but run with ambient host authority — your files, your environment variables, your network.
What this does about it
Declare → verify → confine. A tool publishes a capability declaration; kernel syscall profiling verifies it before admission; per-tool resource grants enforce it at runtime. An attacker must either declare the attack — and be confined to it — or be caught exceeding the declaration.
Comparable systems each hold one half of that loop: sandlock.mcp confines
against declarations it never checks, SandScope observes flows it cannot stop,
mcp-sec-audit inspects source it never runs, ClawGuard authorises calls it cannot
contain.
Results
Measured against real containers, real MCP servers and real strace. Every
figure is traceable to a committed row — eval_results.csv,
evidence/.
| Corpus | Result | n |
|---|---|---|
| Runtime DSR (Appsecco + rug-pull + evasion) | 84.6% (11/13) | 13 |
| ↳ Phase 1 alone (Appsecco) | 81.8% (9/11) | 11 |
| Containment (purpose-built corpus) | 87.5% (7/8) | 8 |
| Declaration verification | 100% / 100% (detection / fidelity) | 5 |
| MCPTox (detection only — see caveat) | 88.0% (425/483) | 483 |
| MSB (detection only, small n) | 100% (5/5) | 5 |
| False positives | 25–50% (varies by run) | 4 |
| Inconclusive | 0 | — |
| Unit tests | 389 passing, 29 skipped | — |
Reference points: ClawGuard 89.50% MCPTox / 94.21% MSB; mcp-sec-audit 74.7% MCPTox and 0% on JavaScript servers.
Read the caveats before quoting any of this. MCPTox is not measured the way ClawGuard measures it — we start no agent and no container, and 346 of our 425 defences there are regex over manifest text. The false-positive rate is not stable. Two of the three runtime phases are n = 1. All of it is spelled out in
MD_files/Paper/.
Determinism: across five consecutive runs from a wiped trust store, 503 of 503 scored rows produced identical verdicts. The one varying metric is the false-positive rate, and the cause is a tool whose own probe fires inconsistently — not the gateway.
Architecture
Two phases sharing one SQLite trust store; they never call each other.
Onboarding — POST /register, once per server
- L1 static scan — 43 rules over names, descriptions and schemas. Three-valued: an attack denies, something merely suspicious quarantines.
- L2 live profiling — the server runs under
strace -f -yy -tttinsidedocker run --cap-drop ALL --read-only, driven over real MCP. Up to 8 tools are exercised with arguments synthesised from each tool's owninputSchema.straceis injected automatically if the image lacks it, so Node, Python and Go servers all profile without special-casing. - Capability mapping — syscalls →
{network, filesystem, subprocess, env}, split at the MCPinitializeboundary so package-manager bootstrap is not blamed on the tool. - L3 comparator + declaration verification — observed vs declared. A tool that exceeds its own published grant is denied, and no renaming evades it.
- Seccomp compilation — the capability verdict (not the raw trace) is
compiled into a syscall deny-list by subtraction, then smoke-tested: the
tool must complete a real MCP
initializeunder it, or the gateway degrades a tier and says so.
Runtime — POST /tool_call, every call
Inbound DLP → trust-store lookup → L4 policy (three-valued: allow / deny / hold for approval) → response-provenance gate → fresh confined container → manifest re-attestation → outbound DLP → log.
Each call gets only the paths its verified grant names, only the environment
variables it declared, --network none unless it declared hosts, and its own
seccomp profile.
What is novel here
- Response-provenance gating — asks where an argument came from rather than what it means, so it is paraphrase- and encoding-proof.
- Continuous manifest attestation — catches a description that changes after approval (CVE-2025-54136 class), separating "changed" from "changed into an attack".
- Declaration verification as a measurable axis — comparable systems score 0% on it by construction.
Scope: process ownership
Kernel-level protection requires the gateway to be the process that spawns the tool.
- Supported — any MCP server the gateway launches as a child process:
effectively all
stdiotools (npx,python, compiled binaries). - Unsupported — anything running remotely, or launched by another
application. A native desktop app exposing SSE on
127.0.0.1cannot be profiled, because we did not spawn it.
This is the inverse of protocol-layer defences like ClawGuard, which inspect JSON-RPC and work on servers they never own but cannot constrain execution. The two are complementary, with inverse blind spots.
Install
Docker Desktop is required for L2 profiling.
pip install mcp-ztgateway
mcp-ztgateway --port 8000
From source:
git clone https://github.com/nabrahma/MCP_Zero-Trust_Gateway_BTP.git
cd MCP_Zero-Trust_Gateway_BTP
python -m venv venv && ./venv/Scripts/activate
pip install -r requirements.txt
./start_gateway.ps1
Usage
Onboard a tool
POST /register
{
"name": "read_file",
"command": ["docker","run","-i","--rm","real-mcp-image","mcp-server-filesystem","/app"],
"image": "real-mcp-image",
"test_tool": "read_file",
"test_args": {"path": "/app/package.json"}
}
Returns a verdict — allow, allow_with_warning, needs_declaration,
quarantine or deny — plus the observed capabilities, the compiled seccomp
profile, and a paste-ready declaration drafted from what the tool was
actually seen doing.
needs_declaration means "we could not classify this tool, so we have no basis
to judge it". It is non-runnable like quarantine, but it is not an accusation.
Declare capabilities (recommended)
"annotations": {
"mcpzt:fs_read": ["/workspace"],
"mcpzt:net_allow_hosts": ["api.example.com"],
"mcpzt:subprocess": ["node"],
"mcpzt:env": ["WORKSPACE"]
}
Standard MCP hints (readOnlyHint, openWorldHint) are deliberately ignored
— they are claims by the tool author, and honouring them would let a malicious
server grant itself anything.
Scope a session to a task (optional)
POST /session { "session_id": "s1", "objective": "Read ./report.md and summarise it" }
Calls outside that objective are held for approval, not denied. Approve with
POST /approvals/{id}; the approval authorises the exact arguments a human saw
and is consumed on use.
Other endpoints
/logs · /verdicts · /profiles · /enforcement · /egress ·
/confinement · /approvals
Docker Compose
docker-compose up --build # gateway :8000, dashboard :5173
Live profiling needs the host Docker daemon. The compose image deliberately has no Docker socket, so run the gateway on the host for evaluation.
Limitations
Stated properly in
MD_files/Paper/LIMITATIONS.md. The ones that
matter most:
- Response-content attacks are not covered. A tool that does its advertised job and returns fabricated data crosses no boundary here. This is the open frontier, and every comparable system shares it.
- Dynamic profiling is evadable and non-deterministic. A dormant payload is invisible at onboarding; containment is what makes that survivable.
- Corpora are small. No statistical significance is claimed anywhere.
Documentation
Start at MD_files/README.md.
| Want to… | Read |
|---|---|
| Join the project | TEAMMATE_HANDOVER.md |
| Write the paper | MD_files/Paper/ |
| Check a number | Paper/RESULTS.md |
| Review the design | ARCHITECTURE.md |
| Reproduce results | Paper/REPRODUCIBILITY.md |
How to read our numbers. Every defence names the layer that produced it.
Crashes and timeouts are INCONCLUSIVE and stay in the denominator — never
counted as defences. DSR is never quoted without the false-positive rate. All raw
data is committed, so if a number looks too good, open the row and check it.
That discipline exists because this project once reported a 100% Defence Success Rate that was an artefact of two measurement bugs cancelling out — a seccomp compiler that made every container fail to boot, and a harness that scored the crashes as defences. The post-mortem is GROUND_TRUTH_AUDIT.md.
Tech stack
Python · FastAPI · official MCP SDK (stdio) · Docker · strace · seccomp ·
Landlock (opt-in) · SQLite · React dashboard
License
MIT — see LICENSE.
Metadata
Release files for mcp-ztgateway 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_ztgateway-0.3.0.tar.gz | 168.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_ztgateway-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 309.1 kB
Release files / mcp_ztgateway-0.3.0.tar.gz
| Download URL | mcp_ztgateway-0.3.0.tar.gz |
|---|---|
| Size | 168.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6c4f9fe19d79b4050ecb5ba38c052befc68c21a6f0c1a8180937739fd78e73a9
|
|
BLAKE2b-256 checksum How to use checksums |
0d654d55e7ce0f37a28322a1007e027555f6ce30974497b57ddc09aa94376d78
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.5
|
Release files / mcp_ztgateway-0.3.0-py3-none-any.whl
| Download URL | mcp_ztgateway-0.3.0-py3-none-any.whl |
|---|---|
| Size | 140.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
501267a83e65dd6cbccd90c10bbc26da08d8eb4085f89704fb542a3ee4e21010
|
|
BLAKE2b-256 checksum How to use checksums |
4a9d8c9ca829ddb9d34ed261fad3ffeaf1d150a601285ecbc2016290908f3db6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.5
|