Skip to main content
Glama
marlyngiselle

Governed MCP Gateway

Governed MCP Gateway

Let AI assistants work with your operations tools without handing them the keys.

In numbers

3

MCP servers (cloud inventory, runbooks, tickets) sharing one governance layer

8

Tools, 7 read and 1 write. The write tool runs only with confirm=true

7

Stages every call passes, from authentication to audit

3

Role policies in YAML, deny by default

10

Secret and PII detectors on every result, plus sensitive field names

10

Forged or invalid JWT cases rejected in tests, including alg: none and HS256 key confusion

16

Security controls, each mapped to a threat, a file and a test

88

Tests, including end to end over stdio and Streamable HTTP with a real MCP client

0

Ways to register a tool that skips the gateway

Related MCP server: varco_mcp

What it is

A reference implementation of how to connect AI assistants to enterprise systems through the Model Context Protocol, with the controls a security review will ask for already in place. Three example servers expose realistic operations tools over synthetic data. Every tool call goes through the same gateway, which knows who is calling, what their role allows, whether the action changes anything, and what must never leave the boundary. The result is an assistant that is useful on day one and an audit trail that answers who asked it to do what.

Architecture

flowchart LR
    subgraph clients["fa:fa-user MCP clients"]
        direction TB
        C1["fa:fa-desktop Desktop assistant<br/>stdio"]
        C2["fa:fa-robot Agent runtime<br/>Streamable HTTP"]
    end

    subgraph idp["fa:fa-id-badge Identity provider"]
        J["JWKS"]
    end

    subgraph gw["fa:fa-shield-halved Gateway, one pipeline for every tool"]
        direction TB
        G1["Authenticate"] --> G2["Rate limit"] --> G3["Authorise by role"] --> G4["Confirm writes"]
        G5["Redact"] --> G6["Audit"]
    end

    subgraph srv["fa:fa-server MCP servers"]
        direction TB
        S1["cloud-inventory"]
        S2["runbooks"]
        S3["tickets"]
    end

    subgraph be["fa:fa-database Systems behind them"]
        direction TB
        B1["Resource inventory and cost"]
        B2["Runbook library"]
        B3["Ticketing"]
    end

    C1 --> G1
    C2 --> G1
    J -. verify token .-> G1
    G4 --> S1 & S2 & S3
    S1 --> B1
    S2 --> B2
    S3 --> B3
    S1 & S2 & S3 -. result .-> G5
    G6 --> L["fa:fa-file-lines audit.jsonl"]

    classDef client fill:#0b1f4d,stroke:#7fa6ff,stroke-width:2px,color:#ffffff
    classDef gate fill:#2456c8,stroke:#c9d8ff,stroke-width:2px,color:#ffffff
    classDef server fill:#1a3a8a,stroke:#7fa6ff,stroke-width:2px,color:#ffffff
    classDef backend fill:#16367f,stroke:#7fa6ff,stroke-width:2px,color:#ffffff
    classDef ext fill:#ffffff,stroke:#2456c8,stroke-width:2px,color:#0b1f4d
    class C1,C2 client
    class G1,G2,G3,G4,G5,G6 gate
    class S1,S2,S3 server
    class B1,B2,B3 backend
    class J,L ext

The gateway is a library, not a proxy. Each server is a GovernedServer, a subclass of the SDK's MCPServer, and tools can only be added through governed_tool(access="read" | "write"). Plain @tool() raises, and a write tool without a confirm: bool = False parameter is refused at startup. The reasoning is in ADR 0001.

One tool call

sequenceDiagram
    autonumber
    participant U as User
    participant A as AI assistant
    participant G as Gateway
    participant T as tickets server
    participant L as Audit log
    U->>A: Open a ticket for the queue backlog
    A->>G: create_ticket(title, service)
    G->>L: deny, needs confirm=true
    G-->>A: Denied. Show the user and ask first
    A->>U: I will open "Queue backlog on batch" as sev3. Go ahead?
    U->>A: Yes
    A->>G: create_ticket(title, service, confirm=true)
    G->>G: token valid, role operator, under rate limit
    G->>T: create
    T-->>G: TCK-1007, reporter lee@example.test
    G->>G: redact PII
    G->>L: allow, ok, 1 redaction
    G-->>A: TCK-1007, reporter [REDACTED:email]

