Content
<p align="center">
<img src="assets/banner.svg" alt="Universal MCP Gateway — one governed door in front of every MCP server you run" width="100%">
</p>
# Universal MCP Gateway
[](https://github.com/MANOJ21K/universal-mcp-gateway/actions/workflows/ci.yml)
[](https://www.python.org/)
[](https://github.com/jlowin/fastmcp)
[](https://modelcontextprotocol.io)
[](LICENSE)
Run one [MCP](https://modelcontextprotocol.io) server and you can reasonably keep authorization in your head. Run three, and each one has quietly become its own place where any client can call any tool, nobody's checking who's asking, and there's no record of what happened afterward — a gap that stays invisible right up until an agent does something it shouldn't have been able to do.
Universal MCP Gateway closes that gap without touching the servers themselves. It's an MCP server to whatever connects to it and an MCP client to whatever it fronts, so agents talk to it exactly like they'd talk to a real tool server — except now every call is authenticated, checked against policy, logged, and rate-limited first. Everything it enforces — which servers exist, who can call what, how fast, what gets redacted — lives in one YAML file, so "universal" isn't a slogan: the same gateway fronts a calculator, a key-value store, or a fleet of internal MCP services with no code changes, just config. It's built on [FastMCP](https://github.com/jlowin/fastmcp) 3.4.4 for the actual proxying; this repo is the governance layer on top of that.
## Why a gateway, not a patch per server
The obvious alternative is adding authentication, policy checks, and audit logging to each MCP server individually. In practice that tends to drift: the second server implements it a little differently, the third might be a vendor's server you can't modify at all, and by the time a few more are added it gets genuinely hard to say with confidence what a given caller is allowed to do without reading through every codebase involved. Keeping that governance in one place instead means it works the same way whether or not you own the upstream server's code, and there's a single, reviewable answer to "who can call what" — not one answer per server.
---
## Contents
- [See it enforce policy in five minutes](#see-it-enforce-policy-in-five-minutes)
- [How it works](#how-it-works)
- [What you get](#what-you-get)
- [Configuring the gateway](#configuring-the-gateway)
- [Running it in production](#running-it-in-production)
- [Tests](#tests)
- [Where things live](#where-things-live)
- [What's next](#whats-next)
- [Contributing](#contributing)
- [License](#license)
---
## See it enforce policy in five minutes
Needs Python 3.10+ (CI runs 3.10–3.12). Redis and Docker are optional — only needed for fleet-wide rate limiting or the containerized stack.
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1 # macOS/Linux: source .venv/bin/activate
pip install -e ./agentctx # local sibling package the gateway depends on
pip install -e . # the gateway itself + the `mcp-gateway` CLI
```
Start the two example upstreams — a calculator and an in-memory key-value store — each in its own terminal:
```powershell
python examples/server.py # calc → http://127.0.0.1:8100/mcp (8100, not 8000 — 8000 is taken locally)
python examples/mock_kv_server.py # kv → http://127.0.0.1:8001/mcp
```
Then the gateway, which reads [`gateway.config.yaml`](gateway.config.yaml) by default:
```powershell
mcp-gateway run # → http://127.0.0.1:9000/mcp
```
Now the part worth actually watching. The shipped config defines three principals with three different rule sets — change the API key below and nothing else, and the gateway enforces a different reality each time:
```python
import asyncio
from fastmcp import Client
from fastmcp.client.auth import BearerAuth
async def main():
async with Client("http://127.0.0.1:9000/mcp", auth=BearerAuth("sk-analyst-abc")) as client:
print([t.name for t in await client.list_tools()])
result = await client.call_tool("calc_query_tool", {"user_input": "add 2 and 3"})
print(result.structured_content)
asyncio.run(main())
```
| Principal | Key | Role | Allowed | Blocked | Limit |
|---|---|---|---|---|---|
| `analyst-1` | `sk-analyst-abc` | `analyst` | `calc_*` | all of `kv_*` | 30/min |
| `editor-1` | `sk-editor-def` | `editor` | `calc_*`, `kv_*` | `kv_delete` (explicit deny) | 60/min |
| `admin-1` | `sk-admin-xyz` | `admin` | everything | nothing | 120/min |
Run it as the analyst and `list_tools()` never mentions `kv_*` at all — not filtered after the fact, just absent from the menu. Call `kv_set` anyway and you still get `Access denied`, because the gateway re-checks at call time regardless of what it advertised. Swap in the editor's key and `kv_get`/`kv_set` work but `kv_delete` doesn't, even though `kv_*` is allowed — the role's explicit deny overrides its own allow. Swap in the admin's key and everything works, with `kv_delete` flagged `sensitive` in the audit log every time it's used. Same client code throughout; only the key changed.
[`examples/client.py`](examples/client.py) wraps the same call in a small FastAPI service — a stand-in for a real agent-facing app — with its identity set by the `GATEWAY_API_KEY` env var. And before any of this touches a deployment, validate a config with no server involved:
```powershell
mcp-gateway check --config gateway.config.yaml # exits non-zero on any error — a CI gate
```
---
## How it works
```mermaid
flowchart LR
Agent["Agent or App<br/>examples/client.py"] -->|Bearer API key| GW
subgraph GW["Universal MCP Gateway — port 9000"]
direction TB
Auth["1 . Auth<br/>key or JWT to principal"]
Audit["2 . Audit<br/>outermost, feeds /metrics"]
Policy["3 . Policy<br/>RBAC + glob + tenant"]
Resilience["4 . Resilience<br/>limits, breaker, retry"]
Auth --> Audit --> Policy --> Resilience
end
Resilience -->|federate| Calc["calc server<br/>port 8100"]
Resilience -->|federate| KV["kv server<br/>port 8001"]
```
Identity is resolved first, since everything downstream needs to know who's asking. After that, the nesting order is a deliberate choice, not an accident:
```
Audit → Policy → Resilience → upstream
```
Audit sits on the outside so it sees the real outcome of a call — including the denials and throttles thrown by the layers inside it — instead of only ever logging successes. Policy sits ahead of resilience so a call that was never going to be allowed doesn't spend any rate-limit budget or retry slot on its way to being rejected anyway.
Tools are namespaced by the upstream key that registered them: `calc`'s `query_tool` becomes `calc_query_tool`, `kv`'s `get`/`set`/`delete` become `kv_get`/`kv_set`/`kv_delete`. Every policy glob matches against that namespaced name — even with a single upstream configured — so policy never has to change shape as you add more servers later.
| Layer | Responsibility | Source |
|---|---|---|
| Auth | `Authorization: Bearer …` → principal, or a 401 before anything else runs | [`gateway/auth.py`](gateway/auth.py), [`gateway/identity.py`](gateway/identity.py) |
| Policy | allow ∧ ¬deny over tools/resources/prompts; filters the menu *and* re-checks every call | [`gateway/policy_engine.py`](gateway/policy_engine.py), [`gateway/middleware/policy.py`](gateway/middleware/policy.py) |
| Audit | JSONL per attempt + Prometheus counters + optional OTel spans | [`gateway/middleware/audit.py`](gateway/middleware/audit.py), [`gateway/observability.py`](gateway/observability.py) |
| Resilience | rate limiter, circuit breaker, timeout, retry+backoff — per upstream | [`gateway/middleware/resilience.py`](gateway/middleware/resilience.py), [`gateway/circuit.py`](gateway/circuit.py), [`gateway/ratelimit.py`](gateway/ratelimit.py) |
---
## What you get
| | |
|---|---|
| **Identity** | Two modes: JWT from your IdP (HS/RS/ES/PS, JWKS, claim mapping — the production posture, no user list in config; see [docs/production-auth.md](docs/production-auth.md)) or static API keys (hashed at rest, rotatable, expiring — the zero-setup demo mode). Optionally publishes RFC 9728 OAuth Protected Resource Metadata so clients can discover your authorization server automatically. |
| **Policy** | RBAC with shell-glob patterns over the entire MCP surface — tools, resources, and prompts, not just tools. Deny always beats allow. |
| **Audit** | Every attempt, allowed or not, becomes one structured JSON line with sensitive arguments redacted — feeding Prometheus metrics and, optionally, OpenTelemetry spans. |
| **Resilience** | Per-caller rate limits (in-memory or Redis), a circuit breaker per upstream, per-call timeouts, retries with backoff and jitter. |
| **Federation** | Add an upstream — HTTP or stdio — by editing config; its tools appear automatically, namespaced so policy never has to guess which server owns what. |
| **Multi-tenant** | Tag an upstream with an owning tenant and callers from any other tenant can't reach it, even if their role would otherwise allow the call. |
| **Ops-ready** | `/healthz`, `/readyz`, Prometheus `/metrics`, a Dockerfile + Compose stack, and `mcp-gateway check` as a CI config gate. |
Alongside the gateway sits [`agentctx`](#the-agentctx-package) — an identity and trace-context library with no dependency on FastMCP or this gateway, usable in any agentic codebase, not just this one.
---
## Configuring the gateway
[`gateway.config.yaml`](gateway.config.yaml) is the only place behavior lives — this is the entire contract, annotated:
```yaml
gateway:
host: 127.0.0.1
port: 9000
request_timeout_s: 15 # per-call upstream timeout
audit_log_path: audit.log # JSONL sink; rotates by size
audit_max_bytes: 10485760 # 10 MiB, 0 = never rotate
audit_backup_count: 5
# Federation registry — every upstream this gateway fronts. Tools are namespaced
# by the key here: calc -> calc_<tool>, kv -> kv_<tool> — same prefix rule even
# if you only ever configure one upstream, so policy globs never have to change.
upstreams:
calc:
url: "http://127.0.0.1:8100/mcp"
transport: http
kv:
url: "http://127.0.0.1:8001/mcp"
transport: http
tenant: acme # only tenant=acme callers may reach this upstream's
# tools/resources/prompts — fails closed, even if
# RBAC alone would have allowed the call
files: # ...or front something that speaks stdio instead,
transport: stdio # spawned as a subprocess — no HTTP endpoint needed,
command: "npx" # the common case for locally-installed MCP servers
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
env: { LOG_LEVEL: "info" } # optional
# cwd: /srv # optional working directory for the subprocess
# Identity — who's allowed to call in, and which principal they become. Keys
# support ${ENV_VAR} / ${ENV_VAR:-default} interpolation (plus an optional .env
# next to this file) and are stored hashed (sha256); the plaintext is discarded
# after load. A single `api_key: "${VAR}"` field works if you don't need
# rotation; `api_keys` below is the multi-key form — give a principal two keys
# with an expiry on the old one to rotate without downtime, both work during
# the overlap.
principals:
- id: analyst-1
roles: [analyst]
tenant: acme
api_keys:
- { key: "${ANALYST_KEY_NEW}" }
- { key: "${ANALYST_KEY_OLD}", expires_at: "2026-08-01T00:00:00Z" }
# Policy — a tool is callable iff it matches some role's allow AND no role's
# deny (deny always wins). A caller with several roles gets the union of both.
roles:
analyst:
allow: ["calc_*"]
deny: []
rate_limit_per_min: 30
editor:
allow: ["calc_*", "kv_*"]
deny: ["kv_delete"] # kv_* is allowed, but this still wins
rate_limit_per_min: 60
sensitive_tools: ["kv_delete"] # elevated audit visibility now; human-approval gate later
redact_arg_keys: ["password", "token", "secret", "api_key", "authorization"]
resilience:
rate_limit_backend: memory # or: redis (needs redis_url below)
redis_url: "redis://localhost:6379/0"
retries: 1 # extra attempts after the first, with backoff + jitter
backoff_base_s: 0.1
backoff_max_s: 2.0
breaker_failure_threshold: 5 # consecutive upstream failures before the breaker opens
breaker_cooldown_s: 30 # open -> half-open cooldown
```
The shipped [`gateway.config.yaml`](gateway.config.yaml) has fallback defaults so the demo just runs; [`deploy/gateway.config.example.yaml`](deploy/gateway.config.example.yaml) is the production template — no fallbacks, so a missing secret fails startup loudly instead of silently defaulting to something guessable. Every key above is schema-validated at load: unknown keys, bad types, and dangling role references all fail fast with a clear error, which is what `mcp-gateway check` is for.
Two modes that reshape the config rather than just adding a key to it, each worth its own snippet:
**JWT instead of static keys — the production mode.** Drop `principals` entirely; identity comes from the token's claims instead, so users, agents, and their role assignments live in your IdP and never touch this file. This is how the gateway is meant to run with real user populations — [docs/production-auth.md](docs/production-auth.md) covers the architecture, per-IdP claim-mapping recipes (Keycloak / Auth0 / Entra ID), service-agent and delegation flows, and why policy stays here while identity doesn't. [`deploy/gateway.config.jwt.example.yaml`](deploy/gateway.config.jwt.example.yaml) is the ready-to-copy template, and [`examples/jwt_demo.py`](examples/jwt_demo.py) runs the whole flow locally with no IdP installed. Add `resource` + `authorization_servers` and the gateway also publishes an RFC 9728 discovery document at `/.well-known/oauth-protected-resource`, for clients that look it up automatically rather than being told the issuer out of band.
```yaml
auth:
type: jwt
algorithm: RS256 # HS*/RS*/ES*/PS*
jwks_uri: "https://idp.example/.well-known/jwks.json" # or public_key: "${JWT_SECRET}"
issuer: "https://idp.example"
audience: "mcp-gateway"
principal_claim: sub
roles_claim: roles # a list, or a space-separated scope string
tenant_claim: tenant
resource: "https://gateway.example.com" # optional: enables discovery below
authorization_servers: ["https://idp.example"]
scopes_supported: ["analyst", "editor", "admin"] # optional, advertised in the discovery doc
```
**A response cache or anything else you'd normally reach for a sidecar for** — isn't there yet; see [What's next](#whats-next).
---
## Running it in production
Three unauthenticated endpoints exist for whatever's watching the process, plus a fourth that only appears once you've opted into it:
| | |
|---|---|
| `GET /healthz` | liveness — `{"status":"ok"}` |
| `GET /readyz` | readiness — echoes the configured upstreams |
| `GET /metrics` | Prometheus text — `mcpgw_calls_total{kind,decision}` and latency summaries |
| `GET /.well-known/oauth-protected-resource` | RFC 9728 discovery document — only published when `auth.resource` is set |
`docker compose up --build` brings up the gateway, both example upstreams, and Redis together; set `ANALYST_KEY`/`EDITOR_KEY`/`ADMIN_KEY` in the environment first if you don't want the demo defaults. OpenTelemetry is opt-in: install the SDK and configure a tracer/exporter in the host process and audit records start emitting spans too — leave it out and nothing changes.
What the audit log actually looks like — one line per attempt, allowed or not, because audit is the outermost layer and sees everything the inner layers reject:
```json
{"ts": "2026-07-14T09:38:13.611773+00:00", "correlation_id": "904cfb7c8191",
"principal": "analyst-1", "tenant": "acme", "roles": ["analyst"],
"kind": "tool", "tool": "calc_query_tool", "sensitive": false, "args": {"user_input": "add 2 and 3"},
"decision": "allow", "error": null, "duration_ms": 798.89, "result_size": 55}
```
```json
{"ts": "2026-07-14T09:38:16.116129+00:00", "correlation_id": "1f640dee60f9",
"principal": "analyst-1", "tenant": "acme", "roles": ["analyst"],
"kind": "tool", "tool": "kv_set", "sensitive": false, "args": {"key": "x", "value": "1"},
"decision": "deny", "error": "Access denied: analyst-1 (roles=['analyst']) may not call 'kv_set' (no allow rule matched).",
"duration_ms": 0.04, "result_size": 0}
```
`kind` covers `tool` / `resource` / `prompt`; `decision` is `allow` / `deny` / `throttled` / `circuit_open` / `error` — queryable directly for both security review and reliability signals, and each line also increments a `/metrics` counter. Anything under `redact_arg_keys` is masked to `***` before it's written.
---
## Tests
```powershell
python -m pytest tests -q # gateway — 63 passing
python -m pytest agentctx/tests -q # agentctx — 49 passing, independent of the gateway
```
The parts that matter most for security — the policy engine, the circuit breaker, the rate limiter — are kept pure and synchronous on purpose, so they're tested directly without spinning up HTTP or FastMCP. A few specific things worth knowing are actually tested, not just asserted in a docstring: the JWT verifier is exercised through a real minted-token round-trip (valid, bad signature, expired, scope→roles), the circuit breaker's state machine is driven by an injected clock rather than real sleeps, and `agentctx`'s claim of having zero dependency on this gateway is checked mechanically — a test statically inspects [`agentctx/examples/standalone_worker.py`](agentctx/examples/standalone_worker.py)'s imports for `gateway`/`fastmcp` and then actually runs it. [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs both suites plus `mcp-gateway check` across Python 3.10, 3.11, and 3.12 on every push and PR. The full per-file breakdown is in [`tests/`](tests/) and [`agentctx/tests/`](agentctx/tests/) if you want it.
---
## Where things live
```
gateway/ control plane
├── app.py assembles the middleware onion, ops routes, runs the proxy
├── cli.py `mcp-gateway run|check`
├── config.py schema-validated, ${env}-interpolated loader
├── auth.py / identity.py token verification → principal resolution
├── policy_engine.py pure allow/deny + tenant gate
├── circuit.py / ratelimit.py breaker and pluggable limiter
├── observability.py metrics + optional OTel span
├── errors.py typed exceptions the audit layer reads
└── middleware/
├── audit.py outermost — structured JSONL
├── policy.py enforce over tools/resources/prompts
└── resilience.py rate-limit · breaker · timeout · retry
agentctx/ identity + trace-context, no gateway dependency
├── src/agentctx/
│ ├── identity.py Identity, IdentityResolver, static-key/JWT/token-exchange
│ ├── context.py RunContext, W3C traceparent propagation
│ ├── tracing.py OTel GenAI span emission
│ └── oauth_metadata.py RFC 9728 Protected Resource Metadata document shaping
├── examples/standalone_worker.py proves the decoupling by actually running without FastMCP
└── tests/
examples/
├── server.py, mock_kv_server.py example upstreams the gateway fronts
├── client.py example agent-facing app calling through the gateway
└── jwt_demo.py + gateway.config.jwt-demo.yaml the JWT identity mode, runnable without an IdP
deploy/
├── gateway.config.example.yaml production template (static keys), no secret fallbacks
├── gateway.config.jwt.example.yaml production template (JWT/IdP), no principals block at all
└── gateway.docker.config.yaml config the docker-compose stack mounts
docs/
└── production-auth.md identity at scale: IdP wiring, delegation, multi-tenant, ops
gateway.config.yaml the live demo config
tests/ gateway test suite (see above)
```
### The `agentctx` package
[`agentctx/`](agentctx/) isn't a gateway module — it's a standalone library the gateway happens to be built on. It answers "who is this" (`Identity`, `IdentityResolver`, with static-key, JWT, and RFC 8693 token-exchange resolvers) and "what run does this belong to" (`RunContext`, W3C traceparent propagation, OTel GenAI span emission), with zero import of FastMCP or anything gateway-specific. [`gateway/auth.py`](gateway/auth.py) is just one consumer, adapting FastMCP's `TokenVerifier` onto `agentctx`'s resolvers rather than reimplementing verification. It intentionally stops at producing standards-compliant traces; scoring and evaluation are left to tools built for that (Langfuse, Arize Phoenix, DeepEval, Ragas).
---
## What's next
| Pillar | Next |
|---|---|
| Auth | Full OIDC discovery / dynamic client registration, or mTLS — middleware won't need to change, since it only ever reads `identity.current_caller()` |
| Policy | Per-argument ABAC conditions, a human-approval gate for `sensitive_tools`, or externalizing to OPA |
| Audit | OTLP export to a real backend (Datadog, Grafana, Honeycomb) once you're ready to scrape `/metrics` in anger |
| Resilience | Response caching for idempotent reads, a per-upstream concurrency bulkhead |
The biggest thing not yet done is config hot-reload — editing principals or policy without restarting the process. Everything else on this list is additive to pillars that already work end-to-end today.
---
## Contributing
PRs and issues welcome — see [CONTRIBUTING.md](.github/CONTRIBUTING.md) for the dev setup, test commands, and the couple of design invariants (one-way `agentctx` dependency, pure/synchronous security-critical code) worth knowing before you touch `gateway/`. This project follows the [Contributor Covenant](.github/CODE_OF_CONDUCT.md), and security reports go through [SECURITY.md](.github/SECURITY.md).
---
## License
[MIT](LICENSE)
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available