Skip to main content

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 734 passing, 1 skipped (needs Docker) —

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

  1. 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 in acknowledged_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.
  2. L2 live profiling — the server runs under strace -f -yy -ttt inside docker run --cap-drop ALL --cap-add SYS_PTRACE --read-only, driven over real MCP. SYS_PTRACE is the one capability kept, because strace cannot 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 own inputSchema. strace is injected automatically if the image lacks it, so Node, Python and Go servers all profile without special-casing.
  3. Capability mapping — syscalls → {network, filesystem, subprocess, env}, split at the MCP initialize boundary so package-manager bootstrap is not blamed on the tool.
  4. L3 comparator + declaration verification — observed vs declared. A tool that exceeds its own published grant is denied, and no renaming evades it.
  5. 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 initialize under 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 stdio tools (npx, python, compiled binaries).
  • Unsupported — anything running remotely, or launched by another application. A native desktop app exposing SSE on 127.0.0.1 cannot 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.

"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:env grant 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 recovered DB_PASSWORD and API_TOKEN that way. If a tool stops finding its configuration after upgrading, add the variables to mcpzt:env — or set MCPZT_ENV_PASSTHROUGH=1 for 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=1 to 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.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mcp-ztgateway 0.5.0
File Size Uploaded
mcp_ztgateway-0.5.0.tar.gz 235.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mcp-ztgateway 0.5.0
File Interpreter ABI Platform
mcp_ztgateway-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 407.4 kB

Release files / mcp_ztgateway-0.5.0.tar.gz

Download URL mcp_ztgateway-0.5.0.tar.gz
Size 235.5 kB
Tags Source
SHA-256 checksum
How to use checksums
a57376b8d806fa16cda3a8efc5fb4114eb6312261ff9d02543cd911959b62977
BLAKE2b-256 checksum
How to use checksums
4cca4356859abd9dd85af5cff286588a47e3542a07de0c4b54b778eea7f15a13
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 15, 2026.

Transparency log

Release files / mcp_ztgateway-0.5.0-py3-none-any.whl

Download URL mcp_ztgateway-0.5.0-py3-none-any.whl
Size 171.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
543a86254f8d2cb151d09fdc2d708e8b06af9bfed91f300635f9afa2bcd73d61
BLAKE2b-256 checksum
How to use checksums
ebf61d563745bb7a468d49943f1a5295286a66fb0bd85509b0d2690c8fd4e122
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 15, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page