The denial message is written for the assistant, so it knows what to do next instead of retrying blindly. The reporter is stamped from the authenticated identity, never from the arguments.

Roles

Capability

reader

operator

admin

List and read cloud resources

yes

yes

yes

Monthly cost by tag (showback)

no

yes

yes

Search and read runbooks

yes

yes

yes

Search and read tickets

yes

yes

yes

Open tickets (with confirm=true)

no

yes

yes

Rate limit, requests per minute (burst)

30 (10)

60 (20)

120 (40)

Tools a role cannot call are removed from tools/list, so they never enter the assistant's context. Over HTTP the role comes from the roles claim of the caller's token, and the highest priority known role wins.

Quick start

git clone https://github.com/marlyngiselle/governed-mcp-gateway.git
cd governed-mcp-gateway
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest

Run the tickets server over Streamable HTTP with a local signing key, then call it as two different roles.

governed-mcp dev-keys --out .dev
export GOVERNED_MCP_ISSUER=https://idp.example.test GOVERNED_MCP_AUDIENCE=governed-mcp
export GOVERNED_MCP_JWKS_FILE=.dev/jwks.json GOVERNED_MCP_PORT=8003 GOVERNED_MCP_AUDIT_LOG=.dev/audit.jsonl
governed-mcp serve tickets --transport http &

export GOVERNED_MCP_TOKEN=$(governed-mcp dev-token --role operator)
python examples/call_tool.py http://127.0.0.1:8003/mcp search_tickets '{"status": "open", "limit": 2}'

export GOVERNED_MCP_TOKEN=$(governed-mcp dev-token --role reader)
python examples/call_tool.py http://127.0.0.1:8003/mcp create_ticket '{"title": "Smoke test", "description": "Created from the quick start.", "service": "platform", "confirm": true}'

The second call is refused with Denied: role 'reader' does not allow 'tickets.create_ticket'. All three servers run together with docker compose up --build on ports 8001 to 8003. Client JSON for stdio and HTTP is in docs/client-configuration.md.

Policy as code

# policies/reader.yaml
role: reader
description: Read-only access for assistants that answer operational questions.
priority: 10
allow_writes: false
tools:
  allow:
    - "cloud-inventory.*"
    - "runbooks.*"
    - "tickets.search_tickets"
    - "tickets.get_ticket"
  deny:
    - "cloud-inventory.cost_by_tag"
rate_limit:
  requests_per_minute: 30
  burst: 10

Four rules, nothing hidden. Deny by default, explicit deny wins, writes need allow_writes, unknown roles get nothing. Unknown fields in a policy file fail validation, so a typo cannot silently widen access.

What the audit log records

One JSON line per call, allowed or denied. This is the line written when an operator's assistant tried to open a ticket without asking first.

{"ts":"2026-10-26T14:41:08.590+00:00","event":"tool_call","request_id":"623586b5-857b-4484-b915-b70beddda200","server":"tickets","tool":"create_ticket","access":"write","principal":"lee@example.test","client_id":"local-mcp-client","role":"operator","auth":"oidc","args_hash":"5f236d71e5166adebacbb137a44f70587f5b8c002a6d9513880f9b181ef7413a","args_hash_alg":"sha256","decision":"deny","reason":"'tickets.create_ticket' changes state and needs confirm=true. Show the user what will be done and ask before retrying.","outcome":"denied","latency_ms":0.051,"redactions":0}

Arguments are stored as a hash of their canonical JSON, never in clear, so the audit trail does not become a second copy of the data. Set GOVERNED_MCP_AUDIT_HASH_KEY to switch to HMAC-SHA256.

Threats and controls

