A Python MCP server is forty lines of FastMCP until you point one at a 900-table ERP. Four things broke every server we shipped for clients: a dependency printing to stdout, a blocking driver stalling the event loop, `str` type hints the model has to guess at, and a tool that works perfectly and returns 40,000 tokens.
A client's server died forty minutes into every session with `Unexpected token 'W' in JSON at position 0`. The `W` was `WARNING: this method is deprecated`, printed to stdout by a vendor SDK that only got imported inside one rarely-called tool. Nothing in the MCP spec, the FastMCP docs, or the client's traceback pointed anywhere near it. That is the shape of nearly every real MCP bug: the protocol is JSON-RPC over a pipe, and the protocol is never what's broken.
We've built Python MCP servers against a logistics ERP with 900 tables, a billing database where invoice status is free text with fourteen spellings of "paid", and a pricing API whose OpenAPI spec marks fields required that the service ignores. Four failure modes showed up in all three, and none appear in a hello-world: stdout corruption, a blocked event loop, schemas that leave the model guessing, and responses that eat the context window.
Under stdio, your stdout is the wire — every line on it is parsed as a framed JSON-RPC message. One `print("got here")` emits a frame the client can't parse, the reader task raises, and the session drops with an error naming the protocol layer, never your print. Dependencies do it on import too — version banners, progress bars aimed at the wrong stream — and those at least fail loudly, before `initialize` returns. The lazy import buried inside one tool is the one that costs you a day.
The fix is boring and absolute. Point logging at stderr before the first third-party import, never write to stdout in server code, and if a dependency insists, stash the real handle for the transport and reassign `sys.stdout` to `sys.stderr` on line one of your entry point. Then make stderr worth reading: one structured line per call with tool name, redacted arguments, duration, and rows returned. When a client reports "the agent couldn't find the invoice," that line separates never called from called with `2026-13-01` from returned zero rows.
A session is one event loop serving every request on it. Declare `async def get_orders()` and call `psycopg2` or `requests` inside, and you've parked that loop on a blocking socket read. Nothing else runs: not the three other tools the model invoked in the same turn, not progress notifications, not the cancellation the user just clicked. A demo never shows it — only one call is ever in flight. Fan out four 800 ms lookups and they serialize into 3.2 seconds.
Pick a lane per tool. Synchronous work belongs in a plain `def`, which FastMCP runs on a worker thread instead of the loop; async work gets an async driver — asyncpg, httpx — and stubborn blocking libraries get wrapped in `asyncio.to_thread`. Build pools once in the lifespan context and hand them to tools through the request context; we watched a server hit Postgres's default `max_connections` of 100 in under a minute because every invocation built its own, and what the client reported was "Claude stopped working." Give every outbound call an explicit timeout. A vendor API that hangs for 300 seconds is an agent that looks frozen, and the user blames the model.
FastMCP derives each tool's JSON Schema from your signature and its description from your docstring, so annotations aren't documentation — they're prompt. `status: str` tells the model nothing, so it invents `"unpaid"` and gets an empty set back; `status: Literal["open", "paid", "void"]` makes that structurally impossible. Dates are worse: typed `str`, one session gave us "last Tuesday", "2026-08-03", "08/03/26", and "Q3" from the same model. Type it `datetime.date` with a `Field` pinning ISO-8601 and Pydantic rejects the rest before your code runs, handing back an error the model can fix.
Keep the surface small and task-shaped. We ship six to twelve tools named for the job — `find_customer`, `list_open_invoices`, `issue_credit_note` — not for the endpoints underneath. A generic `query(sql: str)` feels flexible and is a liability: it inherits every grant your service account holds, it can't be regression-tested because the input space is infinite, and against 900 tables the model writes joins from a schema it has seen one page of. Say what each tool returns and what it does not — that sentence is the entire basis on which a model chooses between `list_open_invoices` and `list_invoices`.
The most common production failure we see isn't an error. It's a tool that works perfectly and returns 40,000 tokens of JSON, leaving the agent one turn of headroom for the reasoning that mattered. Serialized ORM rows are the usual cause: `created_at`, `updated_at`, `tenant_id`, and eleven nullable columns nobody asked for, times 500 rows — most of a context window spent on a question four fields would have answered. Cap the page, project what the task needs, and return items plus a cursor and a total count.
Errors need the same discipline pointed the other way. A traceback returned as tool output gives the model nothing to act on, so it retries the identical call; we've watched one repeat five times before the agent gave up. Catch it, set the result's error flag, and return a sentence: which argument was wrong, what was expected, what to try instead. "No customer matched 'ACME Corp'; try find_customer with a partial name or a tax ID" recovers in one turn. `KeyError: 'id'` never does.
You don't need an LLM to test an MCP server, and shouldn't use one for the bulk of it — it's slow, nondeterministic, and buries regressions under a model that guessed well that afternoon. The Python SDK connects a client session to your server object in memory, so pytest lists tools, calls them with fixed arguments, and asserts on results in milliseconds. Snapshot every tool's generated JSON Schema and diff it in CI: renaming `customer_id` to `customerId` raises no Python error anywhere and breaks every agent already calling you.
Ship it the way it will run. Package with a console entry point and a lockfile so it starts under `uvx` with no ambient virtualenv. Take secrets from environment variables, not command-line arguments — argv is readable by anything that can run `ps`. Over HTTP instead of stdio, treat it as a public service: real auth, per-session rate limits, and Origin validation, because a local server without it is one DNS-rebinding trick from any browser page calling your tools. An MCP server is an API with a model at the keyboard.
A software studio that ships and maintains its own products — KeepChats, Gwora and MoveProof — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar