MCP gateway adding per-tool RBAC, tenant isolation, audit export, and PII redaction to any server.
Project description
MCP Audit Gateway
MCP gateway that adds per-tool RBAC and an exportable audit log to any MCP server, plus per-tenant isolation, PII redaction on tool results, per-tenant rate limits, and tenant-level allow/deny policies. It sits between an MCP client (Claude Desktop, an agent runtime, your own app) and one or more upstream MCP servers. The concrete case it was built for: put per-tool RBAC and an exportable audit log in front of a QuickBooks or HubSpot MCP server, so every tools/call against the books flows through one enforceable, logged, redacted chokepoint.
Related: QuickBooks Online MCP Server · HubSpot CRM MCP Server · What production MCP actually requires
Point it at any MCP server, describe your tenants and roles in YAML, and the policy is one file you can read in a review. HMAC request signing and both stdio and streamable-HTTP transports are included. MIT, no paid tier.
Why this exists
A bare MCP server exposes every tool to every caller with no identity, no quota, no record, and no redaction. That is fine on a laptop and unshippable in a multi-tenant product. This gateway adds that control plane without touching the upstream server. The companion write-up, docs/what-production-mcp-actually-requires.md, walks each feature past a concrete failure story.
Tools
The gateway exposes no tools of its own. It proxies tools/list and tools/call through to the upstream MCP servers configured for the calling tenant, namespacing each proxied tool as <upstream>.<tool> and applying RBAC filtering, rate limiting, and PII redaction on the way through. Started with no upstreams configured, initialize and tools/list still succeed and the tool list comes back empty.
Its own surface is the CLI control plane:
validate: check a policy file (roles, principals, tenants, upstreams, detectors) before it serves any traffic.serve --transport stdio: run the gateway over stdio for a local MCP client such as Claude Desktop.serve --transport http: run the gateway over streamable HTTP for remote or agent clients.audit export --format csv: export the audit log as CSV for a spreadsheet or a SIEM ingest.audit export --format json: export the same records as JSON.
Proof: the two-tenant demo
The evidence here is a demo you run yourself, not a site I ask you to trust. demo/run_demo.py stands up the gateway in front of two tenants, acme (a stdio upstream subprocess) and globex (a streamable-HTTP upstream), and drives twelve scenarios that exercise RBAC, tenant kill-switches, PII redaction, cross-tenant isolation, HMAC signing, unknown-tool rejection, and per-tenant rate limiting against the bundled toy MCP server. There is no hosted instance to keep alive and no external service to reach; everything runs on loopback and finishes in seconds.
The committed output is the artifact:
- docs/demo-transcript.md: every step, its gateway decision, and the result the client actually saw, followed by an audit-outcome summary table.
- docs/demo-audit.csv: the full machine-readable audit trail the same run produced.
Both files are regenerated verbatim by python demo/run_demo.py, so the transcript and its audit table always reconcile with a run you can reproduce locally.
Architecture
flowchart LR
subgraph Clients
C1[Tenant A client]
C2[Tenant B client]
end
subgraph Gateway [mcp-audit-gateway]
direction TB
AUTH[Authenticate principal] --> SIG[Verify HMAC signature]
SIG --> RBAC[RBAC and allow/deny policy]
RBAC --> RL[Per-tenant rate limit]
RL --> ROUTE[Resolve tenant upstream]
ROUTE --> RED[PII redaction on result]
RED --> AUD[(Audit log JSONL)]
end
subgraph Upstreams
U1[Tenant A MCP server<br/>stdio]
U2[Tenant B MCP server<br/>streamable HTTP]
end
C1 -->|signed JSON-RPC| AUTH
C2 -->|signed JSON-RPC| AUTH
ROUTE --> U1
ROUTE --> U2
U1 --> RED
U2 --> RED
RED -->|redacted result| C1
Every request is authenticated to a principal, which pins it to exactly one tenant and role. A tenant can only ever reach its own upstreams, credentials, and state. The pipeline short-circuits at the first failing gate and records the decision.
Features
| Feature | What it does |
|---|---|
| Per-tool RBAC | Role-to-tool allow/deny lists with glob patterns; deny always wins. |
| Per-tenant isolation | Each tenant gets its own upstream processes/endpoints, credentials, tool catalog, rate-limit bucket, and state. Cross-tenant tool names are invisible. |
| Audit logging | Append-only JSONL of who called which tool, with which (redacted) arguments, result status, latency, and redaction counts. Exportable to CSV and JSON. |
| PII redaction | Configurable detectors (email, phone, SSN, Canadian SIN, credit card) applied to tool results before they leave the gateway. mask, hash, or partial modes. |
| Rate limiting | Per-tenant token bucket (requests_per_minute + burst). |
| Allow/deny policies | Tenant-level tool kill-switch that overrides roles: a governance layer above RBAC. |
| Request signing | HMAC-SHA256 over the request body with a timestamp freshness window to stop replay. |
| Transports | stdio and streamable-HTTP on both the client-facing and upstream-facing sides. |
Quickstart
uv venv --python 3.12
uv pip install -e ".[dev]"
# Validate the bundled two-tenant demo config
python -m mcp_gateway validate --config config/demo.yaml
# Run the full scripted demo (starts a stdio upstream and an HTTP upstream,
# proves tenant isolation, and writes docs/demo-transcript.md)
python demo/run_demo.py
Installed as a package, the same commands run through the mcp-audit-gateway console script.
Running it bare
The gateway starts with no config file at all, which is what an MCP client or a registry crawler sees on a first tools/list probe:
mcp-audit-gateway serve --transport stdio
That serves one local principal against zero upstreams: initialize and tools/list succeed, the tool list is empty, nothing is written to disk. Point it at a policy file to make it useful, either with --config or by setting MCP_AUDIT_GATEWAY_CONFIG.
Running the gateway
# Streamable HTTP, for remote/agent clients (reads host/port from the config)
python -m mcp_gateway serve --config config/demo.yaml --transport http
# stdio, for a local client such as Claude Desktop (pins the session to a principal)
python -m mcp_gateway serve --config config/demo.yaml --transport stdio --principal acme-admin
--principal is only required when the config defines more than one; with a single principal the stdio session uses it.
The demo's globex tenant proxies an HTTP upstream expected at http://127.0.0.1:9100/mcp. To serve the gateway against demo.yaml directly, start that upstream first:
python -m mcp_gateway.toy_upstream --transport http --host 127.0.0.1 --port 9100 --dataset globex
demo/run_demo.py handles this wiring automatically on an ephemeral port, so it is the fastest way to see everything work.
Stdio upstream commands that start with python or python3 are launched under the gateway's own interpreter, so a python -m ... upstream works whether or not python is on the caller's PATH. Any other command is executed verbatim.
Exporting the audit log
python -m mcp_gateway audit export --input config/audit-log.jsonl --format csv --output audit.csv
python -m mcp_gateway audit export --input config/audit-log.jsonl --format json
Config reference
gateway:
name: mcp-audit-gateway-demo
http: { host: 127.0.0.1, port: 8080 }
security:
require_signature: true # enforce HMAC signing on incoming requests
signature_max_age_seconds: 300 # replay window
redaction:
enabled: true # redact tool RESULTS before returning them
mode: mask # mask | hash | partial
detectors: [email, phone, ssn, sin, credit_card]
redact_arguments: true # also redact arguments written to the audit log
audit:
enabled: true
path: audit-log.jsonl # relative to the config file's directory
argument_logging: redacted # redacted | full | keys_only | none
roles: # role -> tool allow/deny (glob patterns, deny wins)
admin: { allow_tools: ["*"] }
analyst: { allow_tools: ["billing.get_*", "billing.list_*"], deny_tools: ["billing.delete_*"] }
principals: # a credential = one identity = tenant + role + signing secret
- { id: acme-admin, tenant: acme, role: admin, secret: "demo-only-secret" }
tenants:
acme:
deny_tools: ["billing.delete_invoice"] # optional tenant-level kill switch
upstreams:
- { name: billing, transport: stdio, command: ["python", "-m", "mcp_gateway.toy_upstream", "--dataset", "acme"] }
rate_limit: { requests_per_minute: 60, burst: 10 }
Tools are namespaced as <upstream>.<tool> (for example billing.get_invoice), so RBAC and policy patterns are stable across tenants and collisions between upstreams are impossible.
Running the tests
uv run pytest
The suite is fully offline. The "external" MCP servers it talks to are the bundled toy upstream, exercised for real over both stdio (subprocess) and streamable HTTP (loopback): no network, no mocks-of-mocks, and it finishes in seconds.
Security and scope notes
- Static YAML secrets are for local demos only. In production, load principal secrets from a secret manager and prefer per-request short-lived credentials; see the write-up.
- HMAC signing protects the client-to-gateway hop. It is not a substitute for TLS or network controls.
- Redaction is regex-based best-effort defense-in-depth, not a certified DLP guarantee. Treat it as one layer.
- The audit log is a point-in-time record of what the gateway observed. It is not a compliance attestation.
- Only proxy and inspect MCP servers you own or are authorized to operate.
Hire me
I make AI-era and money-critical backends production-safe: auth, multi-tenancy, governance, and the boring correctness that keeps you out of incidents. Available for MCP, billing, and hardening work. Portfolio and contact: https://amin-ale.github.io/portfolio-site · amin.ale.business@gmail.com.
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 mcp_audit_gateway-0.1.0.tar.gz.
File metadata
- Download URL: mcp_audit_gateway-0.1.0.tar.gz
- Upload date:
- Size: 53.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
71ca74590d3147d5dbd5d9e159150786ee58e46ad4c561f98cd178daba72fef9
|
|
| MD5 |
461ee9c6756d8046c4d5c299da34ce1c
|
|
| BLAKE2b-256 |
23f3721aab69b35c49c5cc9b4bbd69dbed32ed18ec244bc4b0d239f271a351a7
|
File details
Details for the file mcp_audit_gateway-0.1.0-py3-none-any.whl.
File metadata
- Download URL: mcp_audit_gateway-0.1.0-py3-none-any.whl
- Upload date:
- Size: 35.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.17 {"installer":{"name":"uv","version":"0.9.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8479900580ae52a41ac692a2c1b78eecbee260a3bf9a14a204d8e70afbfa4151
|
|
| MD5 |
6b2fe593e70264053f144b9ba3299df2
|
|
| BLAKE2b-256 |
ad892a68a8c901c7786a622aa7962219f6fd6ad7c808c57827246851d931716b
|