{"slug": "gotchas-and-anti-patterns", "title": "Gotchas and Anti-Patterns", "summary": "A book chapter on shipping Model Context Protocol (MCP) servers catalogs ten common production gotchas, starting with stray stdout output corrupting newline-delimited JSON-RPC framing on the stdio transport and oversized tool results flooding the model's context budget. The chapter prescribes logging to stderr via sys.stderr in Python or process.stderr in Node, auditing dependencies that print on import, and adding a test asserting stdout contains only valid JSON-RPC messages, one per line. It draws the gotchas from tool design, auth and security, transports, observability, testing and agent-to-agent coordination across the book's earlier chapters.", "body_md": "*Disclosure: this article was drafted with AI assistance and reviewed by the author.*\n\nChapter 10 gave you the machinery for servers that have to work together: service discovery, server cards, and agent-to-agent coordination across a fleet of focused capabilities. That machinery only pays off if each server behaves itself in production. This chapter is the other half of the book’s promise. You were told you would learn to avoid the gotchas. These are the ten that catch nearly every team the first time they ship an MCP server, drawn from across everything you have built so far: tool design from Chapter 4, auth and security from Chapter 6, transports from Chapter 7, observability from Chapter 8, testing from Chapter 9, and coordination from Chapter 10.\n\nEvery gotcha below follows the same shape. First the symptom: what you actually see when it bites you, in the form you will recognize at 2 a.m. Then why it happens: the root cause, usually a reasonable-looking decision with a hidden cost. Then the fix: the concrete change, stated plainly enough to apply today. Work through them with your own server in mind. At least one of them is already in your code.\n\n**Symptom:** Your server works when you test it by hand, but the moment a host connects over stdio, the session dies during initialization. The client’s log shows a framing error like unexpected token or invalid JSON-RPC message, and every retry fails the same way. Restarting changes nothing. The server process is alive and healthy; the conversation between the two ends is not.\n\n**Why it happens:** On the stdio transport, stdout is not a logging channel. It is the wire. MCP messages travel as newline-delimited JSON-RPC over the server’s standard output, so a single stray line, one print(“debug”), a library that logs its banner on import, a warning from a dependency, is enough to corrupt the message framing. The client reads your debug line where it expected a JSON-RPC envelope, fails to parse it, and the session is over before it began. Hand-testing hides the problem because your terminal happily shows both the stray output and the real messages side by side. The client cannot do that. It is parsing a stream, not reading a screen.\n\n**The fix:** Log to stderr, never stdout. Make this a rule with no exceptions, enforced in code review and in your starter template. In Python, configure your logging handlers to write to sys.stderr; in Node, write to process.stderr. Audit your dependencies too: some libraries print on import, and those prints are just as fatal. If a dependency will not stay quiet, wrap it, redirect its output, or replace it. Then add a test that runs your server’s initialization sequence and asserts that stdout contains only valid JSON-RPC messages, one per line. That test is cheap, and it catches the one debug print that slips in three months from now. The entire logging setup is three lines:\n\n``` python\nimport logging, syslogging.basicConfig(stream=sys.stderr, level=logging.INFO)# every handler points at stderr; stdout stays pristine\n```\n\n**Symptom:** Your tools work, but every call feels expensive. Responses come back slowly, the model’s answers get vaguer as the session continues, and eventually context fills up with material the model never asked for. Your http-status server’s lookup_http_status is the classic small-scale version: one call returns every category, every code, and a paragraph of history for each, when the caller wanted the meaning of 429.\n\n**Why it happens:** Tool results land directly in the model’s context, and context is a budget, not a hard drive. Returning everything is the easiest code to write, so it is the first code everyone writes: fetch the rows, serialize them all, hand them back. The cost is invisible in testing, where a single call looks fine, and brutal in production, where the model pays for every token on every turn, and the relevant fact drowns in fifty irrelevant ones. A tool that returns megabytes to answer a one-line question is not being thorough. It is being wasteful with the most expensive resource in the system.\n\n**The fix:** Return references and pages, not dumps. Give the caller a page of results and a cursor for the next page, the way any well-designed API does. Prefer summaries with drill-down over full detail: ten category names with a count each, then a tool that expands one category on demand. For the http-status server, that means lookup_http_status returns the code, the name, and one line of meaning, and a separate parameter asks for the extended history only when the caller opts in. As a rule of thumb, if a tool’s typical response is longer than a few hundred lines, it needs pagination, filtering, or a narrower shape. Measure response sizes in your tests, the way Chapter 9 measured latency, and fail the build when a tool’s default response crosses your budget.\n\n**Symptom:** A tool call fails, and the model does the worst possible thing: it guesses. It retries the same bad call with slightly different arguments, tries a different tool at random, or confidently reports success to the user while nothing happened. Your logs show the tool returned {“error”: “failed”} or a raw stack trace, and the model had no better information than you gave it.\n\n**Why it happens:** Error strings are written for the developer who wrote the code, not for the agent that has to recover from the failure. “failed” names nothing. A stack trace names everything except what matters: it tells the model about your file layout while hiding the one fact it needs, which is what to do differently. The model is a reasonable reader of error messages, but it can only reason from what you put in front of it. Vague errors produce vague retries, and vague retries produce incidents.\n\n**The fix:** Every error names three things: the value that was wrong, the rule it broke, and the remedy. Chapter 4 introduced this pattern, and production is where it earns its keep:\n\n```\n{\"error\": \"invalid input\"}{\"error\": \"code 'abc' is not a three-digit HTTP status (100-599); retry with a numeric code, e.g. 404.\"}\n```\n\nThe second one tells the model exactly what failed, what the constraint is, and what a correct retry looks like. Build a small error helper in your server so every tool raises errors in the same shape, and review error messages the way you review tool descriptions: they are part of the interface the model reads.\n\n**Symptom:** Everything works in your test environment, and then a security review, or worse, a curious user, notices that your tool descriptions contain live API keys, internal hostnames, or credential formats. The keys are visible in the client’s tool listing, which means they are visible to the model, which means they can end up in conversation history, logs, or a response to an unrelated question.\n\n**Why it happens:** Schemas and descriptions feel like documentation, and developers treat them that way, pasting example requests that happen to contain real tokens, or describing an argument as “the API key for the billing service, format sk-live-…”. But a tool schema is not documentation. It is data the host hands to the model on every session. Anything you write in a description or an example value is something you have disclosed to every model that ever lists your tools. Secrets do not belong in the contract; they belong behind it.\n\n**The fix:** Keep every credential server-side and reference it by name, never by value. Your server holds the keys in its own configuration or secret store; the tool schema refers to accounts, environments, or profiles by opaque identifier, and the server resolves those identifiers to credentials at call time. If an argument genuinely needs a user-supplied token, the host collects it through its own secure channel, not through a string you pasted into a description. Then grep your schemas and descriptions for anything that looks like a key, a token, or an internal hostname, and add that grep to your release checklist. Chapter 6 covers the full auth story; this is its cheapest rule.\n\n**Symptom: Your server runs on Streamable HTTP, reachable from the network, and anyone who finds the URL can call your tools. There is no incident yet, which is exactly the problem: the absence of an incident is not evidence of safety. Roughly 40% of public MCP servers expose tools with no authentication at all (Fudan University active scan of 7,973 servers, May 2026; OWASP reports a similar figure).**\n\n**Why it happens:** Local servers over stdio inherit the security of the machine they run on; the transport itself is the boundary, and Chapter 7 leaned on that fact. Remote servers inherit nothing. The moment your server answers over HTTP, the network is the boundary, and an open endpoint is an open proxy to everything your tools can touch. Teams skip auth because the demo works without it, the OAuth dance from Chapter 6 feels heavy for an internal tool, and “we will add it before launch” quietly becomes “it has been in production for six months.”\n\n**The fix:** Put OAuth with scopes in front of every remote server before it serves its first real request, exactly as Chapter 6 describes. Scopes matter as much as the login: a token that can call any tool is barely better than no token, because a compromised or over-eager client can reach the destructive tools too. Scope tokens to the tools each client actually needs, and keep the read-only tools on narrower scopes than the mutating ones. If the OAuth setup feels heavy for an internal prototype, treat that feeling as a signal, not a veto: the prototype is about to become production, and production without auth is the gotcha this section is named after.\n\n**Symptom:** One server exposes forty tools across six domains: files, databases, email, calendar, deployment, and billing. Tool choice gets unreliable; the model picks the email tool when it wanted the notification tool, or calls a destructive deployment tool when a read would have done. Reviews get harder because every change touches the same sprawling surface. And when the server needs different permissions for different tools, you discover you cannot scope it: it is all or nothing.\n\n**Why it happens:** Consolidation is tempting. One server means one deployment, one auth configuration, one thing to monitor. But a server is a trust boundary as well as a deployment unit, and Chapter 2’s rule stands: never give every server broad visibility. Each tool a server exposes is a capability every client of that server can see and, subject to the host’s policy, invoke. Packing unrelated capabilities into one server forces every client to be trusted with all of them, which means your least-trusted client sets the security posture of your most dangerous tool.\n\n**The fix:** Split by domain and by trust level, the way Chapter 10’s coordination story assumed: small, focused servers, one isolated client per server, each with the minimum tools and the minimum scopes it needs. The billing tools live on the billing server with the billing credentials; the file tools live on the file server with file credentials. Yes, this means more deployments. That is the trade, and it is worth it: a compromised or confused client can only reach the tools on the servers it was connected to. When you feel the pull to add “just one more tool” to a server that has nothing to do with it, that feeling is this gotcha knocking.\n\n**Symptom:** You marked your dangerous tool with the annotation that says it is not read-only, or you set a hint that the tool should require approval, and then you watched a host call it freely with no approval step. Or the reverse: you marked a tool read-only and assumed the model could never change state through it, until someone found the tool that “reads” a configuration by rewriting it.\n\n**Why it happens:** Annotations in MCP are advisory. They are hints the server offers about a tool’s behavior, readOnlyHint, destructive hints, and the like, and a well-behaved host uses them to make better decisions about approvals and presentation. But the protocol does not enforce them, and not every host honors them the same way. Treating an annotation as a security control is like treating a “please knock” sign as a lock. The server asserted a property; nothing in the wire guarantees the client acted on it.\n\n**The fix:** Enforce policy on your side of the boundary. If a tool must not run without human approval, the host owns that decision, per the architecture in Chapter 2: the host owns orchestration, policy, approval, and UX. Your server’s job is to make the host’s job easy: accurate annotations, clear descriptions of consequences, and, for the truly dangerous operations, its own confirmation step or a separate privileged tool with tighter scopes. And never trust a read-only annotation on a tool you did not audit. If the tool can change state, say so in the annotation and the description, and scope it accordingly. Defense in depth means the annotation, the host policy, and the server-side check all agree, and none of them is load-bearing alone.\n\n**Symptom:** Your server works over stdio, where the process lives as long as the session. You move it to Streamable HTTP, and suddenly multi-step workflows break: the second call in a sequence cannot see what the first call stored, progress vanishes between requests, and resuming a session after a network hiccup starts from zero.\n\n**Why it happens:** MCP servers are stateless by default. The stdio transport hid that from you because the process itself was the session: in-memory state survived from one message to the next, and it felt like the protocol was keeping state for you. Over Streamable HTTP, each request may reach a fresh process, a different replica, or the same process after a restart. Anything you kept in a global variable was never part of the contract. It was an accident of the transport, and the new transport does not repeat the accident.\n\n**The fix:** Design every tool call as if it is the first one the server has ever seen. Pass the state the call needs in its arguments or in a resource the client can read back; persist anything durable in a real store, not in process memory. Chapter 7’s rule is the bright line: when the work itself is long-lived, hand it to Tasks, the protocol’s mechanism for durable work, rather than simulating a session out of globals and hope. Then test it the honest way: run two sequential tool calls against a server process you restart in between. If the second call behaves differently than it did in one long-lived process, you were depending on the accident.\n\n**Symptom:** A tool kicks off work that takes minutes: an export, a scan, a batch job. The model, having no way to wait, calls a status tool in a tight loop: are we done yet, are we done yet, are we done yet. Each poll burns context and a request, the transcript fills with dozens of identical status checks, and if the session ends, nobody ever learns the result.\n\n**Why it happens:** The request-response shape of a tool call suggests a synchronous world, so developers build long jobs behind a synchronous-looking tool and leave the waiting to the caller. But the caller is a language model with a context budget, and every poll is a full round trip that says nothing new. Polling is the model doing with tokens what an event or a durable handle should do with almost none.\n\n**The fix:** Use Tasks for durable work, as Chapter 7 prescribes. The tool starts the job and returns a task handle immediately; the client checks the task’s status with backoff or subscribes to its completion instead of hammering a status endpoint. Design the status check to be cheap and the completion payload to carry the actual result, so the dozens of polls become a handful of quiet checks and one useful answer. As a rule, if your status tool gets called more than a few times per job in testing, the polling interval is wrong or the mechanism is: reach for Tasks before you reach for a longer sleep.\n\n**Symptom:** Something is slow, or wrong, or both, and you cannot say which call, which tool, or which session is responsible. Your logs show a flat stream of messages with no way to connect a tool call to the request that triggered it. The host team blames your server, you blame the host, and neither of you has evidence.\n\n**Why it happens:** Observability was deferred. The server logs to wherever was convenient, with no request identifiers, no timing, and no structured fields, because during development there was exactly one developer and one session. In production there are many of both, and a log line without a correlation ID is a fact without an address. Chapter 8 built the case for tracing the full path from host to client to server; this gotcha is what that path looks like when you skip it.\n\n**The fix:** Give every request a correlation ID at the edge and propagate it through every log line your server emits, structured, to stderr, with timings. Log the tool name, the session or task identifier, the duration, and the outcome on every call; log nothing that belongs in the schema, per the secrets rule above. Then make sure you can answer three questions from your logs alone: which tool is slowest, which tool fails most, and what a single session did from start to finish. If you cannot answer all three in under five minutes, your observability is not done. It is the difference between debugging with evidence and debugging with opinions.\n\nRun this list against your own server before it serves real traffic. Every item traces back to a gotcha in this chapter or a rule from an earlier one. If an item fails, fix it now; production is an expensive place to learn.\n\nFifteen checks, most of them automatable, all of them cheaper than the incident they prevent. Keep this list next to your deployment pipeline and run it on every release, not just the first one. Gotchas do not retire; they wait for the release where you stopped checking.\n\nYou now have the field guide to the failures this book promised to help you avoid:\n\nTake the server you have been building through this book, or the http-status server from the running example and run the pre-production checklist against it honestly.\n\nBring the results to Chapter 12, where we look at where the protocol is going and how you can help shape it. The servers that survive contact with production are not the clever ones. They are the checked ones.\n\n**Anti-pattern:** a recurring approach that looks reasonable and reliably causes harm.\n\n**Cascade:** a failure that spreads from one component to others through a shared dependency.\n\n**Chatty tool:** a tool whose responses are large enough to waste context or crowd out other work.\n\n**Correlation ID:** an identifier attached to a request as it crosses component boundaries, so logs from each side can be joined.\n\n*Next: Chapter 12 — The Road Ahead: where MCP goes from here. Follow to get it when it drops.*\n\n[Gotchas and Anti-Patterns](https://pub.towardsai.net/gotchas-and-anti-patterns-887c54cdc574) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/gotchas-and-anti-patterns", "canonical_source": "https://pub.towardsai.net/gotchas-and-anti-patterns-887c54cdc574?source=rss----98111c9905da---4", "published_at": "2026-10-22 12:01:02+00:00", "updated_at": "2026-10-22 12:12:43.077143+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools"], "entities": ["Model Context Protocol", "MCP", "JSON-RPC", "Python", "Node.js"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/gotchas-and-anti-patterns", "markdown": "https://wpnews.pro/news/gotchas-and-anti-patterns.md", "text": "https://wpnews.pro/news/gotchas-and-anti-patterns.txt", "jsonld": "https://wpnews.pro/news/gotchas-and-anti-patterns.jsonld"}}