mcp-gateway
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
mcp-gateway: a hardened MCP server framework for exposing internal tools to LLM clients
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, which validates LLM inputs and outputs in general, and 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.
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 handshakeThe 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:
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:appPrints {'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 Origins 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.
Related MCP server: mcp-server-template
How it works
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]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.
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 thatMCP-Protocol-Version,Mcp-MethodandMcp-Nameagree with the body. It runs before anything else, so a malformed request never reaches auth or a tool.The key resolves to a
Principal(tenant_id, scopes, rate_tier). Keys are stored as salted SHA-256 and compared in constant time.tools:callplus the tool's own scopes are required; a missing scope is-32002(-41002on a 2026-07-28 request).A token bucket per
(tenant, tool, tier)decides; exhaustion is-32003/-41003withretry_after.The tool's
ToolPolicychecks payload size, argument allow-list anddeny_if; any failure is-32001/-41001.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: truethat names the field and the rule, as that revision requires; on a 2025 request they are-32602.The result is validated against the output model, walked for rows tagged with a foreign
tenant_id(-32004/-41004if 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/readandprompts/getoutput goes through the same scrub.Every outcome (allowed, denied, errored, including a malformed
tools/calland 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, 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 |
| modern, stateless | any request with |
| legacy |
|
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:
{"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 |
| yes (no key needed): versions, capabilities, identity, | not applicable |
| removed ( | yes |
| yes | yes |
| yes | yes (templates list is new in 0.2.0) |
| 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 |
|
|
auth / scope denied |
|
|
rate limited |
|
|
tenant violation |
|
|
unknown tool, resource or prompt; bad request params |
|
|
unsupported protocol version / header mismatch | n/a |
|
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 |
| Era, version, required |
|
API-key auth |
| Hashed keys, constant-time compare, scopes, rate tier, revocation |
|
Tenant isolation |
|
|
|
Guardrails |
| Payload cap, argument allow-list, |
|
Rate limiting |
| Token bucket per tenant × tool with named tiers; Redis or memory |
|
Audit |
| Append-only JSONL: who, tool, args hash, outcome, latency, revision, reason | never skipped |
Telemetry |
| One span per call; OpenTelemetry if installed, no-op otherwise | — |
Protocol |
| pydantic models for every message; | 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; | It cannot stop a tool from trusting a |
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 |
S | A web page rebinds DNS to reach a gateway on localhost | A request with an | A non-browser local process is unaffected; bind to |
S | Forged protocol metadata ( |
| 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, | Injection inside a tool (hand-built SQL) is the tool author's; |
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 | The older path runs the same pipeline (auth, scopes, tenancy, guardrails, audit); | 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 |
| Rows with no |
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 | If you set |
I | Reconnaissance without a key | Nothing sensitive is returned before auth, but | They disclose the server name and version, supported revisions, capabilities and |
D | Call flooding | Token bucket per tenant × tool with tiers, shared across replicas with Redis; | 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 |
| Scopes are flat strings ( |
E | A tool's bug leaks internals | A tool exception becomes | 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 |
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 |
|
Bad tool arguments are results in 2026-07-28 | The revision says input validation errors should reach the model as |
| A client probes before it has chosen how to authenticate, and the answer is the same for everyone ( |
| The spec requires validating it. The only clients that send |
Tool exceptions are results, not protocol errors | Per the MCP spec a failing tool returns |
Two layers of tenancy |
|
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. |
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
200with an error body, not401.Protocol coverage is the tools/resources/prompts core. See "Not implemented" above: no
subscriptions/listen,listChanged, multi round-trip requests, logging, completion, Tasks, pagination orx-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);
structuredContentis sent to all of them (added in 2025-06-18); bad arguments are-32602on all of them (2025-11-25 asks forisError); an unknown resource is-32602on 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/cancelledcannot interrupt a running tool. There is no per-call timeout on either transport.The SSE feed at
/mcp/eventsis not MCP. It is one-way, carries theendpointevent 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
JSONLAuditLogandRedisRateLimiterfor 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/discoverandinitializeare unauthenticated.Unexpected exceptions are logged, not raised. Anything that is not a
GatewayErrorbecomes a bare-32603for the client and a traceback on themcp_gatewaylogger; configureloggingor 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 · MakefileTests
pytest tests/ # 253 tests, no network, no Redis, no OpenTelemetry needed
ruff check .
make bench # the Results table aboveProtocol 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 · Agulhas Code
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Maintenance
Related MCP Connectors
Zero-trust gateway for AI agents: score tool calls, verify agent cards, enforce policy, audit.
Security gateway for AI agents: policy, approval, and audited execution, no secrets shared.
Deterministic runtime safety for AI agents: scan PII, gate tool actions, verify LLM output.
OpenAI-compatible BYOK LLM gateway: per-task routing, shadow evaluation, promotion gates.
Related MCP Servers
- AlicenseAqualityCmaintenanceExposes internal databases, HTTP APIs, and files to LLM agents over MCP, with auto-generated schemas, access controls, rate limiting, redaction, and audit logging.3MIT
- AlicenseAqualityCmaintenanceEnables developers to expose product operations as model-callable tools over stdio or Streamable HTTP with bearer-token auth, per-client rate limits, host allow-listing, structured call logging, and a swappable backend for secure, testable deployments.3MIT
- AlicenseNot gradedqualityCmaintenanceEnables 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
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to call backend tools under per-agent policy, deciding which tools each agent can see, what arguments it may pass, how often it may call, and which writes wait for human approval. It also redacts secrets and PII from tool results, fences untrusted output against injection, and records every call in a hash-chained audit log.19 npmMIT