Skip to main content
Glama
README.md
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="docs/assets/header-dark.svg">
  <source media="(prefers-color-scheme: light)" srcset="docs/assets/header-light.svg">
  <img src="docs/assets/header-light.svg" alt="mcp-gateway. A hardened MCP server framework for exposing internal tools to LLM clients. Motif: a request passes the auth, tenant and audit gates and reaches the tool, while an unauthenticated request is stopped at the auth gate." width="100%">
</picture>

# mcp-gateway: a hardened MCP server framework for exposing internal tools to LLM clients

[![tests](https://github.com/darrshangovender/mcp-gateway/actions/workflows/tests.yml/badge.svg)](https://github.com/darrshangovender/mcp-gateway/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.11+-3776AB?logo=python&logoColor=white)](https://python.org)
[![MCP](https://img.shields.io/badge/MCP-2026--07--28-6E4AFF)](https://modelcontextprotocol.io/specification/2026-07-28)

> Wrap the tools you want Claude Desktop, an IDE, or an agent to call in the controls a company needs before it lets an AI touch its systems: **tenant isolation, per-tool auth and guardrails, rate limiting, audit logging, telemetry.** MCP 2026-07-28 (and the 2025 revisions) over JSON-RPC 2.0, implemented here rather than imported, over stdio and HTTP.

**Why this exists.** An MCP server is an RPC surface into your company. The moment a model can call `get_customer`, everything behind that function is reachable by whoever holds the client — and by whatever the model was talked into. The reference servers are demos: they have no authentication, no notion of which tenant a caller belongs to, no limit on how often or how much a tool is called, no record of what happened, and they return whatever the tool returned, PII included. This is the production wrapper around those tools. The protocol is implemented in `mcp_gateway/protocol.py` and `mcp_gateway/negotiation.py` so every message is a pydantic model you can read, and the whole thing runs and tests offline.

**The RPC-surface sibling of [guardrail](https://github.com/darrshangovender/guardrail)**, which validates LLM inputs and outputs in general, and [sql-guardrails](https://github.com/darrshangovender/sql-guardrails), which makes LLM-generated SQL safe to execute. This one guards the boundary where a model calls *your* code.

---

## Quick start

Everything below runs offline, with no API keys.

```bash
git clone https://github.com/darrshangovender/mcp-gateway && cd mcp-gateway
pip install -e ".[dev]"                 # pydantic + starlette; redis/otel are optional extras
python examples/crm_server.py demo      # two tenants, four tools, every control tripped once
python examples/client_demo.py          # a stdio client: server/discover, then 2026-07-28 calls
python examples/client_demo.py --legacy # the same server through the 2025 initialize handshake
```

The distribution is named **`agulhas-mcp-gateway`** (the plain name `mcp-gateway` is taken on PyPI); the import package is still `mcp_gateway`. It is not published yet, so install from a clone as above. Once it is, `pip install agulhas-mcp-gateway` will be the install line.

A gateway in one file, calling itself in-process:

```python
import asyncio
import sqlite3

from pydantic import BaseModel

from mcp_gateway import APIKeyAuth, Gateway, RequestContext, ToolPolicy, tenant_filter, tool

db = sqlite3.connect(":memory:")
db.executescript("""
    CREATE TABLE customers (id INTEGER PRIMARY KEY, tenant_id TEXT, name TEXT, email TEXT);
    INSERT INTO customers VALUES (1, 'acme', 'Thandi Mkhize', 'thandi@example.co.za');
    INSERT INTO customers VALUES (2, 'globex', 'Sipho Dlamini', 'sipho@globex.example');
""")


class SearchIn(BaseModel):
    query: str


class SearchOut(BaseModel):
    rows: list[dict]


@tool("search_customers", input_model=SearchIn, output_model=SearchOut,
      scopes=("crm:read",), rate_tier="standard",
      policy=ToolPolicy(max_payload_bytes=2048, allowed_args=frozenset({"query"})))
def search_customers(args: SearchIn, ctx) -> SearchOut:
    tf = tenant_filter()                       # raises if no tenant is bound to the call
    cur = db.execute(f"SELECT id, tenant_id, name, email FROM customers "
                     f"WHERE {tf.sql} AND name LIKE ?", (*tf.params, f"%{args.query}%"))
    cols = [c[0] for c in cur.description]
    return SearchOut(rows=[dict(zip(cols, row)) for row in cur.fetchall()])


auth = APIKeyAuth(salt="change-me")            # in real use: salt and keys come from the environment
auth.add_key("acme-secret", tenant_id="acme", scopes=["tools:list", "tools:call", "crm:read"])

gateway = Gateway(tools=[search_customers], auth=auth)


async def main() -> None:
    meta = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientCapabilities": {}}
    request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
               "params": {"name": "search_customers", "arguments": {"query": "a"}, "_meta": meta}}
    reply = await gateway.dispatcher.dispatch(request, RequestContext(api_key="acme-secret"))
    print(reply["result"]["structuredContent"])   # acme's row only; the e-mail address is redacted


asyncio.run(main())
# To serve it:  gateway.serve_stdio()   or   app = gateway.asgi()   then   uvicorn module:app
```

Prints `{'rows': [{'id': 1, 'tenant_id': 'acme', 'name': 'Thandi Mkhize', 'email': '[EMAIL]'}]}`.

Point Claude Desktop at a script that calls `serve_stdio()` with `MCP_GATEWAY_API_KEY` in its `env`, or run `uvicorn` and POST JSON-RPC to `/mcp` with `Authorization: Bearer <key>`. Bodies over 1 MiB are refused with HTTP 413 before parsing (`gateway.asgi(max_body_bytes=...)`). Browser `Origin`s are refused with 403 unless listed (`gateway.asgi(allowed_origins=[...])`). `GET /mcp/events` is not part of MCP: it is this gateway's own per-tenant SSE feed of call outcomes.

## How it works

```mermaid
flowchart LR
    R[JSON-RPC request] --> V[protocol gate: era · version · _meta · HTTP headers]
    V --> A[auth: key → Principal]
    A --> S[scope check]
    S --> L[rate limit per tenant × tool]
    L --> G[guardrails: size · allow-list · deny_if]
    G --> T[bind TenantContext]
    T --> X[tool]
    X --> O[tenant ownership check]
    O --> P[PII scrub]
    P --> U[audit + span + SSE event]
    U --> Y[JSON-RPC response]
```

1. The transport (stdio or HTTP) hands the dispatcher a raw message, the API key it found and, on HTTP, the request headers. It never interprets the message.
2. The protocol gate decides whether this is a 2026-07-28 request (per-request `_meta`) or a 2025 one, checks the version and the required metadata, and on HTTP checks that `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` agree with the body. It runs before anything else, so a malformed request never reaches auth or a tool.
3. The key resolves to a `Principal(tenant_id, scopes, rate_tier)`. Keys are stored as salted SHA-256 and compared in constant time.
4. `tools:call` plus the tool's own scopes are required; a missing scope is `-32002` (`-41002` on a 2026-07-28 request).
5. A token bucket per `(tenant, tool, tier)` decides; exhaustion is `-32003` / `-41003` with `retry_after`.
6. The tool's `ToolPolicy` checks payload size, argument allow-list and `deny_if`; any failure is `-32001` / `-41001`.
7. The tenant is bound in a contextvar, so concurrent calls cannot see each other's tenant, and the tool runs with validated arguments. On a 2026-07-28 request, arguments that fail validation come back as a tool result with `isError: true` that names the field and the rule, as that revision requires; on a 2025 request they are `-32602`.
8. The result is validated against the output model, walked for rows tagged with a foreign `tenant_id` (`-32004` / `-41004` if any; the audit log records which tenant's rows leaked, the client is told only that the call was blocked), then scrubbed of emails, phone numbers and checksum-valid SA ID numbers, including when zero-width characters are hidden inside them. `resources/read` and `prompts/get` output goes through the same scrub.
9. Every outcome (allowed, denied, errored, including a malformed `tools/call` and one rejected by the protocol gate) is appended to the audit log with a hash of the arguments, never the arguments, and the protocol revision it was served under. A tool's exception text is scrubbed before it is recorded.

## Protocol: MCP 2026-07-28 and the 2025 revisions

The 2026-07-28 revision ([spec](https://modelcontextprotocol.io/specification/2026-07-28), [changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)) removes the `initialize` handshake and protocol-level sessions. A request is judged on its own: it carries the protocol version and the client's capabilities in `params._meta`. A default `Gateway` is **dual-era**: it serves 2026-07-28 and the older handshake revisions on the same stdio process or HTTP endpoint, chosen per request.

| Revision | Served as | How the client opens |
|---|---|---|
| `2026-07-28` | modern, stateless | any request with `_meta`; optional `server/discover` probe |
| `2025-11-25`, `2025-06-18`, `2025-03-26` | legacy | `initialize` (echoes the version it serves, else answers with `2025-11-25`); on HTTP a supported `MCP-Protocol-Version` header, or none for `2025-03-26` |

`Gateway(supported_versions=[...])` narrows the list. Modern-only refuses `initialize` with an error that names the versions it speaks (the spec asks for this, because a legacy client has no other diagnostic); legacy-only answers a modern probe with `UnsupportedProtocolVersionError`, which is how a dual-era client knows to fall back to `initialize`. `examples/client_demo.py` implements that client side.

A 2026-07-28 request:

```json
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
 "params": {"name": "search_customers", "arguments": {"query": "a"},
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
                      "io.modelcontextprotocol/clientCapabilities": {},
                      "io.modelcontextprotocol/clientInfo": {"name": "my-client", "version": "1.0"}}}}
```

On HTTP the same request also carries `MCP-Protocol-Version: 2026-07-28`, `Mcp-Method: tools/call` and `Mcp-Name: search_customers`; a missing or disagreeing header is `400` with `-32020`.

| Method | 2026-07-28 | 2025 revisions |
|---|---|---|
| `server/discover` | yes (no key needed): versions, capabilities, identity, `instructions` | not applicable |
| `initialize`, `notifications/initialized`, `ping` | removed (`-32601`, HTTP 404) | yes |
| `tools/list`, `tools/call` | yes | yes |
| `resources/list`, `resources/read`, `resources/templates/list` (always empty) | yes | yes (templates list is new in 0.2.0) |
| `prompts/list`, `prompts/get` | yes | yes |

What a 2026-07-28 reply carries that a 2025 one does not: `resultType: "complete"`; `_meta["io.modelcontextprotocol/serverInfo"]`; and on `server/discover`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list` and `resources/read`, `ttlMs` (default `0`, set with `Gateway(cache_ttl_ms=...)`) and `cacheScope` (`public` for discovery, `private` for everything else, because the data depends on the caller). `POST /mcp` replies also carry `Cache-Control: no-store`. `tools/list` returns tools in registration order, which is deterministic.

HTTP status codes follow the transport spec: `400` for a missing/invalid `_meta` (`-32602`), an unsupported version (`-32022`, with `data.supported` and `data.requested`) and a header mismatch (`-32020`); `404` with `-32601` for an unknown or removed method; `403` for a refused `Origin`; `202` for a notification; `405` for `GET`/`DELETE` on `/mcp`. `Mcp-Session-Id` and `Last-Event-ID` are ignored and never minted. Everything else, including auth and tool errors, is `200` with the error in the body.

| Error | 2025 request | 2026-07-28 request |
|---|---|---|
| guardrail denied | `-32001` | `-41001` |
| auth / scope denied | `-32002` | `-41002` |
| rate limited | `-32003` | `-41003` |
| tenant violation | `-32004` | `-41004` |
| unknown tool, resource or prompt; bad request params | `-32602` | `-32602` |
| unsupported protocol version / header mismatch | n/a | `-32022` / `-32020` |

The 2026-07-28 text forbids emitting `-32002` (it was "resource not found") and asks implementations not to use `-32000` to `-32019` at all, so the four gateway codes move outside the JSON-RPC range on a modern request. Audit records and `GatewayError.code` keep the original values.

Not implemented, on purpose: `subscriptions/listen` and `listChanged` (the registry is fixed at start-up; the capability is not advertised), multi round-trip requests (the gateway never answers `input_required`), logging, completion, the Tasks extension, roots/sampling/elicitation, pagination (lists come back whole), `x-mcp-header` parameter mirroring, using `_meta.traceparent` as the parent of the call's span (it is accepted, not propagated), and MCP's OAuth authorization framework (API keys instead). `notifications/cancelled` is accepted and ignored.

## The controls

| Control | Module | What it enforces | On failure |
|---|---|---|---|
| Protocol gate | `negotiation.py`, `versions.py` | Era, version, required `_meta`, HTTP header/body agreement, no batching or null ids in 2026-07-28 | `-32602` / `-32022` / `-32020` |
| API-key auth | `auth.py` | Hashed keys, constant-time compare, scopes, rate tier, revocation | `-32002` / `-41002` |
| Tenant isolation | `tenancy.py` | `tenant_filter()` for data access; post-call check for foreign rows | `-32004` / `-41004` |
| Guardrails | `guardrails.py` | Payload cap, argument allow-list, `deny_if` predicate, PII scrub on output | `-32001` / `-41001` |
| Rate limiting | `ratelimit.py` | Token bucket per tenant × tool with named tiers; Redis or memory | `-32003` / `-41003` |
| Audit | `audit.py` | Append-only JSONL: who, tool, args hash, outcome, latency, revision, reason | never skipped |
| Telemetry | `telemetry.py` | One span per call; OpenTelemetry if installed, no-op otherwise | — |
| Protocol | `protocol.py` | pydantic models for every message; `-32700/-32600/-32601/-32602` | per JSON-RPC |

## Threat model

STRIDE, applied to a gateway that sits between an MCP client (and the model behind it) and the tools of one organisation. The left column is the attack; the right two say what this code does about it and, just as important, what it does not. "Tested" means a test in `tests/` fails if the behaviour is removed.

**Trust boundaries.** The client and the model are untrusted: the model can be talked into anything, and the client may be a stolen laptop. The tool code and the process the gateway runs in are trusted. The network is not, but TLS is the reverse proxy's job.

| | Threat | What the gateway does | What it does not do |
|---|---|---|---|
| **S** | A caller claims another tenant's identity | The tenant comes only from the API key's record, never from arguments or headers; `tenant_filter()` takes it from a contextvar (tested under concurrent calls from two tenants) | It cannot stop a tool from trusting a `tenant_id` argument its own author added |
| **S** | A key is guessed, replayed or stolen | Keys stored as salted SHA-256, compared in constant time over the whole list, revocable; per-key scopes and rate tier | No protection against theft from the client's config or the stdio `env`; no expiry or rotation tooling; no OAuth, mTLS or IP binding; a key has no human behind it |
| **S** | A web page rebinds DNS to reach a gateway on localhost | A request with an `Origin` header is refused with 403 unless the origin is on the allow-list (default: none); tested | A non-browser local process is unaffected; bind to `127.0.0.1` and keep key auth on |
| **S** | Forged protocol metadata (`clientInfo`, `Mcp-*` headers) | `clientInfo` is logged nowhere and decides nothing; headers must equal the body or the request is `400`/`-32020`, so a proxy that routes or rate-limits on `Mcp-Name` cannot be fooled by a different body; tested | The gateway trusts whatever the proxy in front of it forwards |
| **T** | Crafted or oversized arguments | pydantic validation, argument allow-list, per-tool size cap, `deny_if`; 1 MiB body cap before parsing; tested | Injection inside a tool (hand-built SQL) is the tool author's; `deny_if` examples are blocklists, not proofs |
| **T** | Prompt injection carried in tool output | Nothing beyond PII scrubbing: output is data handed to the model | Not defended. Narrow tools and narrow scopes limit what an injected instruction can reach |
| **T** | Downgrade: omit `_meta` to land on the older path | The older path runs the same pipeline (auth, scopes, tenancy, guardrails, audit); `supported_versions` can switch it off; tested | Replies on that path have the older shape and the old error codes |
| **R** | "I never made that call" | Every outcome is appended with key id, tenant, tool, argument hash, outcome, latency and revision, including calls the protocol gate refused; tested | The log is append-only by convention, not tamper-evident (no hash chain or signature); a key id is not a person; arguments are hashed, so content cannot be reconstructed |
| **I** | One tenant reads another's rows | `tenant_filter()` plus a post-call walk of the result for rows tagged with a foreign `tenant_id`; the reply never names the other tenant; tested | Rows with no `tenant_id` field are not checked; a tool that aggregates across tenants is not caught |
| **I** | PII in results, resources, prompts or error text | Emails, South African phone formats and checksum-valid SA ID numbers are redacted, also when zero-width characters are hidden inside them; exception text is scrubbed before it reaches the audit log and clients see only the class name; tested | Names, addresses, other countries' IDs and free text pass through; dictionary *keys* are not scrubbed; this is regex plus checksums, not a DLP product |
| **I** | A cache serves one caller's data to another | 2026-07-28 results are `cacheScope: private` except discovery, `ttlMs` defaults to 0, `POST /mcp` replies are `Cache-Control: no-store`; tested | If you set `cache_ttl_ms`, clients decide how they cache |
| **I** | Reconnaissance without a key | Nothing sensitive is returned before auth, but `server/discover`, `initialize` and `/healthz` answer without one | They disclose the server name and version, supported revisions, capabilities and `instructions`; `/healthz` also lists tool names. Put the HTTP transport behind network controls if those are sensitive |
| **D** | Call flooding | Token bucket per tenant × tool with tiers, shared across replicas with Redis; `-32003`/`-41003` with `retry_after`; tested | Unauthenticated traffic is not limited (auth fails first); no per-IP limit; SSE connections are not capped; there is no per-call timeout, so a hung tool holds its slot, and on stdio requests run one at a time |
| **D** | Batch amplification | 2025 batches are accepted, each element passes the limiter and, on HTTP, the whole body is capped; 2026-07-28 forbids batching and the gateway refuses it; tested | stdio has no line-length cap; the client that spawns it is trusted |
| **E** | Calling a tool beyond the key's scopes | `tools:call` plus the tool's own scopes are checked at call time; `tools/list` only hides tools as a convenience; tested | Scopes are flat strings (`*` is a root key); no per-argument authorisation beyond `deny_if`; a tool that holds its own credentials can be a confused deputy |
| **E** | A tool's bug leaks internals | A tool exception becomes `isError: true` with the class name only; unexpected failures become a bare `-32603` and a server-side traceback; tested | The traceback is only as private as your log destination |

Out of scope: TLS, host and container hardening, secret storage, and the model's own safety behaviour. The two runtime dependencies (pydantic, starlette) have not been audited for this README.

## Results

Measured by `python benchmarks/bench_pipeline.py` on the machine named in the output, in-process, one trivial tool, one tenant, no network. It shows the gateway's own cost per `tools/call`, not your tools' cost, and the absolute figures will differ on other hardware, so read the rows against each other.

Measured on: Windows 10, 4 logical CPUs, Python 3.12.13, 2000 calls per row after warm-up.

| Path | median (us) | p95 (us) | gateway overhead vs direct (us) |
|---|---:|---:|---:|
| direct function call (no gateway) | 8 | 15 |  |
| tools/call, legacy request (dispatcher) | 491 | 2,432 | 483 |
| tools/call, 2026-07-28 request (dispatcher) | 478 | 2,254 | 470 |
| tools/call, 2026-07-28 over HTTP (httpx ASGI client) | 5,382 | 148,899 | 5,374 |

So the whole pipeline (protocol gate, auth, scopes, rate limit, guardrails, tenancy, output validation, PII scrub, audit, event) costs about half a millisecond per call here, and the 2026-07-28 request is not measurably dearer than the old one. The HTTP row includes `httpx`'s in-process ASGI client and the Windows timer tick, and the machine was shared while this ran, so its tail is mostly noise: treat the p95 columns as upper bounds and rerun on a quiet machine before quoting them.

Tests: 253, all offline (see below). Of those, 101 exercise the 2026-07-28 revision and dual-era behaviour directly (`tests/test_protocol_2026.py`, `tests/test_http_2026.py`, `tests/test_stdio_2026.py`), counted with `pytest --collect-only`.

## Design decisions

| Decision | Why |
|---|---|
| **Own protocol layer, not the official SDK** | Every message is a pydantic model in one file you can read, and the suite runs with no network and no SDK version drift. |
| **Dual-era by default** | Claude Desktop, IDEs and agent SDKs will not all move to 2026-07-28 on the same day. The gateway reads each request, so one deployment serves both and a client upgrade needs no gateway change. |
| **No session state, even for the old revisions** | The 2025 handshake is answered but remembers nothing, so any replica can serve any request. The cost is that a legacy HTTP request without the version header is taken to be `2025-03-26`. |
| **The gate runs before auth** | A protocol fault is a client bug, not an access decision, and it should never touch a key lookup or a tool. The cost is that those faults are cheap to send unauthenticated (see the threat model). |
| **Gateway error codes move on modern requests** | `-32002` is on the 2026-07-28 do-not-emit list. Remapping on the wire while the audit log keeps the old codes avoids breaking either side. |
| **Bad tool arguments are results in 2026-07-28** | The revision says input validation errors should reach the model as `isError` so it can retry. The message names the field and the rule, never the value sent. |
| **`server/discover` needs no key** | A client probes before it has chosen how to authenticate, and the answer is the same for everyone (`cacheScope: public`). |
| **`Origin` is default-deny** | The spec requires validating it. The only clients that send `Origin` are browsers, which is exactly the DNS-rebinding case. |
| **Tool exceptions are results, not protocol errors** | Per the MCP spec a failing tool returns `isError: true`; the class name goes to the client, the message goes to the audit log only. |
| **Two layers of tenancy** | `tenant_filter()` is what tools should use; the post-call walk catches the tool that forgot. Defence in depth for the failure that leaks the most. |
| **Hash the arguments in the audit log** | Arguments are where the PII lives. A hash still proves *what* was called and lets you correlate repeats. |
| **Rate limiter degrades to memory** | A limiter that takes the gateway down with Redis is worse than one that briefly limits per process. `degraded` is exposed on `/healthz`. |
| **Checksums before scrubbing SA IDs** | A 13-digit invoice reference is not an ID number. Luhn plus a valid date keeps the scrubber from redacting business data. |

## Limitations

- **API keys only.** No OAuth (so none of MCP's authorization framework), no JWT, no per-user identity behind a key. A key is a tenant plus scopes; if you need user attribution, put it in the key id. A 2026-07-28 auth failure is `200` with an error body, not `401`.
- **Protocol coverage is the tools/resources/prompts core.** See "Not implemented" above: no `subscriptions/listen`, `listChanged`, multi round-trip requests, logging, completion, Tasks, pagination or `x-mcp-header`. Roots, sampling and elicitation are deprecated by the revision and not offered.
- **The three 2025 revisions share one code path and are not tested against each revision's full schema.** Where they differ for this method set: batching is accepted on all of them (removed from 2025-06-18); `structuredContent` is sent to all of them (added in 2025-06-18); bad arguments are `-32602` on all of them (2025-11-25 asks for `isError`); an unknown resource is `-32602` on all of them (2025-11-25 and earlier used `-32002`).
- **No streaming tool results.** A tool returns one value; long-running tools should return a handle.
- **stdio handles one request at a time** and `notifications/cancelled` cannot interrupt a running tool. There is no per-call timeout on either transport.
- **The SSE feed at `/mcp/events` is not MCP.** It is one-way, carries the `endpoint` event for clients written against the deprecated HTTP+SSE transport, and relays per-tenant call outcomes. It is the only GET stream; the MCP endpoint itself is POST-only and not resumable.
- **The PII scrubber is regex plus checksums**, tuned for emails, South African phone formats and SA ID numbers. It will miss other identifiers and is not a substitute for not returning the column.
- **In-memory audit and rate limits are per process.** Use `JSONLAuditLog` and `RedisRateLimiter` for anything with more than one replica.
- **Rate limits are per tenant, so unauthenticated traffic is not limited.** Auth fails before the limiter runs; put the HTTP transport behind a reverse proxy with per-IP limiting if it is reachable from the internet. `/healthz`, `server/discover` and `initialize` are unauthenticated.
- **Unexpected exceptions are logged, not raised.** Anything that is not a `GatewayError` becomes a bare `-32603` for the client and a traceback on the `mcp_gateway` logger; configure `logging` or you will not see it.

## Project layout

```
mcp-gateway/
├── mcp_gateway/
│   ├── protocol.py       # JSON-RPC 2.0 + MCP models, error codes, Dispatcher (guard + shaper hooks)
│   ├── versions.py       # supported revisions, _meta keys, which methods exist in which era
│   ├── negotiation.py    # ProtocolGate: per-request era, version, _meta and HTTP header checks
│   ├── auth.py           # APIKeyAuth · Principal · require_scope
│   ├── tenancy.py        # TenantContext · tenant_filter · assert_tenant_owned
│   ├── guardrails.py     # ToolPolicy · check_input · scrub_pii (Luhn SA ID, invisible-character aware)
│   ├── ratelimit.py      # Tier · TokenBucket · MemoryRateLimiter · RedisRateLimiter
│   ├── audit.py          # AuditRecord · JSONLAuditLog · hash_args
│   ├── registry.py       # @tool · @resource · @prompt · Registry
│   ├── telemetry.py      # get_tracer · OTelTracer · NoopTracer · RecordingTracer
│   ├── events.py         # per-tenant pub/sub feeding SSE
│   ├── server.py         # Gateway: the pipeline, discover/initialize, serve_stdio(), asgi()
│   └── transport/        # stdio.py (NDJSON) · http.py (POST /mcp, Origin check, GET /mcp/events)
├── examples/             # crm_server.py (SQLite CRM, two tenants) · client_demo.py (dual-era client)
├── benchmarks/           # bench_pipeline.py
├── tests/                # offline; *_2026.py files cover the 2026-07-28 revision
├── SECURITY.md · CHANGELOG.md · CITATION.cff
├── Dockerfile · docker-compose.yml · .env.example · Makefile
```

## Tests

```bash
pytest tests/             # 253 tests, no network, no Redis, no OpenTelemetry needed
ruff check .
make bench                # the Results table above
```

Protocol conformance for 2026-07-28 (`server/discover`; required `_meta`; version negotiation and the supported list; removed methods; null ids and batching refused; `resultType`, `ttlMs`, `cacheScope`; the code remap and the codes the revision forbids; HTTP `Mcp-*` header validation including the base64 form, 400/404/405/403 statuses, no sessions) and for the older revisions on the same gateway (`initialize` echo and fallback, headerless HTTP, batches, the old codes), plus the earlier suites: auth (missing/wrong key, insufficient scope, revocation), tenancy (cross-tenant read blocked, contextvar isolation under concurrent calls from two tenants, violation replies that name no other tenant), guardrails (oversized payload, allow-list, `deny_if`, PII scrub on tool, resource and prompt output, zero-width characters), rate limiting (refill maths, tier separation, Redis fallback with a fake client), audit (every outcome logged including malformed calls, arguments hashed, exception text scrubbed), HTTP via `httpx.ASGITransport` and the SSE stream, and stdio through real pipes under a cp1252 console. The README's code block and `examples/client_demo.py` are run by the suite. CI runs the suite and `ruff check .` on Python 3.11 and 3.12.

---

Built by [Darrshan Govender](https://github.com/darrshangovender) · [Agulhas Code](https://agulhascode.co.za)