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 | 395 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
Python 3.11+ and Docker Desktop, running. Docker is what makes L2 profiling and
runtime confinement possible; without it you get a static scan and nothing else.
You do not need to install strace — it is injected into the tool's image
when missing.
mkdir ztg-workspace && cd ztg-workspace # the gateway writes its DB and
python -m venv .venv # profiles relative to this directory
.venv\Scripts\Activate.ps1 # Windows (source .venv/bin/activate elsewhere)
pip install mcp-ztgateway
mcp-ztgateway doctor # check the machine
mcp-ztgateway serve # http://127.0.0.1:8000
serve is a subcommand — mcp-ztgateway --port 8000 on its own is an error. It
binds localhost with no API key by default; set GATEWAY_API_KEY before binding
anything else.
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.ps1
pip install -e ".[dev]"
mcp-ztgateway serve
New here? MD_files/GETTING_STARTED.md walks a blank machine through to a confined tool call, with the real outputs at each step and a troubleshooting table.
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
Connecting an agent
The gateway is an HTTP service, not an MCP server, so there is nothing to
paste into claude_desktop_config.json or VS Code's mcp.json — no stdio shim
ships in this release. An agent integrates by sending its tools/call envelope
to POST /tool_call instead of connecting to the MCP server directly, which is
what the evaluation harness does. A drop-in adapter for MCP clients is a small,
self-contained piece of work that has not been built yet.
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.1
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.1.tar.gz | 172.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_ztgateway-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 314.6 kB
Release files / mcp_ztgateway-0.3.1.tar.gz
| Download URL | mcp_ztgateway-0.3.1.tar.gz |
|---|---|
| Size | 172.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2a9e2536c0aadad3a72ffb199d3f0a14d4617e983b5a68d0e9d6e4846bc1bfd2
|
|
BLAKE2b-256 checksum How to use checksums |
713923fda23a18fd392a80507f4cc0525a60e0ba4da09dbda7b0845b60b7a769
|
| 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.1-py3-none-any.whl
| Download URL | mcp_ztgateway-0.3.1-py3-none-any.whl |
|---|---|
| Size | 142.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
361a9a69b9cc2b1cab58feb0d01c5e121cb82948f15a81eb417c3e2d292a563d
|
|
BLAKE2b-256 checksum How to use checksums |
ebf4a0a4fc6bfa61430ade3de662e21ac5ef89986f1857ce65a655290db1d6d5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.5
|