Best practices
Fifteen decisions, none of them about the protocol
A demo server and a production one differ by about fifteen decisions, and almost none are protocol questions. They are API design, error handling, versioning, observability and distribution — the things any service needs, arriving in an unfamiliar costume.
The pre-flight checklist
Pilots run a checklist before every flight, including pilots who have flown the route a thousand times. It is not a competence test. It exists because the failures it catches are the ones expertise does not prevent — the overlooked, the assumed, the done-yesterday-so-surely-done-today.
Both audiences accept this for the same reason, which is why it is the right frame for a lesson that is a list. What follows is that checklist.
Tool design: fewer, wider, honestly named
Fewer tools than you think. Every tool competes for the model's attention, and Lesson 10's forty-tool ceiling is a real budget. Ten well-chosen tools outperform thirty granular ones, because the model's job is selection and a long list makes selection harder.
Group by system, not by verb. book_room and cancel_booking, not create and delete. GitHub's toolsets in Lesson 11 do this and it is why they are legible.
One operation per tool. A tool with a mode parameter that changes what it does is two tools. The model chooses better between two clear names than between two values of one parameter.
Names are namespaced in practice. Yours land in a list beside every other server's. find_slots is fine; search is not.
Descriptions say when, not just what — the Lesson 15 rule, and still the highest-leverage thing you can edit.
Return structured output. The annotation is the schema; there is no reason not to.
Errors, one more time
The Lesson 17 split matters most in production, so it is worth restating as a rule you can apply without thinking:
- Domain failure — raise an ordinary exception. Comes back with
is_error=True, model reads it and adapts. "Room is booked", "no such member", "outside opening hours". - Protocol failure — raise
MCPError. Comes back as a JSON-RPC error. Malformed arguments, missing authorization, a caller doing something that cannot be retried into working.
And write error messages for the model, because that is who reads them. "Studio B is already booked at 09:00 on 2026-09-17" tells it what to try next. "Constraint violation on bookings_room_day_hour_key" does not.
Versioning without breaking callers
Your tool signature is a public API consumed by clients you cannot see. Change it additively.
- Safe: a new optional parameter with a default; a new field in a returned object; a better description; a new tool.
- Breaking: renaming a parameter, removing one, making an optional one required, changing a type, removing a returned field.
When you genuinely must break something, add the new tool alongside the old, mark the old one deprecated in its description so the model learns to prefer the new one, and remove it later. The protocol has no version negotiation for individual tools — this is the mechanism.
Observability, now that logging left the protocol
Protocol logging is deprecated, so telemetry is ordinary application telemetry:
import logging
from opentelemetry import trace
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
@mcp.tool()
def book_room(day: str, hour: int, ctx: Context[AtriumContext] = None) -> Booking:
"""Book a room."""
with tracer.start_as_current_span("book_room") as span:
span.set_attribute("mcp.request_id", ctx.request_id)
span.set_attribute("atrium.day", day)
logger.info("booking %s at %02d:00 (request %s)", day, hour, ctx.request_id)
...ctx.request_id on every span and log line is what lets you follow one call end to end. Four things are worth measuring: call rate per tool (which tools are actually used — usually a surprise), error rate per tool (a tool erroring 40% of the time has a description problem, not a bug), latency per tool (the model waits on you), and argument validation failures (the model consistently getting a parameter wrong is a schema that needs a better description).
And never log tool arguments wholesale. They contain whatever the user said.
Distribution
How somebody else installs this decides whether they do.
- A README with a copy-pasteable config block, for the clients you expect. Lesson 10's four dialects.
- A published package, so the command is
uvx atrium-mcprather than a git clone. - Sensible defaults, so it runs with no configuration and gets better with some.
- The registry, if it is public. Lesson 12's five-minute read is what a stranger will do to you.
- Say what it needs — credentials, permissions, network access — in the README, before they find out by failing.
The low-level API, and when you need it
MCPServer covers essentially everything. Underneath sits a lower-level Server class where handlers are passed to the constructor and you build result objects yourself:
async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(type="text", text="…")], is_error=False)
server = Server("atrium", on_call_tool=handle)Reach for it when your tool list is genuinely dynamic — generated per caller from a database, say — or when you are implementing a protocol feature the high-level API does not expose. Otherwise the decorators are not a simplification you are outgrowing; they are the intended interface.
What to take into the next lesson
Fewer and wider tools, grouped by system and named for a crowded list; the domain-versus-protocol error split, with messages written for the model; additive changes only, with deprecation through description; telemetry keyed on request_id, measuring call rate, errors, latency and validation failures; and distribution treated as part of the product. Next: the questions that are still bothering you.