Your server works perfectly when you run it by hand. You point the assistant at it and… nothing. No tools, no error, no clue. Almost every MCP problem looks like this at first, and almost every one is quickly identifiable if you know which phase of the connection broke. This lesson is that triage — three phases, four classic failures, and the one logging mistake that produces the most confusing symptom in the whole ecosystem.
The three phases — your triage map
Every MCP connection goes initialization → operation → shutdown, and each phase produces its own class of bug:
| Phase | What happens | Failures look like |
|---|---|---|
| Initialization | version + capability negotiation | "unsupported protocol version", empty tool list |
| Operation | requests, responses, notifications | tool errors, timeouts, malformed results |
| Shutdown | clean termination | zombie subprocesses, unreleased connections |
Name the phase before you touch anything — it eliminates most of the search space in one step, and it's exactly how an interviewer expects you to reason.
Failure 1 — "protocol version not supported"
An initialization failure: client and server implement versions that don't overlap. Fix by upgrading one side or pinning both to a version they share. It's common in a young standard, and it's also the friendliest failure — at least it tells you what's wrong.
Failure 2 — connection succeeds, zero tools
The most common setup problem, and it's a capability issue, not a broken tool. Either the server never declared the tools capability during the handshake, or registration silently failed (a decorator not applied, a module not imported, an exception swallowed at startup).
The diagnostic that resolves it in thirty seconds: query the server directly with an inspector. If it lists tools, your client config or handshake is at fault. If it doesn't, registration is.
Failure 3 — a tool call returns a stack trace
An operation-phase bug with a hygiene problem attached. The handler raised instead of returning a structured error, so internals — file paths, query fragments, sometimes secrets — have just been written into the model's context, where they may be logged or displayed.
# bad
def get_order(order_id: str) -> dict:
return db.find_order(order_id).to_dict() # AttributeError on None
# good
def get_order(order_id: str) -> dict:
if not order_id.isdigit():
return {"error": f"order_id must be numeric; got {order_id!r}"}
order = db.find_order(order_id)
if order is None:
return {"error": f"no order found with id {order_id}"}
return order.to_dict()Wrap handlers so failures become readable, actionable results. The model then self-corrects on the next turn instead of the session dying.
Failure 4 — the tools are there, and the model ignores them
Everything connects, the tool list is populated, invocations work when you trigger them manually — and the assistant keeps answering from its own knowledge.
This is not a protocol problem at all. The plumbing is demonstrably fine, so the failure is behaviour: the tool descriptions don't make it obvious when to use them, or the system prompt never requires tool use for that question type. Fix the descriptions (when to use it, and what it is not for) and add an explicit rule to the prompt.
This case matters because it's where engineers waste the most time — debugging transports and schemas when the answer was two sentences of English.
The stdout trap
On the stdio transport, stdout is the wire. A single print() anywhere in your server — a debug line, a library's startup banner — injects text into the JSON-RPC stream and produces parse errors that look like protocol corruption.
import logging, sys
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # stderr!
log = logging.getLogger("orders-server")
@mcp.tool()
def get_order(order_id: str) -> dict:
log.info("get_order called id=%s", order_id) # safe
# print(...) <- would corrupt the protocol streamResult
INFO orders-server get_order called id=8123
Logs to stderr or a file; structured entries covering requests, arguments, latency and errors — and no secrets, since logs get shared while debugging.
Isolate with an inspector
The single most useful habit: use an MCP inspector — a client that connects to your server directly, lists tools, resources and prompts, and lets you invoke them by hand.
It separates two failure sources that otherwise blur together: your server and the model's judgment. If the inspector shows a tool working correctly, stop debugging the server — the problem is the client config, the prompt, or the description. Judging correctness only through an assistant's replies makes every bug ambiguous.
Selection-round radar: "How would you debug an MCP server?" Answer with the triage: name the phase (initialization / operation / behaviour), verify with an inspector to isolate server from model, check logs on stderr, and confirm the capability handshake. Mentioning the stdout trap unprompted is a reliable signal you've actually written one.
Common mistakes
- Printing to stdout in a stdio server.
- Debugging the transport when the real problem is a weak tool description.
- Raising exceptions from handlers instead of returning structured errors.
- Leaking stack traces (and sometimes secrets) into model context.
- Testing only through an AI client, so server bugs and model judgment blur.
- Forgetting environment differences when the host launches the subprocess — different cwd, PATH or variables.
Quick recap
| Symptom | Phase / cause |
|---|---|
| "Unsupported protocol version" | initialization — version mismatch |
| Zero tools listed | capability not declared, or registration failed |
| Stack trace from a call | operation — unhandled exception; return structured errors |
| Tools ignored by the model | behaviour — descriptions and system prompt, not the protocol |
| Garbled protocol stream | something printed to stdout on stdio |
| Works manually, fails in the host | subprocess environment: cwd, PATH, missing variables |
Practice Zone — PYQs from real selection rounds
Six MCQs and a triage task — diagnose four failures, one of which isn't an MCP problem at all.
The three phases of an MCP connection are:
Asked in

A client connects but sees zero tools. What's the most likely cause?
Asked in

The tools appear, but the model never uses them. Where do you look?
Asked in

An MCP inspector tool is useful because:
Asked in

A stdio server 'works when run manually' but fails inside the host. Most likely cause?
Asked in

How should an MCP server log?
Asked in

Hands-on task:
Diagnose each: 1) client shows 'protocol version not supported'; 2) tools list is empty; 3) a tool call returns a stack trace; 4) the assistant answers from memory despite a working search tool.
Asked in

FAQ
How do I see the raw messages?
Inspector tools show the message exchange, and most SDKs offer verbose logging (to stderr). For remote HTTP servers you can also observe requests at the network layer, which is often the fastest way to confirm whether a call ever left the client.
The server hangs on start inside the host but not manually. Why?
Usually environment: the host launches the subprocess with a different working directory, PATH or variables, so an interpreter, dependency or credential isn't found. Log the resolved paths and environment at startup — to stderr — and the cause is usually obvious in one run.
Should I log tool arguments?
Yes for debuggability, with PII masked and secrets never logged — arguments are exactly what you need when a tool behaves oddly, and exactly what you'll regret storing raw if they contain personal data.
Final lesson: how teams actually use this — Lesson 9: MCP in Practice →


