The shape of the thing
Strip the diagrams and a Model Context Protocol server is a program that speaks JSON-RPC 2.0 down a pipe. Two transports matter: stdio, where the client starts your program as a subprocess and talks over standard input and output, and streamable HTTP, for a server running somewhere else.
Three primitives are worth knowing:
- Tools: model-invoked functions. This is what you will write.
- Resources: data the application pulls in as context, addressed by URI.
- Prompts: templates a user explicitly chooses.
Most servers are three tools and nothing else. Start there.
A minimal server
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orders")
@mcp.tool()
def get_order(order_id: str) -> str:
"""Look up one order by id.
Returns status, amount paid and refund eligibility.
order_id looks like ORD-48213. If you only have an email
address, call find_orders_by_email first.
"""
row = db.fetch_one(order_id)
if row is None:
return f"No order found with id {order_id!r}. Ids look like ORD-48213."
return f"status={row.status} amount={row.amount} refunded={row.refunded}"
if __name__ == "__main__":
mcp.run()Look at what the framework does with that. The type hints become the input schema. The docstring becomes the description the model reads. Everything from the tool-design lessons applies here and is now enforced by the language: a vague docstring is a vague interface, and a str where you meant an enum is a free-text field the model will fill with whatever it likes. Use Literal["pending", "shipped"] and get an enum in the schema for free.
Wiring it up
A client is configured with a command, its arguments, and an environment:
{
"mcpServers": {
"orders": {
"command": "python",
"args": ["/opt/agents/orders_server.py"],
"env": { "DATABASE_URL": "postgres://..." }
}
}
}The trap is right there in env. A stdio server receives the environment the client gives it, not the one in your interactive shell. A variable exported in your shell profile, which works perfectly when you run the script by hand, is absent when the client launches it. The symptom is a server that starts and then fails on its first call, and the cause is invisible from either side.
The single most common bug
Never print to standard output. On a stdio server, stdout is the protocol channel. One stray print() (a debug line, a library’s progress bar, a warning) corrupts the JSON-RPC stream and the client disconnects with a parse error that names none of this.
Log to stderr. Every time. Configure any library that might print to do the same.
Debug it before an agent touches it
The reference MCP Inspector gives you a local interface that lists your tools and lets you call them by hand with arguments you choose. Use it first, always. Debugging a tool through an agent means debugging two non-deterministic systems at once, and you will blame the wrong one.
When to write one, and when not to
If one codebase uses the tool, a plain function is simpler and you should write a plain function. Write a server when several different clients need the same tools, or when you want them available inside an editor or desktop application you did not build. That is the whole value: it is the N-times-M problem again, and the server is the once-per-N side.
One security note carried forward
A remote server can change its tool descriptions after you have approved them, and tool descriptions are prompt. Pin versions where you can, read what you connect to, and treat a third-party server the way you would treat a dependency that runs with your credentials, because that is what it is.
Every lesson here is open. Your progress is recorded once you pass the Context Engineering exam.
Check your understanding
A developer writes a stdio MCP server that works when run by hand but disconnects with a JSON parse error as soon as the client calls a tool. The server logs progress with print(). What is happening?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Mai, your AI tutor
