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) | 100% (26/26) | 26 |
| Benign utility retained at zero approvals | 94.3% (33/35) | 35 |
| ↳ benign calls actually denied | 0% | 35 |
| Declaration verification | 100% / 100% (detection / fidelity) | 5 |
| MCPTox (detection only — see caveat) | 87.8% (424/483) | 483 |
| MSB (detection only; all from L4 task scoping, L1 flagged none) | 98.2% (1,964/2,000) | 2,000 |
| False positives (server onboarding) | 50% (2/4) | 4 |
| Inconclusive | 0 | — |
| Unit tests | 812 passing; up to 3 skipped without Docker or the MSB corpus | — |
Reference points: ClawGuard 89.50% MCPTox / 94.21% MSB and 87.4% utility at zero approvals; 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 424 defences there are regex over manifest text.
- Containment at 100% means a corpus that has stopped failing, not a solved problem. It was 91.3% until the two escapes it found were fixed. Its worth is in what it caught — four confinement bugs, two scorer defects and one wrong diagnosis in our own limitations document — not in its current score.
- The 50% false-positive rate is now deterministic, and that is an improvement. It used to read 25–50% depending on whether
mcp-fetch's probe happened to fork during profiling; three-trial profiling means the fork is always seen, so the verdict is always the same. The number got worse and more truthful together.- 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 metric that varied was the false-positive rate, caused by a tool whose own probe fired inconsistently — closed since by repeat-trial profiling.
Architecture
Two phases sharing one SQLite trust store; they never call each other.
Onboarding — POST /register, once per server
- L1 static scan — 44 rules over names, descriptions and schemas.
Three-valued: an attack denies, something merely suspicious quarantines.
Only 27 of the 44 score on a manifest; the 17 behaviour rules (secret
access, command execution, exfiltration) are recorded as signals there and
score only on tool responses.
A finding refuses the tool that carries it, not the whole server. Because
honest documentation can match the same patterns ("You MUST confirm with
the user before calling this tool"), every refusal comes with a
review_token: read the finding, and if it is ordinary documentation, register again with the token inacknowledged_findings. The token is tied to the exact text you reviewed, and accepting it waives only the static verdict; profiling, verification and confinement still apply. - L2 live profiling — the server runs under
strace -f -yy -tttinsidedocker run --cap-drop ALL --cap-add SYS_PTRACE --read-only, driven over real MCP.SYS_PTRACEis the one capability kept, becausestracecannot trace without it. The profiling container has network access: a server that installs itself at launch needs it, and a connection has to complete to be recorded. Registered environment values are replaced by canaries for this run (see below). 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": "get_current_time",
"command": ["docker","run","-i","--rm","mcp/time"],
"image": "mcp/time",
"test_tool": "get_current_time",
"test_args": {"timezone": "Asia/Kolkata"}
}
mcp/time is the reference time server from Docker's MCP catalog
(docker pull mcp/time). command is how you would start the server yourself;
the gateway reads the image's own ENTRYPOINT for profiling. A full walkthrough
with real output is in GETTING_STARTED.
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 never treated as
permission; honouring them that way would let a malicious server grant itself
anything. They are read as claims to check instead: readOnlyHint: true
followed by an observed write, or openWorldHint: false followed by an observed
connection, is a declaration violation in the server's own words. Only those
two negative claims narrow anything (see scanner/declaration_inference.py).
Since 0.4.0 — a tool that declares no
mcpzt:envgrant receives no environment variables at all. It used to inherit everything the operator registered, which made declaring nothing the cheapest way to be trusted with everything; the containment corpus recoveredDB_PASSWORDandAPI_TOKENthat way. If a tool stops finding its configuration after upgrading, add the variables tomcpzt:env— or setMCPZT_ENV_PASSTHROUGH=1for an unmodified third-party server that cannot publish annotations.
Registered environment values are not given to a server while it is being profiled. Profiling runs a server nobody has judged yet, with network access, so each registered variable is replaced by a canary token of the same name; forwarding one is recorded as evidence. A server that cannot start without a working credential will fail onboarding with a message saying so — set
MCPZT_PROFILE_REAL_ENV=1to profile it with the real values.
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
/tools · /logs · /verdicts · /profiles · /enforcement · /egress ·
/confinement · /approvals
Connecting an agent
mcp-ztgateway stdio is an MCP server that MCP clients (Claude Desktop, Cursor,
VS Code) can launch. It shows the client the tools that passed onboarding, with
their approved descriptions, and forwards every call to POST /tool_call, so
every layer above still runs. A refused call comes back as a tool error with the
gateway's reason. Start the gateway with serve, then add to the client's config:
{ "mcpServers": { "zero-trust-gateway": { "command": "mcp-ztgateway", "args": ["stdio"] } } }
Setup for each client is in GETTING_STARTED §7.
Code can also call POST /tool_call directly, which is what the evaluation
harness does.
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.5.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.5.1.tar.gz | 238.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_ztgateway-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 410.0 kB
Release files / mcp_ztgateway-0.5.1.tar.gz
| Download URL | mcp_ztgateway-0.5.1.tar.gz |
|---|---|
| Size | 238.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b59640ab9cab2c0c8b314803265f2f40e273d72fa6543a0c8c43235e74c3462a
|
|
BLAKE2b-256 checksum How to use checksums |
b6a0f82ac41d5a42d81e4f704995ab875c117cf1854ae89c204e5f3bffa31745
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency logRelease files / mcp_ztgateway-0.5.1-py3-none-any.whl
| Download URL | mcp_ztgateway-0.5.1-py3-none-any.whl |
|---|---|
| Size | 172.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
45f441fdfe0f3ce02ee092af29b30d9465befa005b69c42c48499c67f99cea12
|
|
BLAKE2b-256 checksum How to use checksums |
3781fbd90f8a26a2428648e304a4d99387b2f1a12744ee715b6bb3a227aef3e8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.
Transparency log