Zero-trust, risk-aware gateway proxy for the Model Context Protocol
Project description
PortunusMCP
A policy-enforcing gateway proxy for the Model Context Protocol (MCP) — identity-scoped tool visibility, schema-drift ("rug pull") detection, per-call risk scoring, and a tamper-evident signed audit trail, with no changes required to the upstream MCP server.
A low-privilege identity sees only the tools it's allowed. The upstream server rug-pulls its own schema mid-session. The gateway classifies the drift as Critical, blocks the call, and holds it for human re-approval. A byte-identical replay is rejected. Finally, a draft policy is simulated against the traffic that just happened.
Why
MCP defines a JSON-RPC 2.0 transport between LLM clients and tool servers, but deliberately leaves authorization, auditability, and integrity out of scope — it assumes the deploying org builds that layer. In practice almost nobody does, which leaves three concrete gaps:
- No identity-scoped tool visibility. Any client that can reach a server gets the server's full
tools/list. There is no native "this user should only see a subset of tools." - Rug pulls. A server can change a tool's schema after a human approved it in a prior session, with nothing to detect the drift.
- No audit trail. Nothing records which identity invoked which tool with which arguments, under which policy, in a form you could later prove wasn't edited.
PortunusMCP sits between the two and closes those three.
Client compatibility
The upstream server is proxied unchanged. The default bearer mode works without client source changes when the client can attach X-PortunusMCP-Key to a remote MCP connection.
| Client | Result | Evidence |
|---|---|---|
| Claude Desktop 1.20186.0 | Incompatible with bearer | The remote custom connector could not attach the header; it received 401, then attempted OAuth discovery and dynamic client registration. |
| Cursor 3.13.10 | Compatible | The user-level configuration below connected and tools/list served send_email and read_inbox while pruning delete_mailbox; the signed audit row recorded the same set. |
Python MCP SDK 1.28.1 ClientSession |
Compatible | The stock streamable-HTTP client produced the same pruned tools/list; the integration suite exercises this path end to end. |
These are point-in-time results from 2026-07-27 on macOS 15.7.3 arm64. Cursor's tested user-level ~/.cursor/mcp.json entry was:
{
"mcpServers": {
"portunusmcp": {
"url": "https://gateway.example.com/mcp/default",
"headers": {
"X-PortunusMCP-Key": "${env:PORTUNUSMCP_API_KEY}"
}
}
}
}
Launch Cursor with PORTUNUSMCP_API_KEY available in its environment; do not put the raw key in a committed file. The equivalent SDK connection is:
import os
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
headers = {"X-PortunusMCP-Key": os.environ["PORTUNUSMCP_API_KEY"]}
async with streamable_http_client(
"https://gateway.example.com/mcp/default", headers=headers
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
Auth posture is per-identity (ROADMAP.md item 34): identities that can adopt a small signing client opt into signed mode, where the request carries a non-secret key id plus an HMAC over the call — no credential on the wire at all, which is what makes replay protection real (a captured request cannot be re-signed with a fresh nonce). The tradeoff is honest: bearer = stock client plus header configuration, key rides the request; signed = custom client, capture-proof.
Architecture
Every box inside the gateway is a module in services/gateway/ with the same name; numbered stages are the decision pipeline in ARCHITECTURE.md §4.2 order. Sequence, deployment, and data-flow diagrams live in §4.4–§4.7.
graph TD
Client["MCP Client"] -->|"Streamable HTTP"| Interceptor
subgraph Gateway["PortunusMCP Gateway process"]
Interceptor["JSON-RPC Interceptor + Session Manager"]
Interceptor --> Replay["1 Replay Guard"]
Replay --> Auth["2 Auth / Identity"]
Auth --> Policy["3+4 Policy Engine: RBAC + ABAC conditions + versioning"]
Policy --> Drift["5 Drift Detector"]
Drift --> Risk["6 Risk Engine"]
Risk --> Validator["7 Param Validator"]
Validator --> UpClient["Upstream Client"]
Interceptor --> SchemaCache["Schema Cache + Pruner (tools/list)"]
Interceptor --> AuditW["Audit Log Writer (hash-chained, ECDSA-signed)"]
Approvals["Approvals lifecycle (admin API)"]
Explainer["Decision Explainer (admin API)"]
Simulator["Policy Simulator (admin API)"]
end
UpClient --> Docker["Local Docker daemon"]
Docker --> Srv["Hardened upstream container (one per session)"]
Replay --> Redis[("Redis: nonces, schema cache, risk counters, session TTL")]
Risk --> Redis
SchemaCache --> Redis
Policy --> PG[("Postgres: audit_log, policy_versions, tool_baselines, approvals")]
Drift --> PG
AuditW --> PG
Approvals --> PG
Explainer --> PG
Simulator --> PG
Policy --> Rev["policy root: active file, staging journal, revision snapshots"]
Simulator --> Rev
Verifier["audit_verifier sidecar (separate process, read-only chain walk)"] --> PG
Multiple upstreams are registered in the policy's servers: block as typed container specifications, versioned and rolled back with the rest of the policy. Clients connect to /mcp/<server_id>; one session owns one hardened upstream container. RBAC grants, drift baselines, schema caches, risk counters, approvals, and runtime fingerprints are keyed on the real server_id.
servers:
github:
image: "ghcr.io/acme/github-mcp@sha256:..."
command: ["python", "-m", "github_mcp"]
env:
GITHUB_TOKEN: PORTUNUSMCP_UPSTREAM_GITHUB_TOKEN
volumes:
- source: "portunusmcp-upstream-dev-github-config"
target: "/config"
network: none
resources: {memory_mb: 256, cpus: 0.5, pids: 64}
The local Docker daemon, every referenced image, and every named volume are preflighted before policy activation; images are never pulled at runtime. Containers run as UID 65532 with a read-only root, a restricted /tmp, no new privileges, no capabilities, and bounded memory/CPU/PIDs. Environment values can only come from host variables prefixed PORTUNUSMCP_UPSTREAM_; gateway database, signing, TOTP, and audit-key secrets are not inherited. Network defaults to none. See ADR-007.
Threat model (summary)
The full version, including the assumptions the whole model rests on, is in THREAT_MODEL.md. It is deliberately explicit about what is not covered — an honest scope boundary is worth more than an implied claim of total coverage.
| Threat | Protected? | How / why not |
|---|---|---|
| Unauthorized tool access by a known identity | Yes | RBAC + ABAC policy resolution |
| Rogue / rug-pulling MCP server (schema mutation) | Yes | Drift Detector classifies mutations; High/Critical blocks at tools/call until re-approval |
| Contextually risky calls by an authorized identity | Yes | Risk Engine — challenge / human approval / deny, by score band |
| Audit-log tampering | Yes | Hash chain + per-row ECDSA signature; independently verified by a sidecar holding only the public key |
| Replay of a captured request | Yes for signed / Partial for bearer |
A signed request carries no credential: a byte-identical replay is deduped (DENY_REPLAY) and a fresh nonce cannot be re-signed (401 at the edge). bearer keeps opportunistic dedup only — the API key travels in the captured request |
| Tool Poisoning (adversarial text in descriptions) | Partial | A description changed after approval blocks until re-approval (default High, item 36a); first-contact baselines are heuristically scanned — a hit is audited (BASELINE_FLAGGED) and raises every later call's risk, but flags never block and novel phrasing evades pattern lists. Descriptions still reach the LLM verbatim — Partial is the ceiling |
| Compromised registered upstream reading gateway secrets | Yes (scoped) | Per-session hardened containers receive a minimal allowlisted environment and no gateway secrets directory, Docker socket, or DB/Redis credentials; host/gateway compromise remains out of scope |
| DNS rebinding against Streamable HTTP | Yes (scoped) | The MCP SDK validates every /mcp/* Host and supplied Origin against configured allowlists before auth or parsing; the deployment must configure its real public names |
| Authenticated resource exhaustion / stuck tools | Partial | Per-identity sessions, in-flight calls, fixed-window rate limits, bounded bodies/depth, and a 60s call deadline cap one identity; many identities can still exhaust the host, and a deadline cannot undo an upstream side effect already started |
| Unauthenticated credential stuffing / auth-path availability | Partial | Bad bearer/signed credentials are fixed-window throttled by trusted-proxy-resolved source across MCP/admin and raise an alert; address rotation, initial concurrent bursts, and shared-NAT collateral remain |
| Prompt injection via tool results | Partial | A protocol-layer gateway can log and rate-limit but not semantically evaluate result content — client/agent-framework responsibility |
| Stolen API key | Partial | Behavioral risk factors reduce blast radius; a key alone can't be distinguished from its holder. A signed identity's secret never appears on the wire at all — stealing it means compromising a host environment |
| Compromised gateway host | No | The attacker has the signing key — an infra hardening problem, not an application one |
| Insider admin abusing legitimate access | No | Attributable and tamper-evident after the fact, not prevented; two-person activation is designed, not built |
Run the demo
cp .env.demo.example .env.demo
python scripts/generate_signing_key.py # once: audit signing keypair (gateway won't start without it)
python scripts/run_demo.py # resets demo state, mints keys, writes policies/demo-policy.yaml, waits
# First set UPSTREAM_RUNTIME_NAMESPACE and DOCKER_GID in .env.demo. Find the GID with:
# docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker:29.6.1-cli \
# stat -c '%g' /var/run/docker.sock
#
# In another terminal (the rogue upstream container lives in the policy's servers: block):
POLICY_FILE=policies/demo-policy.yaml \
docker compose --env-file .env.demo -f compose.demo.yml up -d --build
# when the driver prompts — the rug pull, deliberately on screen:
curl -X POST localhost:9800/_admin/apply_mutation
# when it prompts again — hot-load the tightened v2 policy for the simulation finale:
docker kill -s HUP portunusmcp-demo-gateway-1
The driver connects as developer — a stock MCP client, no custom _meta on the first call (sees only send_email / read_inbox; the destructive delete_mailbox is absent, not marked), then as ops-admin (sees all three). It makes a successful call, waits for the operator's mutation curl, then shows the drift classified Critical and blocked (DENY_DRIFT), the admin re-approval, and the same call succeeding after a TOTP step-up if current risk requires it. It then shows the signed ci-agent's captured request replayed byte-identically (DENY_REPLAY) and with a forged fresh nonce (HTTP 401), followed by Policy Simulation of the v2 draft (would_now_deny: 2) and the hash-chained audit receipts.
All seven beats are live — nothing is scripted or faked. The mutation fires only when the operator actually calls that endpoint, so the adversarial event is visible on camera rather than happening off-screen on a timer.
If later demo policy v1 content conflicts with recorded state, the fail-closed activation check refuses startup and names the dev-only reset: docker compose --env-file .env.demo -f compose.demo.yml run --rm gateway python scripts/reset_dev_state.py --yes. The check is not weakened.
Development setup: python3.12 -m venv .venv && .venv/bin/pip install -e ".[dev]", copy .env.demo.example to .env.demo, set its required Docker namespace/GID values, build the local upstream image with docker build -t portunusmcp:dev ., then run .venv/bin/pytest. The mounted Docker socket is root-equivalent access to the host; only trusted operators should receive a shell in the gateway container. Full command list in CLAUDE.md.
Self-host the production profile
compose.prod.yml is a hardened single-host, single-gateway-replica profile. It is intentionally not selected by a bare docker compose up: every invocation names the production file and env explicitly.
cp .env.prod.example .env.prod
cp .env.prod.gateway.example .env.prod.gateway
chmod 600 .env.prod .env.prod.gateway
# Fill every required password, digest, allowlist, path, namespace and Docker GID.
docker compose --env-file .env.prod -f compose.prod.yml config
docker compose --env-file .env.prod -f compose.prod.yml pull
docker compose --env-file .env.prod -f compose.prod.yml up -d
The tagged production bundle fills every service image with the exact manifest digest
tested by the release workflow; the source-tree env example keeps placeholders for
development between releases. Every production servers: image should likewise be
repository@sha256:... and must already exist on the host because runtime pulling is
disabled.
Production now mounts two operator-owned, writable roots into the gateway:
POLICY_DIR_HOST(mode0700, UID/GID 1000) containspolicy.yaml; the gateway creates crash-recovery staging/journal files andrevisions/beneath it.AUDIT_SIGNING_KEY_DIR(mode0700, UID/GID 1000) containsaudit_signing_key.pem; the gateway maintainspublic/<fingerprint>.pub.pemand the rotation journal beneath it. The verifier receives only thatpublic/directory read-only.
Private files and journals are mode 0600; archived public keys are 0444. .env.prod.gateway contains only policy-referenced PORTUNUSMCP_UPSTREAM_*, signing and TOTP secrets. Postgres and Redis are password-protected and reachable only on the internal data network; neither publishes a host port.
The gateway binds to 127.0.0.1:${GATEWAY_PORT:-8000}. Put an operator-managed TLS reverse proxy in front of it, set ALLOWED_HOSTS/ALLOWED_ORIGINS to the real public names, and set Uvicorn's comma-separated FORWARDED_ALLOW_IPS to that proxy's IP/CIDR so source auth throttling cannot trust a client-spoofed address. * is appropriate only while this loopback-only trusted-host boundary holds. Internal gateway-to-database traffic is plaintext inside the isolated single-host Compose network.
Optional production monitoring also stays loopback-only and requires a Grafana login:
docker compose --env-file .env.prod -f compose.prod.yml --profile monitoring up -d
Do not scale gateway: session/container handles are in memory and the audit chain has one safe writer. The direct Docker socket remains root-equivalent host access. Named volumes persist data but are not backups; backup/restore, TLS termination, host patching and log shipping remain operator responsibilities.
Resource controls and readiness
GET /health is process liveness only. GET /ready concurrently checks Postgres, Redis, the active audit key/keyring/recovery state, and policy-promotion recovery state, returning 200 only when all four are ready (otherwise 503):
{"status":"ready","checks":{"postgres":"ok","redis":"ok","signing":"ok","policy":"ok"}}
The item-40/43 edge settings are environment-backed; all numeric values must be positive, and allowlists are JSON arrays:
| Setting | Default |
|---|---|
MAX_MCP_BODY_BYTES |
1048576 |
MAX_JSON_DEPTH |
32 |
MAX_SESSIONS_PER_IDENTITY |
3 |
MAX_INFLIGHT_CALLS_PER_IDENTITY |
5 |
TOOL_CALL_RATE_LIMIT / TOOL_CALL_RATE_WINDOW_SECONDS |
60 / 60 |
AUTH_FAILURE_RATE_LIMIT / AUTH_FAILURE_RATE_WINDOW_SECONDS |
5 / 300 |
TOOL_CALL_DEADLINE_SECONDS |
60 |
READINESS_TIMEOUT_SECONDS |
1.0 |
ALLOWED_HOSTS |
["localhost:*","127.0.0.1:*"] |
ALLOWED_ORIGINS |
[] |
A missing Origin remains valid for non-browser MCP clients. Any supplied Origin must be listed.
Operator CLI
The package installs portunusmcp, a stdlib-only operator client for the authenticated /admin API. Put the admin credential only in the environment; it is never accepted as a command-line argument:
pipx install portunusmcp==0.1.0
export PORTUNUSMCP_URL=https://gateway.example.com
export PORTUNUSMCP_ADMIN_KEY='shown-once-admin-key'
portunusmcp approvals list
portunusmcp baselines list --kind all
portunusmcp baselines show default send_email
portunusmcp decisions get 42
portunusmcp policy validate candidate.yaml
portunusmcp policy simulate candidate.yaml --window 2026-07-01..2026-07-27
portunusmcp --yes policy rollout candidate.yaml
portunusmcp --yes policy rollback 3
portunusmcp keys audit-status
portunusmcp --yes keys rotate-audit
portunusmcp audit export --from-seq 1 --to-seq 500 --output audit.ndjson
.venv/bin/python scripts/verify_audit_chain.py --export audit.ndjson
Mutations confirm interactively unless --yes; JSON-mode mutations require --yes. --json emits stable pretty JSON for automation. Plain HTTP is accepted only for localhost, 127.0.0.1, or ::1; remote operators must use HTTPS, optionally with --ca-file.
Policy rollout and rollback use one crash-recoverable journal: validate and preflight, record the revision, write the old-policy-signed POLICY_ACTIVATED handoff, atomically promote policy.yaml, then swap memory. SIGHUP follows the same path by consuming adjacent policy.next.yaml; a rejected candidate stays there for correction. Audit-key rotation similarly writes AUDIT_KEY_ROTATED with the old key before promoting the new private key. Historical public keys are fingerprint-addressed and retained so old rows remain verifiable.
Approvals and flagged baselines are bounded review queues (100 rows per response). Audit export is verified before download and emits self-contained NDJSON: one manifest with the exact public-key bundle followed by inclusive, gap-free rows. A partial range proves its internal chain and signatures but explicitly does not attest the omitted prefix.
Performance
Measured, not estimated, on 2026-07-27 from the item-43 working tree based on 98e4a80, with real per-session Docker upstreams and the full §4.2 pipeline plus item-40 edge/rate/deadline and item-43 source-auth controls active. Methodology, hardware, and reproduction steps: ARCHITECTURE.md §9.
| Scenario | Direct call | Through gateway | Overhead |
|---|---|---|---|
| Single call, cached schema | 0.25 / 0.21 / 0.43 / 0.59 ms | 16.17 / 15.59 / 18.29 / 31.35 ms | 15.93 / 15.38 / 17.85 / 30.76 ms |
| Single call, cold schema cache | 0.25 / 0.21 / 0.43 / 0.59 ms | 22.14 / 19.26 / 34.01 / 72.71 ms | — |
| 10 concurrent sessions (p95) | — | 229.20 ms | — |
| 50 concurrent sessions (p95) | — | 1171.36 ms | — |
| 100 concurrent sessions (p95) | — | 5376.84 ms | — |
tools/list payload (pruned identity) |
744 B (unpruned) | 425 B | 42.9% reduction |
Latencies are mean / p50 / p95 / p99. The high-concurrency p95 includes one hardened Docker container per session as well as the synchronous fail-closed audit write; both are known ceilings, discussed in ARCHITECTURE.md §10.
Container initialization: first 899.89 ms; next 20 p50 338.03 ms / p95 802.46 ms. Peak RSS after the 100-session run was 153 MiB (gateway + harness); initialized upstream containers used 1,095 MiB in aggregate.
Tech stack
| Layer | Choice | Why |
|---|---|---|
| Runtime | Python 3.12, FastAPI + Starlette, async throughout | First-party MCP SDK; async-native for a proxy that's almost entirely I/O wait |
| MCP handling | mcp official Python SDK |
Don't hand-roll JSON-RPC framing — intercept at the session layer instead |
| Storage | PostgreSQL 16 (audit chain, baselines, approvals, policy versions) + Redis 7 (nonces, schema cache, risk counters) | Relational integrity matters for a hash chain; Redis for everything with a TTL |
| Policy | YAML + Pydantic, with a hand-rolled ABAC expression evaluator (ast.parse + node whitelist, no eval) |
Git-diffable and validated at load. Deliberately not Turing-complete — no loops, no recursion, no code execution (ADR-004 on why not OPA) |
| Risk | Fixed weighted factor list + behavioral Redis counters — no ML, by design | A security decision an operator can't explain is a security decision they can't trust |
| Crypto | SHA-256 hash chain + ECDSA P-256 per-row signatures | The chain alone is regenerable by anyone with DB write access; the signature isn't |
| Ops | Docker Compose, Prometheus + Grafana (opt-in profile), structlog JSON logs, GitHub Actions (ruff / mypy strict / pytest with an 80% coverage gate) |
Documentation
ARCHITECTURE.md— decision pipeline, every component in depth, failure modes, observability, benchmarks, scalability, testing, deploymentTHREAT_MODEL.md— what's protected, what isn't, and the assumptions underneathSECURITY.md— vulnerability disclosureCOMPATIBILITY.md— supported runtimes, images, clients, and tested platformsUPGRADING.md— supported upgrade and rollback procedureCHANGELOG.md— release historydocs/adr/— one file per consequential decision, including why Envoy, OPA, Kong, NGINX, sidecars, and client-SDK middleware were each rejected for v1ROADMAP.md— the build order as a living checklist
Roadmap
Phases 1–6 are complete. Phase 6 added the upstream isolation boundary, bounded lifecycles/readiness, explicit demo/production profiles, the operator CLI/API, source-scoped authentication throttling, and fail-closed duplicate identity/index validation.
Each item in ROADMAP.md states the check that proves it done and the threat-model row it upgrades — an item is finished when that row can be honestly rewritten, not when the code merges.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file portunusmcp-0.1.0.tar.gz.
File metadata
- Download URL: portunusmcp-0.1.0.tar.gz
- Upload date:
- Size: 3.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b186b9994661faf8c16009cc712055d33c28aa466960bbead88a51d1a4b1b7b
|
|
| MD5 |
9f89607c38f205e79627b188458bd692
|
|
| BLAKE2b-256 |
9d0800e288ab12c3183fe2179e7ba8e6b92fc4c9c4988b3153211075917cbd19
|
Provenance
The following attestation bundles were made for portunusmcp-0.1.0.tar.gz:
Publisher:
release.yml on BashaarJavaid/PortunusMCP
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
portunusmcp-0.1.0.tar.gz -
Subject digest:
5b186b9994661faf8c16009cc712055d33c28aa466960bbead88a51d1a4b1b7b - Sigstore transparency entry: 2260573801
- Sigstore integration time:
-
Permalink:
BashaarJavaid/PortunusMCP@d5fe9e12514b1feba9562ac7f1b16024097410ea -
Branch / Tag:
refs/heads/main - Owner: https://github.com/BashaarJavaid
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d5fe9e12514b1feba9562ac7f1b16024097410ea -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file portunusmcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: portunusmcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 106.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7efec61d3a169c4671fe3bb4b7dfb96186abefd6febdaec0e05c8571431393cf
|
|
| MD5 |
c4f8f9a62cb6905404cb91e16e9729b5
|
|
| BLAKE2b-256 |
0f184d53bab4e69114543480742b03b31455d3021c656deed9f0243162e3ca99
|
Provenance
The following attestation bundles were made for portunusmcp-0.1.0-py3-none-any.whl:
Publisher:
release.yml on BashaarJavaid/PortunusMCP
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
portunusmcp-0.1.0-py3-none-any.whl -
Subject digest:
7efec61d3a169c4671fe3bb4b7dfb96186abefd6febdaec0e05c8571431393cf - Sigstore transparency entry: 2260573835
- Sigstore integration time:
-
Permalink:
BashaarJavaid/PortunusMCP@d5fe9e12514b1feba9562ac7f1b16024097410ea -
Branch / Tag:
refs/heads/main - Owner: https://github.com/BashaarJavaid
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d5fe9e12514b1feba9562ac7f1b16024097410ea -
Trigger Event:
workflow_dispatch
-
Statement type: