mcp server
mcp-gateway-reference
Reference implementation of the MCP authorization spec (2025-11-25) for enterprise deployments: an OAuth 2.1 gateway that brokers Microsoft Entra ID, enforces three-layer OPA policy, never passes clie
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 23, 2026Enterprise MCP Gateway — Reference Implementation
⚠️ What this is, and what it is not
This repository is a teaching artifact: a worked example, published to show what an MCP gateway has to do and why, for people who are about to build or buy one. It is not production-ready software, and it is not a product.
It is real code — it runs against a real Entra tenant (or the fake one in
dev-idp/), the tests pass, and every claim below is something you can go and read. But it makes deliberate simplifications that a production deployment must not: per-process state, a hand-maintained tool registry, best-effort audit writes, no policy shadow mode. Those are listed in Known limitations and What this does not solve, and they are as much the point as the code is.Use it to learn the shape of the control point, and to steal the ideas. Use the three-part write-up as the checklist you hold your own design — or a vendor's — against: 1 · Why you need an MCP gateway · 2 · Three layers of policy · 3 · What it takes to run one. Do not put this in front of your CRM on Monday.
A public, MIT-licensed reference implementation of the MCP authorization spec (2025-11-25) for enterprise deployments.
An MCP gateway that sits between unmodified MCP clients (Claude Code, VS Code, Cursor) and backend MCP servers:
- speaks OAuth 2.1 to clients (RFC 9728 + RFC 8414 metadata, PKCE-S256, pre-registration / CIMD / DCR-shim);
- is a confidential OAuth client to Microsoft Entra ID behind the scenes;
- enforces authorization with an OPA sidecar and a three-layer Rego bundle, including content-based (argument-level) policy and per-tool entitlements via Entra app roles;
- never passes the client's token through — every backend hop uses a fresh, audience-scoped, short-TTL token minted by an On-Behalf-Of or client-credentials exchange;
- writes a structured audit log shaped for NIS2 / DORA / EU AI Act / GDPR reporting.
Quick start — no Entra tenant needed
docker compose --env-file .env.dev \
-f docker-compose.yml -f docker-compose.dev.yml up --build
No app registrations, no client secret, no Microsoft account. dev-idp/ is a
fake identity provider: it serves OIDC discovery and a JWKS, runs the PKCE-S256
authorization-code flow, and implements client_credentials and the jwt-bearer
On-Behalf-Of grant. Nothing in the gateway is stubbed or bypassed — it still
validates signatures against a JWKS it fetched, still refuses to forward an
inbound token, still mints a fresh audience-scoped token per hop. The only thing
that changes is where the authority points, one line in .env.dev:
ENTRA_AUTHORITY=localhost:9443
Then point a client at http://localhost:8443/mcp and log in as one of four people:
| Persona | Roles | What it shows |
|---|---|---|
alice@dev.local | crm.read, ledger.read | Reads work; writes are denied by the tool layer, which names the role to request. |
bob@dev.local | both reads, plus Crm-Customers.Write and Ledger.Write | Fully entitled, so the amount cap in tool.rego is the only control left. It fires at 1,000,001. |
carol@dev.local | none | Denied by the domain layer, before any tool is considered. |
dave@dev.local | bob's roles, plus 250 directory groups | Past ~200 groups Entra drops the groups claim for a Graph pointer. Dave is authorized anyway because domain.rego reads app roles — on a groups-based design he is the user you lock out in production. |
Three things to know: your browser warns once about the IdP's self-signed cert
(AzureProvider builds https:// authority URLs, there is no plaintext option);
localhost:9443 has to reach the IdP from the browser and from inside the
gateway container, which the overlay arranges by sharing a network namespace (see
the header of docker-compose.dev.yml); and it is not a security boundary — it
signs whatever the login page asks for and keeps its state in a dict.
How to read this repo
This is a blueprint, meant to be read, not just deployed. Running it shows that it works; reading it shows how and why. Suggested order, following a single tool call through the system:
policy/data.json— the static data a decision is made against (which roles gate which servers/tools).policy/bundle/mcp/— the three Rego layers (platform.rego→domain.rego→tool.rego) that consult it.gateway-fastmcp/app/middleware/opa_pep.py— the Policy Enforcement Point that calls OPA and acts on the decision.gateway-fastmcp/app/tools/registry.py— the per-tool metadata (elevated role, downstream scope, regulatory tags) the PEP reads.gateway-fastmcp/app/downstream/obo.pyandm2m.py— how the gateway gets a fresh, backend-scoped token for the hop downstream.gateway-fastmcp/app/auth.py— the inbound OAuth 2.1 AS surface clients authenticate against.
Then read Known limitations and What this does not solve — for a teaching reference, the edges of its scope are as much the point as the code itself.
Architecture
The gateway is simultaneously an OAuth 2.1 authorization server (to clients) and a confidential OAuth client (to Entra) — that dual role is what lets unmodified clients work against an IdP that doesn't support DCR. Three tokens are in flight and never confused: the user's Entra token (aud=gateway), the gateway's session token to clients (aud=gateway, internal), and the per-backend OBO token (aud=api://mcp-server-X, short TTL).
One naming note, since it is conflated nearly everywhere: Entra's On-Behalf-Of is a JWT-bearer assertion grant (RFC 7523 §2.1) plus Microsoft's requested_token_use=on_behalf_of — not RFC 8693 token exchange, which is a different grant type the v2.0 token endpoint does not accept. See app/downstream/obo.py.
sequenceDiagram
participant C as MCP client
participant G as Gateway (AS + client)
participant E as Entra ID
participant O as OPA sidecar
participant B as mcp-server-a/b
C->>G: OAuth 2.1 (PKCE-S256) against the gateway
G->>E: Confidential client auth-code flow
E-->>G: User's Entra token (aud=gateway)
G-->>C: Gateway session token (aud=gateway, internal)
C->>G: tools/call (bearer = gateway session token)
G->>O: PEP decision request (actor roles, tool, args)
O-->>G: allow / deny (+ required_role if denied)
G->>E: On-Behalf-Of exchange (per-tool scope)
E-->>G: Downstream token (aud=api://mcp-server-X, short TTL)
G->>B: Tool call (bearer = downstream token)
B-->>G: Result
G-->>C: Result (+ audit row written to Postgres)
Repo layout
| Path | What |
|---|---|
gateway-fastmcp/ | FastMCP gateway — OAuth AS surface (AzureProvider), Entra broker, OBO/M2M exchange, OPA + audit middleware, MCP proxy |
mcp-server-a/ · mcp-server-b/ | FastMCP servers — fake CRM and fake ledger, each validating its own aud |
dev-idp/ | Fake Entra ID for local dev — four personas, real RS256 tokens, no Microsoft account |
policy/bundle/mcp/ | Three-layer Rego, served to OPA: platform.rego · domain.rego · tool.rego |
policy/tests/mcp/ | Rego unit tests — kept OUT of policy/bundle/ so they never load into the serving OPA instance |
policy/trap/ | A rule that looks fail-closed and isn't, next to the fix, both runnable — the worked example from part 2 of the write-up |
db/init/ | Postgres init SQL — the audit_log table |
docs/ | Entra setup · walkthrough · connecting Claude Code |
docker-compose.dev.yml | Overlay that swaps Entra for dev-idp/ — the no-tenant path |
docker-compose.mitm.yml | Optional mitmproxy overlay for inspecting gateway↔backend traffic |
Running against a real tenant
You need Docker + Docker Compose, uv if you want
to run a service outside Docker, and three app registrations in an Entra tenant.
The registrations, the portal settings that matter, and a checklist of the
mistakes that only surface at call time are in
docs/ENTRA_SETUP.md.
cp .env.example .env # fill in tenant id, client id, client secret
./scripts/gen-local-certs.sh # optional: local TLS so the gateway serves https on :8443
docker compose up --build
| Service | URL |
|---|---|
| Gateway | http://localhost:8443 (MCP at /mcp) — https:// if you ran gen-local-certs.sh |
| mcp-server-a | http://localhost:9001/mcp |
| mcp-server-b | http://localhost:9002/mcp |
| OPA | not published to the host — reachable only from other containers at http://opa:8181 |
| Postgres | localhost:55432 (db mcp_audit) |
claude mcp add --transport http mcp-gateway http://localhost:8443/mcp
claude mcp login mcp-gateway # opens browser → Entra login → stores token
See docs/CLAUDE_CODE_AUTH.md for the details, and
what invalidates a stored session.
See it work
Five turns of a real session — two reads, an elevated write, a write under the
cap, and one refusal — are annotated turn by turn in
docs/WALKTHROUGH.md. This is what they left behind:
$ docker compose exec -T postgres psql -U mcp -d mcp_audit -c \
"SELECT tool_name, opa_decision, opa_reason, status, dora_major FROM audit_log ORDER BY ts"
tool_name | opa_decision | opa_reason | status | dora_major
--------------------+--------------+---------------------------------------------+--------+------------
tools/list | t | allowed: domain layer agrees | ok | f
tools/list | t | allowed: domain layer agrees | ok | f
crm_list_customers | t | allowed: all policy layers agree | ok | f
crm_update_customer| t | allowed: all policy layers agree | ok | f
ledger_get_balance | t | allowed: all policy layers agree | ok | f
ledger_post_entry | t | allowed: all policy layers agree | ok | t
ledger_post_entry | f | denied by tool layer: amount exceeds the | denied | t
| | 1000000 cap | |
Five turns, seven rows: the two tools/list decisions are per backend, and the
refusal is a row like any other. Each also carries the delegation chain
(actor_sub / actor_upn, agent_client_id + agent_kind, broker_client_id),
the minted token's jti and aud, latency, and a server-generated trace_id for
SIEM correlation. Columns are in db/init/001_audit_log.sql.
The last row is the one worth dwelling on. The tool layer denied on content, not
entitlement — the caller still holds Ledger.Write, the argument failed. So no
backend credential was ever created: the decision happens before the token
exchange, which means a denied call doesn't mint, doesn't reach the backend, and
can't have a side effect to undo.
The cap is per entry, not per session. Nothing in this gateway would stop a caller from posting 100 entries of 1,000,000 each. The agent in the walkthrough declined to split the transaction, but that was the model's judgment, not a control this repo implements — and a control you are relying on a model to enforce is not a control. Cumulative limits and velocity checks need state the policy layer doesn't have.
Don't take any of this on trust, either: docker compose -f docker-compose.yml -f docker-compose.mitm.yml up routes the gateway's outbound HTTP through
mitmproxy at http://localhost:8081, where you can
decode the backend tokens yourself and check the aud and scp are what this
section claims. The gateway's own HTTPS calls to Entra stay encrypted there — set
LOG_TRAFFIC=true for those.
Adding a tool
A tool exists in three places, and the gateway is deliberately unhelpful until all three agree:
- The backend serves it (
mcp-server-x/app/main.py). policy/data.json→toolsgives it a server, an optionalrequired_role, and any argument constraints.app/tools/registry.pygives it the regulatory tags, the least-privilegedownstream_scope, andpolicy_args— the argument names the gateway is allowed to forward to OPA.
Miss steps 2–3 and the tool is hidden from tools/list and denied if called,
with one WARNING naming it. Listing a tool the PEP will always refuse would be
worse: it advertises a capability that cannot work.
policy_args is the one that bites. It is an allowlist — an argument a tool
doesn't declare never reaches the PDP, so a tool.rego rule constraining an
undeclared argument sees it as absent and denies (fail-closed, but for a
confusing reason). tests/test_args_projection.py checks both directions.
cd gateway-fastmcp && uv sync && uv run pytest # registry ↔ data.json ↔ tool.rego agreement
opa test policy/bundle policy/tests # the policy itself
opa eval -b policy/bundle -i input.json 'data.mcp'
To run one service outside Docker: cd gateway-fastmcp && uv sync && uv run python -m app.main.
Spec compliance
The gateway's OAuth Authorization Server surface is provided by FastMCP's
AzureProvider (gateway-fastmcp/app/auth.py), with a thin bare-path RFC 9728
alias in app/server.py. Each MUST, and the test that proves it:
| Spec item | Test in tests/test_oauth_surface.py |
|---|---|
| RFC 8414 AS metadata, PKCE-S256 + CIMD advertised | test_as_metadata_advertises_pkce_s256_and_cimd |
| RFC 9728 protected-resource metadata (scoped + bare-path alias) | test_protected_resource_metadata_scoped_and_bare_agree |
401 + resolvable WWW-Authenticate: resource_metadata on unauth /mcp | test_unauthenticated_mcp_is_401_with_resolvable_resource_metadata |
DCR (POST /register) | test_dcr_register_issues_a_client_id |
| Confused-deputy consent for DCR/CIMD (forced on in prod) | test_prod_env_forces_consent |
Set GATEWAY_ENV=prod (or GATEWAY_REQUIRE_CONSENT=true) to force the
authorization-consent screen on in any shared deployment.
Protocol revision: this targets 2025-11-25
On fastmcp==3.4.2, pinned exactly. A newer revision, 2026-07-28, supersedes
it; this repo does not implement it yet and may not. Support lands in FastMCP 4,
still beta (4.0.0b2), and pinning a beta dependency in a reference
implementation about enterprise security is not something this repo is willing to
document as a good idea.
The architecture is revision-independent — three identities that must not be
conflated, no token passthrough, OPA deciding before a downstream token exists,
one audit row per call, per-backend audience separation. Verified against
4.0.0b2 in a throwaway venv: AzureProvider, .jwt_issuer, FastMCPProxy,
mount(namespace=…) and the four Middleware hooks all survive untouched. What
would change is three protocol surfaces the PEP does not cover, none of which
FastMCP polices for you:
| Surface | What it is | What the gateway would need |
|---|---|---|
server/discover | Servers MUST implement it; clients MAY call it first | An on_discover hook. Today it is the one method that would reach a backend with no OPA decision and no audit row. |
subscriptions/listen | Replaces the HTTP GET stream and resources/subscribe | FastMCP 4 implements it in the SDK layer with no middleware hook, so it would bypass the PEP entirely — a path around the fail-closed position on resources and prompts. The honest interim answer is an ASGI-layer block until a hook exists. |
| Multi Round-Trip Requests (MRTR) | Replaces sampling/createMessage, elicitation/create and roots/list; a call may return input_required and be retried with inputResponses | Two audit consequences. One logical operation becomes N calls with N unrelated trace_ids, so the record fragments exactly where a human supplied input — needs an operation-level correlation_id. And tool_args_hash covers arguments only, so an MRTR retry with different human input hashes identically. |
One thing gets easier: 2026-07-28 formally deprecates RFC 7591 Dynamic Client
Registration in favour of Client ID Metadata Documents, with a minimum
twelve-month window. platform.rego already splits agent_trusted into a
verified cimd branch and a weaker dcr branch, so that is a comment change
rather than a code change.
Known limitations (read before citing this as complete)
This is a teaching reference. Deliberate simplifications a reader should know:
- Elevated tools are gated by admin-assigned Entra app roles — no runtime
step-up. Each elevated tool declares a
required_role(a gateway app role, e.g.Ledger.Write) that the PEP matches against therolesclaim of the gateway-audience token. A principal without the role is denied (the PEP raisesMissingEntitlementnaming the role to request) and the tool is hidden fromtools/list. App roles are assigned by an administrator in Entra, so there is deliberately no interactive consent/step-up: a hidden tool becomes callable once the role is granted, not by a re-authorization prompt at call time. (An interactive alternative — delegated gateway scopes that a user consents to — is possible but intentionally not used here.) - Only
tools/callis policy-enforced. Resource reads (resources/read) and prompt gets (prompts/get) are fail-closed / denied by the PEP because the policy model is tool-centric — they are not silently passed through. Modeling resources/prompts inpolicy/data.json+ aresource_allowrule is the documented extension point (app/middleware/opa_pep.py). - Static tool registry.
app/tools/registry.pyis hand-maintained rather than discovered from the backends. A backend tool with no registry entry is hidden fromtools/listand denied if called — fail-closed, and reported once at WARNING naming the tool, rather than silently. See Adding a tool above. - Agent identity is weaker than user identity, and is not gated by default.
The MCP client's id is recovered from the FastMCP session JWT and recorded on
every audit row, but
platform.agent_enforcementships as"audit". Even switched to"allowlist"it is a coarse control: the session token is a bearer token, so an allowlist governs who was issued one, never who is using it (token binding — DPoP/mTLS — is not implemented), and a DCR id identifies an installation rather than a product. Authorization rests on the Entra-signed user identity; agent identity is attribution plus an optional supply-chain check. - Per-process state, and half of it is on disk. The OBO/M2M token caches are
in-memory (bounded, TTL-evicted, single-flight). The
AzureProvidersession store is not: with noclient_storagepassed it falls back to an encrypted on-disk file store (Fernet over a file tree) in a directory named after a fingerprint ofGATEWAY_SESSION_SIGNING_KEY. So adocker compose restartkeeps sessions, but recreating the container discards them (it lives in the writable layer, not a volume), rotating the signing key orphans them, and two replicas share nothing at all. Seedocs/CLAUDE_CODE_AUTH.md. Shared, Redis-backed state for both halves is the production fix and is out of scope here.
What this does not solve
This is an authorization gateway, not a complete compliance solution. Out of scope: data classification / content scanning / DLP, model governance, HA / BC / DR, ICT third-party risk due diligence, substantive human-review workflows, DPIA, conformity assessment, end-user transparency notices, and tamper-evident audit storage (plain Postgres is the v1 choice on purpose).
License
MIT — see LICENSE.