Server craft
Anyone can register a tool. This lesson is the difference between a server that demos well and one you would let colleagues point real hosts at — and it is mostly a short list of disciplines, each purchased by someone's bad afternoon.
The stdio contract, tested rather than folklore
Lesson 3 stated the rule: on stdio, stdout is the wire. The folklore version says "one print() anywhere kills your server." Folklore is out of date, and the truth is more instructive. I put a print() inside a Paper Trail tool and called it through FastMCP 3.4.5:
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two integers."""
print(f"adding {a} + {b}") # the classic mistake
return a + bcall succeeded: 5The call succeeded — modern FastMCP shields tool-body stdout precisely because this mistake was so common. So why does the rule still matter? Because the shield covers only what the framework can see. Code that writes to stdout at import time (a banner, a version notice), a background thread, or a dependency's progress bar all land on the wire before or around the shield — and a server built on the raw SDK has no shield at all. The symptom is always the same and always cryptic: the client reports a JSON parse error, for a byte the server wrote. You now know the diagnosis on sight.
The discipline: diagnostics go to stderr (hosts capture it into their logs), or better, through the protocol itself — ctx.log sends structured log messages the host can display, and long tools should report progress via the context so the host can show a bar instead of a spinner:
@mcp.tool
async def reindex_catalog(ctx: Context) -> str:
"""Rebuild the search index over the full catalog."""
for i, batch in enumerate(BATCHES):
await ctx.report_progress(i, len(BATCHES))
...Validate like the arguments came from a model, because they did
Your schema enforces types, not sense. order_id: str admits "PT-1041", "1041", and "the one from Tuesday" equally — and a model under a vague user instruction will eventually send all three. Validate semantically at the top of every tool and reject with a message that teaches:
if not re.fullmatch(r"PT-\d{4}", order_id):
raise ValueError(f"order ids look like PT-1041; got {order_id!r}")That message goes back as isError text the model reads — so the next attempt in the same conversation is usually correct. An assert that just says "invalid input" wastes the round trip. Constrain at the signature where possible (enums via Literal, bounded ints) so bad calls die before your code runs; explain in the error for everything the schema cannot express.
Statelessness is the default posture
Lesson 4 ended on the observation that a session can end between any two frames. The design consequence: a tool should complete its effect or not have started it. Keep no session-scoped state that matters (the next session starts blank; assume nothing survives), make writes idempotent where the domain allows — draft_refund twice is two identical drafts, harmless — and when an operation is genuinely multi-step, persist progress in your system, keyed by something the model can quote back, not in server memory.
The shape of a good server
Small surface: five sharp tools beat fifteen vague ones — every description you add is context the host must spend and the model must weigh. One domain per server: paper-trail fronts the bookstore; it does not also wrap the weather, because hosts compose servers and monoliths compose with nothing. Boring dependencies, pinned versions: lesson 15 will containerise this; the fewer moving parts, the smaller the image and the audit. And a README that shows the config block to paste — lesson 12 explains why that is the difference between adopted and abandoned.
Try this: add the validation regex and a ctx.log line to your own copy of order_status, then send it "1041" and read what comes back on the wire. Watching your own error text arrive as isError content closes the loop this lesson opened.