Skip to content
Conduit

mcp server

PortunusMCP

Zero-trust, risk-aware gateway proxy for the Model Context Protocol (MCP) — enforces identity-scoped tool access, detects schema drift ("rug pull") attacks, scores tool-call risk, and produces a tampe

BashaarJavaid/PortunusMCPcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "portunusmcp": {
      "command": "uvx",
      "args": [
        "portunusmcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 21, 2026

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.

ci coverage python license

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:

  1. 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."
  2. 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.
  3. 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.

ClientResultEvidence
Claude Desktop 1.20186.0Incompatible with bearerThe remote custom connector could not attach the header; it received 401, then attempted OAuth discovery and dynamic client registration.
Cursor 3.13.10CompatibleThe 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 ClientSessionCompatibleThe 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)"]
        Scaffolder["Policy Scaffolder (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
    Scaffolder --> 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.

ThreatProtected?How / why not
Unauthorized tool access by a known identityEnforce: Yes; Observe: NoRBAC + ABAC block in enforce; observe audits the would-be denial and forwards
Rogue / rug-pulling MCP server (schema mutation)Enforce: Yes; Observe: detects onlyDrift Detector classifies mutations; High/Critical blocks until re-approval only in enforce
Contextually risky calls by an authorized identityEnforce: Yes; Observe: advisoryRisk Engine computes the same score and outcome in both modes; only enforce holds or denies
Audit-log tamperingYesHash chain + per-row ECDSA signature; independently verified by a sidecar holding only the public key
Replay of a captured requestEnforce: Yes for signed / Partial for bearer; Observe: detects onlyThe nonce check still runs and consumes nonces in observe, but a detected replay is forwarded. HTTP signature failures remain 401 in both modes
Tool Poisoning (adversarial text in descriptions)Enforce: Partial; Observe: weaker advisory detectionChanged descriptions block only in enforce; observe still scans, audits, and scores but forwards. Descriptions reach the LLM verbatim in both modes
Compromised registered upstream reading gateway secretsYes (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 HTTPYes (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 toolsPartialPer-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 availabilityPartialBad 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 resultsPartialA protocol-layer gateway can log and rate-limit but not semantically evaluate result content — client/agent-framework responsibility
Stolen API keyPartialSource throttling remains active in both modes; behavioral risk blocks only in enforce. A signed identity's secret never appears on the wire at all
Compromised gateway hostNoThe attacker has the signing key — an infra hardening problem, not an application one
Insider admin abusing legitimate accessNoAttributable and tamper-evident after the fact, not prevented; two-person activation is designed, not built

Quickstart

Open in GitHub Codespaces

Devcontainer and Codespaces

GitHub Codespaces needs no local install: use the badge and wait for creation to finish. For local use, install only Docker, VS Code, and the Dev Containers extension, then run Dev Containers: Reopen in Container. Both paths start a non-root Python 3.12 workspace, a dedicated Docker-in-Docker daemon, and this repository's existing quickstart against the harmless read_file sample. Creation succeeds only after the log contains canonical ALLOW and DENY_RBAC Decisions, a verified audit export, and a healthy doctor result.

Generated state stays in the ignored ./portunusmcp-quickstart/ directory. Rebuilding the devcontainer reuses its namespace, credentials, keys, and database volumes; the post-create script never repairs or destroys existing state. Load credentials without printing them:

cd ./portunusmcp-quickstart
set -a && source ./credentials.env && set +a

If creation fails, its final output includes doctor findings and exact commands. The equivalent state-preserving recovery and explicit destructive reset are:

state=./portunusmcp-quickstart
project="$(awk -F"'" '/^QUICKSTART_NAMESPACE=/{print $2}' "$state/.env.quickstart")"
docker compose --env-file "$state/.env.quickstart" -p "$project" \
  -f "$state/compose.quickstart.yml" up -d --wait --pull never
portunusmcp doctor "$state"

# Destructive: delete the generated named volumes, then the generated files.
docker compose --env-file "$state/.env.quickstart" -p "$project" \
  -f "$state/compose.quickstart.yml" down --volumes
rm -rf -- ./portunusmcp-quickstart

Port 8000 is forwarded silently and remains private by default. In browser Codespaces, use loopback from the integrated terminal (for example, curl http://127.0.0.1:8000/ready): the authenticated *.app.github.dev hostname is deliberately outside quickstart's Host allowlist. Local VS Code can use its localhost:8000 tunnel.

The Docker-in-Docker feature runs the devcontainer privileged, and its Docker daemon gives the workspace root-equivalent control of that environment. Only open trusted repository code and use trusted upstream images; postCreateCommand runs repository code automatically.

Existing Python and Docker

portunusmcp quickstart is the evaluation path from an existing local stdio MCP image to one verified ALLOW and one verified DENY_RBAC. It creates and starts the real PostgreSQL, Redis, migration, gateway, and audit-verifier stack; it is not a SQLite-shaped substitute for the production deployment.

Prerequisites:

  • Python 3.12 with portunusmcp installed from PyPI.
  • Docker Engine and Docker Compose with /var/run/docker.sock available (tested release line: Engine 29.x and Compose 5.x).
  • Linux amd64/arm64 or macOS arm64, running as a non-root user.
  • Internet access for the three immutable release images, plus a pre-existing local image containing the upstream MCP server. Quickstart never pulls the upstream.

For example, against a local image whose echo tool accepts {"text": ...}:

portunusmcp --timeout 300 quickstart \
  --upstream-image my-local-mcp:latest \
  --allow-tool echo \
  --arguments '{"text":"hello"}' \
  --output-dir ./portunusmcp-quickstart \
  --command python -m my_mcp_server

--command must be last; everything after it is passed as argv without a shell. Quickstart resolves the supplied image to its local immutable sha256:... image ID, generates two 256-bit bearer credentials and a fingerprint-addressed audit key, binds the gateway only to 127.0.0.1:8000, disables upstream networking and environment passthrough, then prints the full canonical Decisions after independently verifying the audit export. Raw credentials are written only to the mode-0600 portunusmcp-quickstart/credentials.env file:

cd ./portunusmcp-quickstart
set -a && source ./credentials.env && set +a
# PORTUNUSMCP_URL=http://127.0.0.1:8000
# MCP endpoint: http://127.0.0.1:8000/mcp/default

The private mode-0700 work directory also contains compose.quickstart.yml, .env.quickstart, config/policy.yaml, the revision directory, and secrets/audit_signing_key.pem plus its public-key ring. A successful run leaves the stack running and prints exact start/restart, state-preserving stop, and destructive reset commands. Their shapes are:

docker compose --env-file .env.quickstart -p <printed-namespace> -f compose.quickstart.yml down
docker compose --env-file .env.quickstart -p <printed-namespace> -f compose.quickstart.yml down --volumes

The second command deletes PostgreSQL, Redis, and runtime audit-key named volumes; the generated host key files remain. The mounted Docker socket grants the gateway root-equivalent control of the host; only trusted operators and upstream images should use it. Quickstart is an evaluation/on-ramp profile, not a replacement for the hardened, operator-configured compose.prod.yml.


Diagnose a deployment

Run doctor from a generated quickstart directory or a production bundle directory:

portunusmcp doctor .
portunusmcp doctor . --fix

It checks the local Docker socket and tested version lines, Compose rendering, socket GID and runtime namespace, policy/audit-key paths and modes, the complete fingerprinted public-key ring, policy-referenced gateway environment variables, immutable locally available upstream images, named volumes, loopback ports, FORWARDED_ALLOW_IPS, and /ready when the gateway is running. A stopped stack is informational, not unhealthy.

--fix offers only unambiguous local repairs: mode-0700 directories, mode-0600 private/config files, mode-0444 archived public keys, pristine initial audit keys, a missing active public archive, and exactly inferable Docker GID or namespace values. It prompts once; automation must put the global flags before the command:

portunusmcp --yes doctor . --fix
portunusmcp --json --yes doctor . --fix

Exit status is 0 when no ERROR remains and 1 otherwise; warnings do not fail the run. Repairs that affect an existing stack do not restart it. Doctor exits unhealthy and prints the exact docker compose ... --force-recreate gateway verifier command to run, after which a second doctor verifies readiness. Missing secrets, ambiguous namespaces, ownership changes, unavailable images, missing volumes, port conflicts, and corrupt or historically incomplete key material remain explicit operator decisions; doctor never guesses, pulls images, creates volumes, or uses ambient secret environment variables to hide a missing deployment value.


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.

Warning: ENFORCEMENT_MODE=observe forwards calls that RBAC, ABAC, replay, drift, risk, or parameter validation would deny. Use it only to learn policy impact; authenticated source/tool rate limits, session/resource limits, deadlines, upstream availability, and audit-before-action remain enforcing.

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.
# Keep ENFORCEMENT_MODE=enforce, or deliberately set observe for an audit-only trial.

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

ENFORCEMENT_MODE is process-wide and accepts only enforce or observe. The bare-host and demo defaults are enforce; production Compose refuses to render without an explicit value. Switching mode requires recreating/restarting the gateway: changing policy or sending SIGHUP does not change it. In observe mode tools/list serves the full upstream list, and each tools/call audit Decision records "mode":"observe" plus the earliest would-be terminal and downstream risk score/factors. The upstream result itself is returned unchanged.

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 (mode 0700, UID/GID 1000) contains policy.yaml; the gateway creates crash-recovery staging/journal files and revisions/ beneath it.
  • AUDIT_SIGNING_KEY_DIR (mode 0700, UID/GID 1000) contains audit_signing_key.pem; the gateway maintains public/<fingerprint>.pub.pem and the rotation journal beneath it. The verifier receives only that public/ 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:

SettingDefault
ENFORCEMENT_MODEenforce (explicitly required by production Compose)
MAX_MCP_BODY_BYTES1048576
MAX_JSON_DEPTH32
MAX_SESSIONS_PER_IDENTITY3
MAX_INFLIGHT_CALLS_PER_IDENTITY5
TOOL_CALL_RATE_LIMIT / TOOL_CALL_RATE_WINDOW_SECONDS60 / 60
AUTH_FAILURE_RATE_LIMIT / AUTH_FAILURE_RATE_WINDOW_SECONDS5 / 300
TOOL_CALL_DEADLINE_SECONDS60
READINESS_TIMEOUT_SECONDS1.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 scaffold --from-audit \
  --window 2026-07-01..2026-07-07 \
  --output candidate.yaml
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.

The observe-mode adoption loop is deliberately explicit: observe → scaffold → validate → simulate → rollout → restart in enforce mode. Scaffolding verifies every signed audit row in the selected contiguous sequence span, then grants only the exact observed (identity, server, tool) triples. It retains registered servers, current admins, identity authentication/attributes, and protected repository patterns; static sensitivity and ABAC conditions are cleared and marked with TODO(human) comments. The output is a review artifact only: generation does not fully validate, simulate, activate, restart, or append an audit event. Drift, replay, parameter validation, and risk still apply independently at runtime.

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, at N=1000 on 2026-07-30 from implementation commit dbae28124b26a3a51fbb029ef57e384e44a29bc8, with real per-session Docker upstreams on the gateway path and the full §4.2 pipeline. Methodology and reproduction steps: ARCHITECTURE.md §9.

Local macOS arm64 — Darwin 24.6.0, Python 3.12.13; run 20260730T052023Z.

ScenarioDirect callThrough gatewayOverhead
Single call, cached schema0.26 / 0.21 / 0.46 / 1.04 ms16.04 / 15.53 / 18.22 / 27.81 ms15.78 / 15.32 / 17.76 / 26.77 ms
Single call, cold schema cache0.26 / 0.21 / 0.46 / 1.04 ms19.04 / 18.40 / 21.69 / 33.38 ms18.78 / 18.19 / 21.23 / 32.34 ms
10 concurrent sessions1.26 / 1.12 / 1.93 / 2.05 ms76.29 / 66.82 / 103.48 / 196.50 ms75.03 / 65.70 / 101.55 / 194.44 ms
50 concurrent sessions10.43 / 5.38 / 76.76 / 88.29 ms578.22 / 508.65 / 857.25 / 1874.82 ms567.79 / 503.27 / 780.49 / 1786.53 ms
100 concurrent sessions15.18 / 12.13 / 26.56 / 65.17 ms1450.36 / 1398.24 / 2053.76 / 3453.88 ms1435.18 / 1386.11 / 2027.20 / 3388.71 ms
tools/list payload (pruned identity)744 B (unpruned)425 B42.9% reduction

Container initialization: first 504.22 ms; next 20 p50 327.70 ms / p95 402.78 ms. Peak RSS was 179 MiB; initialized upstream containers used 1,095 MiB.

GitHub-hosted Ubuntu 24.04 x86_64 — Linux 6.17.0-1020-azure, Python 3.12.13; workflow run 30516344641, report 20260730T052354Z.

ScenarioDirect callThrough gatewayOverhead
Single call, cached schema0.31 / 0.31 / 0.37 / 0.56 ms15.13 / 14.97 / 15.80 / 18.92 ms14.82 / 14.66 / 15.44 / 18.36 ms
Single call, cold schema cache0.31 / 0.31 / 0.37 / 0.56 ms17.64 / 17.37 / 19.12 / 21.43 ms17.33 / 17.06 / 18.75 / 20.87 ms
10 concurrent sessions2.50 / 2.49 / 2.59 / 3.41 ms126.69 / 124.53 / 133.87 / 221.50 ms124.19 / 122.04 / 131.28 / 218.09 ms
50 concurrent sessions10.81 / 10.62 / 13.06 / 13.65 ms689.45 / 652.20 / 777.93 / 1407.16 ms678.64 / 641.59 / 764.87 / 1393.51 ms
100 concurrent sessions21.30 / 21.22 / 21.95 / 22.19 ms1387.19 / 1309.17 / 1436.30 / 3182.54 ms1365.88 / 1287.94 / 1414.35 / 3160.35 ms
tools/list payload (pruned identity)744 B (unpruned)425 B42.9% reduction

Container initialization: first 299.46 ms; next 20 p50 307.96 ms / p95 341.66 ms. Peak RSS was 216 MiB; initialized upstream containers used 1,087 MiB.

Latencies are mean / p50 / p95 / p99. Each overhead value subtracts the matching statistics of independently summarized gateway and direct distributions; it is not a percentile of paired per-call differences. Against item 52, every like-for-like local gateway mean/p95 improved: cached by 3.3%/7.2%, cold by 8.4%/21.2%, and concurrent values by 4.6%–55.9%. No regression crossed the 10% publication block threshold. High-concurrency latency includes one hardened Docker container per gateway session and the synchronous fail-closed audit write, both discussed in ARCHITECTURE.md §10.


Tech stack

LayerChoiceWhy
RuntimePython 3.12, FastAPI + Starlette, async throughoutFirst-party MCP SDK; async-native for a proxy that's almost entirely I/O wait
MCP handlingmcp official Python SDKDon't hand-roll JSON-RPC framing — intercept at the session layer instead
StoragePostgreSQL 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
PolicyYAML + 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)
RiskFixed weighted factor list + behavioral Redis counters — no ML, by designA security decision an operator can't explain is a security decision they can't trust
CryptoSHA-256 hash chain + ECDSA P-256 per-row signaturesThe chain alone is regenerable by anyone with DB write access; the signature isn't
OpsDocker 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, deployment
  • THREAT_MODEL.md — what's protected, what isn't, and the assumptions underneath
  • SECURITY.md — vulnerability disclosure
  • COMPATIBILITY.md — supported runtimes, images, clients, and tested platforms
  • UPGRADING.md — supported upgrade and rollback procedure
  • CHANGELOG.md — release history
  • docs/adr/ — one file per consequential decision, including why Envoy, OPA, Kong, NGINX, sidecars, and client-SDK middleware were each rejected for v1
  • ROADMAP.md — the build order as a living checklist

Roadmap

Phases 1–6 and Phase 7 items 44–52 are complete. v0.1.0 is available from GitHub Releases and PyPI, and GHCR tags 0.1.0 and latest resolve to the tested linux/amd64 + linux/arm64 image index sha256:fdbfb388e68830fb6dff44c285fb0b3b43633113e586c448ab3e76abd6811073. Phase 7 remains active; item 53, the demo screen recording, is next, and the five-adopter exit gate remains open.

Items through Phase 6 are complete only when their verification passes and the corresponding threat-model claim is earned. Phase 7 verifies against observed friction; item 49's observe mode is the explicit exception that weakens and therefore qualifies existing threat-model claims. The phase closes after five outside users complete quickstart and their friction is triaged.

How this was built

PortunusMCP was built by one engineer working with AI coding assistants, and the split is worth stating plainly.

The design decisions are the human's: what the gateway does and deliberately does not cover, the threat model and its honest No rows, the decision pipeline, the fail-closed posture, the choice to keep the policy DSL non-Turing-complete and the risk engine free of ML, and every rejected alternative recorded in docs/adr/. The phase gates are the human's too — including the rule that a THREAT_MODEL.md claim may not move until the code that earns it exists and its check passes.

The assistants did the implementation downstream of those decisions: writing the modules, the migrations, the test suites, the Compose profiles, and the documentation, and doing the debugging. CLAUDE.md is the standing instruction file that constrains how they work here — surface assumptions, no speculative complexity, no unrelated edits, explicit success criteria per task.

Nothing in this README is asserted on a model's say-so. Benchmarks carry their commit, date, hardware, and run ID; compatibility results carry the client version and what was observed; the audit chain is independently verifiable with scripts/verify_audit_chain.py. Where something is unproven or unprotected, it says so.

License

MIT — see LICENSE.

↑↓ navigate openesc close