Threat

Main controls

Proven by

Prompt injection leading to tool misuse

Role policies, hidden tools, write confirmation, severity ceiling

test_prompt_injection_in_a_runbook_cannot_turn_into_a_write

Confused deputy

Role from the caller's own token, no shared service credential

test_role_comes_from_the_token

Data exfiltration

Redaction before results leave the server, masked internal errors

test_secrets_and_pii_are_redacted_from_results

Abuse and enumeration

Per-client rate limit, applied before authorisation

test_rate_limit_applies_per_client

Token forgery

JWKS, issuer, audience, expiry, asymmetric algorithms only

tests/test_auth.py

The full model, with 16 controls and what this design does not solve, is in docs/security-model.md.

Before you build on it

IMPORTANT

The three back ends are synthetic. Inventory, runbooks and tickets return fixed, fictional data so the project runs anywhere and tests are deterministic. Replacing one with a real API means implementing the same small adapter interface. The gateway does not change.

WARNING

On stdio, the role is whateverGOVERNED_MCP_ROLE says, set by the person who configures the client. That is the right trust model for a local tool acting as its user. For anything shared or remote, use Streamable HTTP, where the role comes from a verified token.

CAUTION

confirm=true proves the assistant asked, not that the human read the question. Keep write tools narrow, reversible and below the severity that pages people. Assistants here cannot open sev1.

NOTE

Version 2 of the MCP Python SDK renamedFastMCP to MCPServer. This project targets mcp>=2.3,<3.

Repository map

Path

What it holds

src/governed_mcp/gateway/

The governance layer. Pipeline, policies, identity, JWT verification, rate limiter, redaction, audit

src/governed_mcp/servers/

The three MCP servers and their tool schemas

src/governed_mcp/adapters/

Synthetic back ends standing in for real APIs

src/governed_mcp/app.py

Wiring for stdio and Streamable HTTP. HTTP refuses to start without OIDC

src/governed_mcp/devkeys.py

Local signing key and token minting for development

policies/

Role policies: reader, operator, admin

runbooks/

Five synthetic runbooks, one of them carrying a prompt injection on purpose

tests/

88 tests across policy, redaction, rate limits, JWT, audit, tools, stdio and HTTP

examples/call_tool.py

A minimal MCP client session over HTTP

Dockerfile, docker-compose.yml

Non-root image from a hashed lock file, three services on ports 8001 to 8003

.github/workflows/ci.yml

Ruff, mypy strict, pytest on 3.11 to 3.13, hadolint and image build

Documentation

Document

Read it for

Architecture

Components, the call pipeline and the two transports

Security model

Threats, 16 controls with their tests, and the known gaps

Add a tool in three steps

Write the tool, allow it in policy, test it through a client session

Client configuration

JSON for stdio and HTTP clients, and every environment variable

ADR 0001

Why governance lives inside each server and not in a proxy

ADR 0002

Why role policies are deny-by-default YAML

ADR 0003

Why HTTP is stateless and acts only as an OAuth resource server

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    C
    maintenance
    Enables controlled AI-agent access to enterprise-shaped tools with a deny-by-default gated write path, human approval, dry-run execution, and append-only audit logging.
    1
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables MCP-compatible AI agents to safely act on business backends by enforcing per-agent permissions, autonomy thresholds, human approval with review-and-edit, and full audit trails.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables LLM agents to access third-party APIs through a least-privilege set of audited tools with allowlists, per-caller scopes, label gates, write confirmation, limits, and PII-masked logging. It restricts reads to labeled data, prevents sending or destructive actions, and records every allowed or denied call.
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI agents to safely invoke enterprise tools such as Supabase PostgreSQL and the Notion API through a zero-trust gateway that performs server-side policy and risk evaluation, cryptographically binds approval tickets to exact parameters, and requires out-of-band human approval for destructive operations. It also scrubs credentials from logs and responses while providing audit trails, rate limiting, and a web console for reviewing pending authorizations.
    Apache 2.0