Dev.to · 6 min read

Five ways real MCP clients broke our server

Five ways real MCP clients broke our server

An agent once told one of our users that our MCP tools "cannot create projects" and asked them to make four projects by hand. The tools could. The sentence saying so had been cut off before the agent ever saw it. That was the fourth way real clients broke our server. I build StateLode, a hosted task board that coding agents read and write over MCP. The server is small: three tools behind one Streamable HTTP endpoint, stateless, a fresh server and transport per request. It passed every test we wrote. The first two bugs below showed up within a week of the endpoint going live; the rest over the next two months. None of them are exotic. If you put an MCP server on the internet, you'll probably hit at least two. 1. We only routed POST, and clients quietly gave up Our route looked like this: app.post("/mcp", requireAuth, rateLimit, async (c) => { /* ... */ }); JSON-RPC calls are POSTs, so that seems fine. But a Streamable HTTP client POSTs initialize and then immediately sends a GET to the same URL to open the server-to-client SSE stream. Our framework answered that GET with its default 404. A client doesn't read 404 there as "this server doesn't stream". It reads it as "the session is gone", reconnects, gets another 404, and after five in a row drops the server. In Cursor this was a loop of "Failed to open SSE stream: Not Found", and then the tools simply stopped working. The SDK's transport already dispatches on method. It just has to receive the requests: app.on(["POST", "GET", "DELETE"], "/mcp", requireAuth, rateLimit, async (c) => { /* same handler */ }); If you really don't want to offer a GET stream, the spec's answer is 405 Method Not Allowed, not 404. Clients treat those very differently. Lesson: bind every method the transport handles, not just the one you think of as "the API". 2. Our replies were SSE-framed, and one client only parsed JSON By default, Streamable HTTP frames the response to a POST as an SSE event: event: message data: {"jsonrpc":"2.0","id":1,"result":{...}} The spec allows that, and most clients handle it. Antigravity didn't: it expected a plain JSON body, read EOF off the stream, and failed initialize. Our tools are pure request/response. They never send notifications mid-call, so SSE framing on POST bought us nothing. One flag returns a single application/json body instead: const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, // stateless enableJsonResponse: true, }); The GET stream is still there for clients that want it. A related trap: the SDK rejects a POST with 406 unless the client sends Accept: application/json, text/event-stream. If you test with curl, send both, or you'll chase the wrong bug. 3. Our input schema rendered as an empty object Our write tool, task_mutate, takes { action, payload }, where the payload fields depend on the action (create, move, comment and so on). The natural Zod type is a discriminated union. The SDK accepts it, and validation works. But with SDK 1.29, tools/list advertised it as: { "type": "object", "properties": {} } The JSON Schema conversion only walks plain object shapes, so the union vanished. Agents saw a tool with no parameters. A plain z.union for the payload isn't a way out either. Zod takes the first branch that matches, and move's payload also matches the update schema, so status gets silently stripped. What we ship now: action is an enum, payload is an open object, the server validates each action precisely, and the tool description lists each action's required and optional fields. That list sits right next to the schemas, with a note to change both together. 4. The description got cut off, and the agent believed what was left Moving the contract into the description created the next problem. Some clients truncate tool descriptions, and we've seen the cut at about 2,048 characters. Our description did say that create makes the project if it doesn't exist yet. That sentence sat at about character 2,150, in a paragraph near the end. The agent read up to the cut, saw create → projectId, title, decided the project had to exist already, and sent its user to the dashboard to create four projects by hand. None of that was needed. To an agent, a capability it can't see doesn't exist, and it will send the human to do the work instead of trying. The description is now ordered by how bad it is to lose each part, not by how nicely it reads: actions first, then project creation, then enums, then locking rules. Advisory text goes last and is repeated in the server's instructions, so it's the cheapest part to lose. When the description changes, we check where the 2,048-character mark falls instead of eyeballing it. 5. OAuth told us who the person was, but not which tool We wanted the board to say "Claude Code moved this task", not show an email address. With static tokens we stored the client kind when the token was created. With OAuth we had nothing: our identity provider's access tokens describe the user and organization, with no client ID we could read. MCP already has the answer. Every client sends clientInfo: { name, version } with initialize, whatever the credential. The catch: our transport is stateless, so only the initialize request carries it. The tool calls that follow don't. We read the name off that first request, before auth resolves, and remember it against the credential for later calls. Two rules we kept. It's a display label and a lane color, never used for authorization, because a client can claim to be anything. And it has to be read before the first "agent connected" event is written, or that event, the one that matters most, gets the fallback name. What I'd tell someone starting an MCP server today Test with at least two real clients, not just the Inspector. The bugs live in how they read the spec. Route every method your transport handles, and answer 405, not 404, for any you don't. If your tools are request/response, return plain JSON. Check what tools/list actually emits. A schema that validates isn't necessarily a schema that's advertised. Treat your tool description as a budget. Put what the agent can't work out on its own first. StateLode is a shared task board for Claude Code, Cursor, Codex and other agents: https://statelode.dev. The stdio bridge is open source (Apache-2.0): https://github.com/Statelode/statelode-bridge. If you've hit other client quirks, I'd like to hear about them in the comments.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News