mcp-gateway
by kenbin64
README.md
# 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](https://github.com/kenbin64/citeline), a retrieval service over
40 CFR Part 141, the US National Primary Drinking Water Regulations.
| Tool | What it does |
|---|---|
| `regulations.search` | Hybrid search over the regulations, returning passages with their CFR citations and how each was matched |
| `regulations.corpus` | 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 `isError` and 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
```bash
make install
make check # ruff, ruff format, mypy, pytest
make run # http://127.0.0.1:8812
```
```bash
curl -s localhost:8812/tools | jq
curl -s localhost:8812/mcp -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq
```
`make 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](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues