Kenneth W. Bingham

case study · python · deployed

A tool server a security review would pass.

mcp-gateway gives a language model a closed set of read only tools over internal services. It validates every argument against a declared schema, logs every call including the ones it refused, and makes sure a crash cannot tell the model anything it should not know.

The problem

Connecting a model to internal data is what nearly every company is trying to do right now, and the demo takes an afternoon. The hard part arrives immediately afterwards, in the form of four questions:

A gateway that cannot answer those is not going into production anywhere that has an audit. This one is built around them rather than around the demo.

Architecture

Every call passes the same four checks, in the same order, and leaves a record whether it succeeded or not.

Request path through the gateway A JSON-RPC call passes authentication, rate limiting, tool lookup against a closed registry, and schema validation before the tool runs under a timeout. Every outcome, including each refusal, is written to the audit trail. model JSON-RPC 2.0 auth constant time rate limit per client registry closed list validate args no coercion tool runs under a timeout audit trail: tool, client, outcome, duration, argument fingerprint refusals are recorded too, because a rejected call is usually the interesting one a backend credential never travels outbound; the model only ever sees tool output
One path, four refusal points. A call can be turned away for being unauthorised, too frequent, naming a tool that does not exist, or carrying arguments that do not match the declared schema. All five outcomes land in the same audit trail.

Engineering trade-offs

DecisionChosenInstead ofWhy, and what it costs
Tool discovery A closed registry in code Reflection over a module There is no path by which a caller names a function and has it run. Adding a tool is a code change and a review. The cost is that adding one is a deploy, not configuration.
Argument handling Validate and refuse Coerce helpfully A model sending "5" where an integer belongs has made a mistake worth surfacing. Guessing what it meant is how a validator stops being one. The cost is occasional friction when a model is nearly right.
Unknown fields Dropped, not rejected Rejecting the whole call Models invent a plausible extra field often enough that failing the call is unhelpful when every declared argument is valid. Dropping keeps the schema an accurate description of what the handler receives.
Audit contents A fingerprint of arguments The arguments themselves Arguments are model generated text containing whatever a user typed, so logging them verbatim makes the log a second copy of the data the gateway exists to be careful with. A hash still answers "was this the same call", "how often does it repeat", and "did the arguments change between the failure and the retry". The cost is that the log alone cannot reconstruct a request.
Write tools Refused at registration Allowed with a flag A tool that changes something needs a conversation about consent and blast radius that has not happened. The registry refuses one outright rather than leaving that judgement to whoever adds the next tool in a hurry.

The failure that matters most. An unexpected exception is logged in full and reported to the model as <tool> failed, nothing more. There is a test that raises an error containing a database password and asserts it never reaches the response, because anything handed to a model can end up in a transcript and out of your hands.

Try it

The running service, not a recording. It fronts citeline, so the tools search real federal regulations.

curl -s https://butterflyfx.us/api/mcp/healthz

curl -s https://butterflyfx.us/api/mcp/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

curl -s https://butterflyfx.us/api/mcp/mcp \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{
        "name":"regulations.search",
        "arguments":{"question":"What is the lead action level?","limit":3}}}'

Send "limit":"lots" instead and it answers limit must be an integer rather than guessing.

Protocol details worth getting right

Three rules that are easy to miss and that a strict client will punish, each covered by a test:

Limits

29tests
2tools exposed
5audited outcomes
0write tools