case study · python · deployed
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.
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.
Every call passes the same four checks, in the same order, and leaves a record whether it succeeded or not.
| Decision | Chosen | Instead of | Why, 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.
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.
Three rules that are easy to miss and that a strict client will punish, each covered by a test:
id, receives no response at all, not even an error.isError and the model can read it and try
something else. Only protocol faults are errors.tools/list, and echoing the inventory into every error is free reconnaissance.