mcp-gateway
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-gatewayshow audit logs for the acme tenant from the last hour"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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 over JSON-RPC 2.0, implemented here rather than imported, over stdio and HTTP/SSE.
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 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
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 driving the server end to endimport os
from pydantic import BaseModel
from mcp_gateway import APIKeyAuth, Gateway, ToolPolicy, tool, tenant_filter
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
rows = db.execute(f"SELECT * FROM customers WHERE {tf.sql} AND name LIKE ?",
(*tf.params, f"%{args.query}%")).fetchall()
return SearchOut(rows=[dict(r) for r in rows])
auth = APIKeyAuth(salt=os.environ["MCP_GATEWAY_KEY_SALT"])
auth.add_key(os.environ["ACME_KEY"], tenant_id="acme", scopes=["tools:list", "tools:call", "crm:read"])
gateway = Gateway(tools=[search_customers], auth=auth)
gateway.serve_stdio() # or: app = gateway.asgi() -> uvicorn module:appPoint Claude Desktop at the script with MCP_GATEWAY_API_KEY in its env, or run uvicorn and send JSON-RPC to POST /mcp with Authorization: Bearer <key>. GET /mcp/events is a per-tenant SSE stream of call outcomes. Bodies over 1 MiB are refused with HTTP 413 before parsing (gateway.asgi(max_body_bytes=...) to change it).
Related MCP server: ToolForge MCP Server
How it works
flowchart LR
R[JSON-RPC request] --> 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 and the API key it found. It never interprets the message.
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.A token bucket per
(tenant, tool, tier)decides; exhaustion is-32003withretry_after.The tool's
ToolPolicychecks payload size, argument allow-list anddeny_if; any failure is-32001.The tenant is bound in a contextvar, so concurrent calls cannot see each other's tenant, and the tool runs with validated arguments.
The result is validated against the output model, walked for rows tagged with a foreign
tenant_id(-32004if 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.resources/readandprompts/getoutput goes through the same scrub.Every outcome — allowed, denied, errored, including a malformed
tools/call— is appended to the audit log with a hash of the arguments, never the arguments. A tool's exception text is scrubbed before it is recorded.
The controls
Control | Module | What it enforces | On failure |
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, reason | never skipped |
Telemetry |
| One span per call; OpenTelemetry if installed, no-op otherwise | — |
Protocol |
| pydantic models for every message; | per JSON-RPC |
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. |
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, 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.
No streaming tool results. A tool returns one value; long-running tools should return a handle.
Protocol version pinned to
2025-03-26and only the core methods are implemented:initialize,ping,tools/*,resources/list|read,prompts/list|get. No sampling, roots, subscriptions orlistChangednotifications.The SSE stream is one-way. It carries the
endpointevent and per-tenant call outcomes; requests still go overPOST /mcp. Full Streamable HTTP resumability is not implemented.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.
/healthzis unauthenticated and lists tool names.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
│ ├── auth.py # APIKeyAuth · Principal · require_scope
│ ├── tenancy.py # TenantContext · tenant_filter · assert_tenant_owned
│ ├── guardrails.py # ToolPolicy · check_input · scrub_pii (Luhn SA ID)
│ ├── 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, serve_stdio(), asgi()
│ └── transport/ # stdio.py (NDJSON) · http.py (POST /mcp, GET /mcp/events)
├── examples/ # crm_server.py (SQLite CRM, two tenants) · client_demo.py
├── tests/ # 128 tests, all offline
├── Dockerfile · docker-compose.yml · .env.exampleTests
pytest tests/ -q # 128 tests, no network, no Redis, no OpenTelemetry needed
ruff check .Protocol conformance (each method, malformed JSON, unknown method, bad params, batches), 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), 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 (including the body cap) and the SSE stream, and stdio through real pipes under a cp1252 console. CI runs the suite on Python 3.11 and 3.12.
Author
Darrshan Govender · Agulhas Code · Durban, South Africa
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.
Operate Linux, macOS and Windows from your LLM. Every action runs through an auditable allowlist.
Related MCP Servers
- FlicenseNot gradedqualityCmaintenanceEnables 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-
- FlicenseNot gradedqualityBmaintenanceEnables AI agents to securely discover, execute, and observe tools with role-based access control and audit logging. Serves tools over MCP stdio and HTTP for integration with Claude Desktop, Cursor, and other clients.1-
- AlicenseAqualityBmaintenanceExposes internal databases, HTTP APIs, and files to LLM agents over MCP, with auto-generated schemas, access controls, rate limiting, redaction, and audit logging.3MIT

OpenToolofficial
AlicenseNot gradedqualityCmaintenanceEnables AI agents to execute provider tools through a self-hostable gateway with scoped keys, permissions, and accounting reservations.Apache 2.0