mcp-gateway
mcp-gateway
An audited Model Context Protocol server. It gives a language model a closed set of read only tools over internal services, validates every argument against a declared schema, and logs every call including the ones it refused.
POST /api/mcp/mcp {"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"regulations.search",
"arguments":{"question":"lead action level","limit":2}}}
[1] 40 CFR 141.80 - General requirements.
similarity 0.855, matched on meaning and on exact terms
[2] 40 CFR 141.86 - Monitoring requirements for lead and copper.
similarity 0.810, matched on meaning
Cite these by their CFR reference. If none of them actually answers the
question, say so rather than inferring an answer from the titles.The problem
Connecting a model to internal data is the thing every company is currently trying to do, and the demo is easy. The hard part is everything a security review will ask afterwards:
What exactly can it reach, and who decided that?
What happens when the model sends arguments that are wrong, or hostile?
What did it actually do last Tuesday, and can you show me?
When something breaks, does the error tell the model more than it should know?
This is a gateway built around those questions rather than around the demo.
What it does
A closed tool list. A tool exists only if it was registered in code. There is no path by which a caller names a function and has it run. Write tools are refused at registration, so the gateway cannot grow one by accident.
Arguments are validated, not coerced. A model that sends
"5"where an integer belongs gets an error rather than a helpful guess. Bounds, lengths and enums are enforced; unknown fields are dropped so the schema always describes what the handler receives.Every call is audited, including refusals, timeouts and crashes. The record carries the tool, the caller, the outcome, the duration, and a fingerprint of the arguments rather than the arguments themselves, so the audit log does not become a second copy of the data the gateway is meant to be careful with.
Failures do not leak. An unexpected exception is logged in full and reported to the model as
<tool> failed. There is a test that raises an error containing a database password and asserts it does not reach the response.Per tool timeouts, a per client rate limit, and optional bearer auth compared in constant time.
The tools
Real ones, over a real corpus. They call citeline, a retrieval service over 40 CFR Part 141, the US National Primary Drinking Water Regulations.
Tool | What it does |
| Hybrid search over the regulations, returning passages with their CFR citations and how each was matched |
| What is indexed: which regulations, which edition, how much |
The backend abstains when the corpus does not support an answer, which is the reason it is worth putting behind a tool. A model asking about something outside 40 CFR 141 is told the scope rather than handed plausible looking passages to build a confident answer from.
Protocol
MCP over JSON-RPC 2.0: initialize, tools/list, tools/call, ping, and
notifications/initialized. Batches are supported.
Three JSON-RPC details that are easy to get wrong and are tested here:
A notification (a request with no
id) receives no response at all, not even an error.A tool failure is not a JSON-RPC error. The call succeeded and the tool reported a problem, so the result carries
isErrorand the model can read the message and try something else. Only protocol faults are errors.An unknown tool does not list the available ones. The model already has them from
tools/list, and echoing the inventory into every error is free reconnaissance.
Run it
make install
make check # ruff, ruff format, mypy, pytest
make run # http://127.0.0.1:8812curl -s localhost:8812/tools | jq
curl -s localhost:8812/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jqmake check runs everything a CI provider would, on any machine. That is
deliberate: the checks are the project's, not a hosting provider's.
Deploying
deploy/ carries a systemd unit and an nginx snippet. The service binds to
loopback and nginx terminates TLS in front of it, so the gateway is never
directly reachable. The unit runs with ProtectSystem=strict, a read only home,
a restricted address family set and a memory cap, because a process that reads
from one loopback backend and writes nothing but logs does not need the machine.
Honest limits
Bearer auth is a single shared secret. That is appropriate for one trusted caller and is not an identity system; per client keys with rotation would be the next step.
The rate limiter is in process, so it counts per instance. Behind more than one replica the real limit is the sum of them.
The argument validator understands only the schema shapes these tools declare and refuses anything else rather than waving it through. If the schemas grow, the right move is a real JSON Schema library, not more clauses.
Read only by design. Exposing a tool that changes something needs a conversation about consent and blast radius that this does not attempt.
Licence
MIT. See LICENSE.