Four lessons of concepts. Now write one — it takes about fifteen lines. The SDKs are deliberately unglamorous: annotate an ordinary function and the schema is generated for you. Which means the interesting work isn't the protocol at all — it's deciding which capabilities to expose, describing them so a model chooses correctly, validating what arrives, and keeping outputs small. That's what this lesson is really about.
The smallest useful server
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Get the status and items of a customer order by its numeric id.
Use for questions about a specific order's status or contents.
Does NOT search policies or create returns."""
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 {"id": order.id, "status": order.status,
"items": order.items[:10], "eta": order.eta}
@mcp.resource("policy://shipping")
def shipping_policy() -> str:
"""Current shipping and delivery policy text."""
return load_policy("shipping")
if __name__ == "__main__":
mcp.run()Result
tools: get_order — Get the status and items of a customer order… resources: policy://shipping — Current shipping and delivery policy text.
One model-controlled tool, one application-controlled resource — discoverable by any MCP client.
Four production habits are already in there. Let's take them one at a time, because each maps to a question interviewers ask.
Docstrings are prompts now
The SDK turns your function's docstring into the description the model reads when deciding whether to call it, and your type hints into the parameter schema. Convenient — and a trap, because a lazy docstring is now a lazy prompt.
Notice the two upgrades in the example: it says when to use it in the language a user would use, and it says what it is not for. That negative clause is the single most effective fix for a model that keeps picking the wrong tool among similar options.
Validate everything — it's an API
Treat every incoming call as untrusted, exactly like a request from the internet, because effectively that is what it is: a model chose those arguments, possibly influenced by content it read.
Schemas catch shape errors — a string where a number belongs. They do not catch business errors: ownership (does this order belong to the requesting user?), limits (is this amount within policy?), permissions (is this user allowed to see this project?). Schema validation is necessary and never sufficient.
Wait — what should a tool do when something goes wrong?
Not raise, and not return nothing. Return a clear, actionable error the model can read:
# good — the model can self-correct next turn
{"error": "order_id must be numeric; got 'ORD-8123'"}
# bad — teaches the model nothing, it will retry identically
{}
# worse — leaks internals into the model's context (and your logs)
Traceback (most recent call last): File "server.py", line 42 ...Models recover from clear errors surprisingly well — the next turn usually carries corrected arguments. An empty result produces an identical retry, an exception can kill the session, and a stack trace puts file paths and internal details into a context that may be logged or displayed.
Output size matters more than you'd think
Whatever your tool returns enters the model's limited context — and in an agent loop, it's re-sent on every subsequent step. A tool that dumps 50,000 tokens of logs doesn't just cost money once; it crowds out everything else for the rest of the run.
Hence order.items[:10] in the example. The general rule: return focused results with a way to fetch more — summaries, top matches, pagination — the same discipline as choosing top-k in retrieval.
Curating the tool surface — the real design work
You have an internal API with forty endpoints. How many tools should the server expose? The instinctive answer, forty, is wrong for two reasons: every tool schema is sent to the model on every request, and forty near-identical descriptions give the model forty plausible options for any step.
Design tools around tasks, not endpoints: get_leave_summary(employee_id) that merges four endpoints beats four separate tools that each look right. Fewer, task-shaped, clearly non-overlapping tools fix wrong-tool-choice problems far more reliably than better prompting.
The opposite failure is worth naming too: one generic call_api(endpoint, params) passthrough. It looks elegant and shifts all the difficulty onto the model while destroying validation — everything becomes a free-form string.
Testing a server
Two layers, and keeping them separate is what makes debugging tractable. Unit-test the handlers directly — they're ordinary functions; test valid inputs, invalid inputs, permission denials and the error shapes. Then use an MCP inspector/client to verify the protocol layer: does the handshake succeed, are tools listed, does an invocation round-trip correctly?
What you should not do is judge correctness only by chatting with an AI client — that confuses two failure sources (your server and the model's judgment) and makes every bug ambiguous.
Selection-round radar: "Have you built an MCP server?" is best answered with specifics: decorate a function, docstring becomes the description, validate in the handler, return readable errors, cap output size, and curate a small task-shaped tool surface. Those five details are exactly what someone who has built one says, and someone who has read about one doesn't.
Common mistakes
- One-line docstrings — the model's only guidance for choosing the tool.
- Relying on schemas for ownership, limits and permissions.
- Raising exceptions or returning empty results instead of readable errors.
- Returning huge payloads that flood the context window.
- Mirroring every API endpoint as a tool — or collapsing everything into one generic passthrough.
- Printing logs to stdout in a stdio server.
Quick recap
| Practice | Why |
|---|---|
| Rich docstrings, with a "does NOT" clause | they are the prompt the model uses to choose |
| Validate in the handler | ownership, limits and permissions aren't schema-checkable |
| Readable structured errors | the model self-corrects on the next turn |
| Cap output size | results occupy context and are re-sent every step |
| Task-shaped tools, few of them | schemas cost tokens; similar tools cause wrong picks |
| Test handlers + inspector separately | isolates server bugs from model judgment |
Practice Zone — PYQs from real selection rounds
Six MCQs and two build tasks — write a small server, then redesign a thirty-tool surface that keeps confusing the model.
In most MCP SDKs, what turns a normal function into a tool?
Asked in

Where should input validation happen in an MCP server?
Asked in

How should an MCP tool report a failure?
Asked in

How large should a tool's returned output be?
Asked in

You're wrapping an internal API with 40 endpoints. How many tools should the server expose?
Asked in

How do you test an MCP server?
Asked in

Hands-on tasks:
Write an MCP server (Python SDK style) exposing one tool that looks up an order and one resource listing shipping policies. Include validation and a readable error path.
Asked in

A team wrapped their HR API by exposing 30 tools mirroring every endpoint (get_employee, list_employees, get_leave_balance, list_leave_types, get_leave_by_id...). The assistant keeps picking wrong tools. Redesign the surface.
Asked in

FAQ
Which language should I write a server in?
Whichever your system already lives in — official SDKs exist for several languages, and the server usually wraps existing internal code. Rewriting logic in a different language just to use a particular SDK is almost never worth it.
Should the server hold its own credentials?
For a local stdio server, it typically uses the user's own environment. For a remote multi-user server, it must authenticate the caller and act with that user's permissions — a single shared super-user token is the classic security mistake (lesson 4).
How do I version a server's tools?
Treat tool names and schemas like a public API: adding is safe, renaming and changing semantics are breaking changes for every connected client. Version the server, communicate changes, and remember that clients discover capabilities at runtime — so a change lands immediately (lesson 6).
Next lesson: how this differs from plain function calling — Lesson 6: MCP vs Function Calling →


