A working server is about fifteen lines. The distance between that and one you would put in front of a customer is the rest of this post — and almost none of it is protocol.
Every team that wires a model into its own systems writes the same integration twice: once for the agent in the IDE, once for the one in the product, and again the next time either changes. MCP exists to make that a protocol problem instead of a copy-paste problem.
An MCP server is a small process that exposes your systems over a standard interface, so any MCP-speaking client can use them without knowing anything about you. Write the integration once; every agent you own gets it. See the MCP overview for how it fits the wider stack.
What a server can expose
Three primitives, and the distinction matters because clients treat them differently. Tools are model-invoked, resources are application-controlled, prompts are user-invoked. Most servers are all tools, and most servers that feel wrong are all tools when one of them should have been a resource.
The smallest server that works
The Python SDK ships a decorator API that handles the protocol for you. This is a complete, runnable server.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Look up a single order by its id.
Use for order status, line items and shipping state.
Do NOT use for refunds - call create_refund instead.
"""
return db.fetch_order(order_id)
if __name__ == "__main__":
mcp.run() # stdio transport by default
That is the whole protocol surface. The type hints become the input schema, and — this is the part worth slowing down on — the docstring becomes the tool description the model reads when deciding whether to call it.
Point a client at it with a few lines of config:
{
"mcpServers": {
"orders": { "command": "python", "args": ["-m", "orders_server"] }
}
}
Nobody gets stuck on the protocol. They get stuck on which operations deserve to be tools, what the descriptions say, and what happens when a call fails. That is the real work, and none of it is in the SDK docs.
The descriptions are the product
The model reads one tool description, out of context, with nothing else to go on, and decides. Treat it as a prompt rather than documentation — see tool surface design for the longer argument.
| Weak | Strong | Why it matters |
|---|---|---|
| “Gets order data.” | “Look up one order by id. Use for status, items, shipping.” | Says when, not just what |
| No negative case | “Do NOT use for refunds — call create_refund.” | The single highest-value line in most descriptions |
data: dict | order_id: str, one purpose per parameter | A loose dict makes the model invent keys |
| 18 tools on one server | Split by domain, or route before attaching | Selection degrades past roughly fifteen |
From works to deployable
1 · Return failures as data, not exceptions
A raised exception tells the model nothing it can act on. Return a result it can reason about — what failed, and whether retrying is sensible.
@mcp.tool()
def create_refund(order_id: str, amount_cents: int, idempotency_key: str) -> dict:
"""Issue a refund against an order. Requires an idempotency key."""
if amount_cents > MAX_AUTO_REFUND:
return {"ok": False, "reason": "over_limit",
"detail": f"Refunds above {MAX_AUTO_REFUND} need human approval.",
"retryable": False}
return {"ok": True, "refund": payments.refund(order_id, amount_cents,
key=idempotency_key)}
The idempotency key is not decoration. A timeout does not tell you whether the write landed, so any write tool that can be retried needs one — see MCP in production.
2 · Put auth on the server, not in the prompt
The server is the security boundary, which is most of the reason to build one instead of calling the API directly. Propagate the user’s identity and check permissions there. A broad service account creates a confused deputy: someone who cannot read a table asks a question, the server can read it, and the answer contains it. MCP security covers the failure modes.
3 · Scope each server to a domain
One server per bounded area — orders, analytics, docs — rather than one company-wide server. Clients attach only what a task needs, which keeps tool counts in the range where selection still works, and it lets you give each server its own identity and rate limits.
4 · Version like the consumer is a model
Because it is. Adding a tool is safe. Changing a description is not, even though nothing about the schema moved — the model was choosing based on that text. Treat description changes as behavioural changes and re-run your evals.
5 · Remember descriptions are attacker-controlled if the server is
A third-party server controls text that lands directly in your model’s context. That is prompt injection with a supply chain attached. Pin what you install, review descriptions the way you review dependencies, and keep enforcement outside the model.
Testing it
Two layers, and most teams only build the first. Call the functions directly in unit tests — they are ordinary Python, that is the point of the decorator API. Then test the layer that actually breaks: whether a model, given these descriptions and a realistic request, picks the right tool.
# The test that catches what unit tests cannot: did it choose correctly?
CASES = [
("where is order A-91?", "get_order"),
("refund the shipping on A-91", "create_refund"),
("how many orders shipped today?", "run_report"),
]
def test_tool_selection(agent):
for request, expected in CASES:
assert agent.first_tool_call(request) == expected
Run it on every description change. It is the only thing that catches a reworded docstring quietly sending every refund request to get_order.
The protocol is the easy part and will stay the easy part. What determines whether your server is any good is which operations you exposed, what the descriptions say, and whether a failed call leaves the agent with something it can act on. Get those right and switching model or client costs you nothing — which was the entire reason to build a server instead of a function.
From here: building MCP servers for the fuller walkthrough, transports for when stdio stops being enough, and skills vs subagents vs MCP if you are still deciding whether this capability belongs in a server at all.