If you have built a Model Context Protocol server with a passthrough tool, the kind that
lets the model call any endpoint, there is a real chance its write operations are quietly
broken in some clients. You may not have noticed, because reads work.
I hit this building an open source MCP server for Hetzner. Listing worked perfectly. Every
create, update, and delete came back from the API with the same complaint.
A valid JSON document is required.
The body was arriving empty. Here is why, and the single line that fixed it.
First, here it is working. This server stands up a load-balanced stack and tears it back
down in plain language, with a cost guard in front of every billed action.
The symptom
A passthrough tool needs a free-form body so the model can send any payload. The obvious
way to type that in Zod looks harmless.
body: z.unknown().optional()
Reads need no body, so every GET sailed through. The moment the model tried a POST, the
body never reached the API.
The root cause
z.unknown() compiles to an empty JSON schema. Not an object, not a string, an empty shape.
Several MCP clients drop a property whose schema is empty. There is nothing to validate and
nothing to send, so the field is stripped before the request leaves the client. Your server
receives the call with the body undefined, forwards an empty payload, and the API rejects it.
The cruel part is that the error comes from the upstream service, not from your server, so
you go hunting in the wrong place for an hour.
The fix
Give the body a real schema. An object is enough, and accepting a JSON string as well makes
it work no matter how a client serializes the payload.
body: z.union([z.record(z.string(), z.unknown()), z.string()]).optional()
Then parse a string body into an object before you use it. The schema is now non-empty, so
clients forward the field, and the string branch covers the clients that send serialized
JSON. That is the whole fix.
How I knew the fix actually held
A schema change you cannot see is a schema change you cannot trust. So I wrote a suite that
drives the compiled server over stdio, the exact artifact a client loads, against the live
API. Reads, the cost and delete guards, and a create then delete for every resource type.
Every billed resource is removed in a finally block, so a failure never leaks a charge.
It caught the bug, proved the fix, and runs on every change now. The number that matters is
not that it compiles, it is that every tool answered the real API and the account was left
clean.
If you build MCP servers
Two things to carry away.
- Audit any tool that types an input as unknown or leaves it untyped. Give it a real schema.
- Test write tools through the built server against the real service, not only your unit tests. This failure only appeared at the client boundary.
The server I found this in is open source under MIT. It gives an AI assistant the full
Hetzner platform, Cloud, Storage Box, and Robot dedicated servers.
npx -y hetzner-mcp
What is the most painful silent failure you have hit building MCP tools?
Top comments (0)