A Minimal Server
The smallest useful MCP server exposes one tool over stdio. Using the official Python SDK's low-level API:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
server = Server("orders")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_order",
description="Fetch one order by id",
inputSchema={
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "Order id, e.g. ORD-42"},
},
"required": ["order_id"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "get_order":
return [TextContent(type="text", text=f"unknown_tool:{name}")]
order = await orders_db.fetch(arguments["order_id"])
return [TextContent(type="text", text=order.to_json())]
async def main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
Input: the client sends tools/call with order_id: "ORD-42". Output: the order as JSON inside a TextContent block. list_tools advertises the schema so the model knows what to send; call_tool dispatches; stdio_server wires stdin and stdout to JSON-RPC.
Resources follow the same pattern — advertise on list_resources, serve on read_resource:
@server.list_resources()
async def list_resources() -> list[Resource]:
return [Resource(uri="config://orders", name="Order config", mimeType="application/json")]
@server.read_resource()
async def read_resource(uri: AnyUrl) -> str:
if uri != "config://orders":
raise FileNotFoundError(f"unknown resource: {uri}")
return json.dumps({"max_page_size": 100})
Start with one or two read-only tools. Add write tools only after the safety review passes — the rollout order is a safety decision, not a technical one.
The server.run call is the event loop: it reads JSON-RPC messages from the client, dispatches them to your handlers, and writes responses back. create_initialization_options tells the server which capabilities to advertise during the handshake — tools, resources, or both. Everything the client can do is determined by what you register here.
Precise Input Schemas
A vague parameter pushes the agent into guesswork. The model invents values, the call fails, and the loop retries — burning tokens on a problem the schema could have prevented.
Vague:
{"type": "object", "properties": {"query": {"type": "string"}}}
Precise:
{
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": "^ORD-\\d+$",
"description": "Order id like ORD-42",
},
"include_items": {
"type": "boolean",
"default": False,
"description": "Include line items in the response",
},
},
"required": ["order_id"],
}
Four rules make the difference:
- Required fields are explicit. The model never guesses what it must supply.
- Enums replace free text for categorical values —
enum: ["metric", "imperial"], notstring. - Patterns constrain formats the model cannot infer —
^ORD-\d+$kills a whole class of malformed ids. - Descriptions explain meaning, not type. "Order id like ORD-42" teaches; "the order identifier" decorates.
This is the same discipline as defining tools for LLMs and schema-constrained generation: the schema is the contract, and precision is what makes it enforceable.
Authenticating the Invoking User
A shared service key makes every call identical: you cannot audit who did what, and one leaked key grants every tool to everyone. Instead, resolve the invoking user on every call and check their scopes.
TOOL_PERMISSIONS = {
"get_order": {"orders:read"},
"issue_refund": {"orders:write"},
}
class AuthedServer(Server):
def __init__(self, identity: dict) -> None:
super().__init__("orders")
self.identity = identity
async def assert_permission(self, tool_name: str) -> None:
required = TOOL_PERMISSIONS.get(tool_name, set())
if not required & set(self.identity.get("scopes", [])):
raise PermissionError(f"missing scope for {tool_name}")
The identity arrives with the connection — a user token exchanged at session start, propagated per call. assert_permission runs before any tool logic, so an agent holding only orders:read cannot reach issue_refund no matter what it sends.
Failure modes this design prevents:
- Shared key in logs: one leak, full access to every tool.
- Auth checked only at connection time: a long-lived connection outlives the user's session, and the server keeps serving a logged-out user.
- Write scopes on read-only agents: the support copilot can issue refunds because nobody scoped it down.
Per-call user identity is what makes the audit trail real: every invocation is attributable to a person, not a service.
Structured Errors Agents Can React To
A free-form error message — "that order doesn't seem to exist, sorry" — forces the model to parse English and guess the reaction. A structured error code lets it react programmatically.
ERROR_CODES = {
"order_not_found": (40401, "No order exists with that id"),
"invalid_state": (40901, "Order cannot transition to that state"),
"rate_limited": (42901, "Too many calls, retry after 60s"),
}
def structured_error(code: str, detail: str) -> dict:
number, message = ERROR_CODES[code]
return {"code": number, "message": message, "data": {"error": code, "detail": detail}}
| Code | Meaning | Agent reaction |
|---|---|---|
| invalid_arguments | Schema violation | Fix arguments, retry |
| order_not_found | Entity missing | Ask the user for a correct id |
| invalid_state | Business rule blocked | Explain and offer an alternative |
| rate_limited | Throttled | Back off, retry later |
The pattern matches how agents already handle tool results — see shaping tool results for models and agent error recovery. The error is data, not prose: a code the agent can branch on, a message it can show the user, and a detail it can log.
Return errors in the protocol's isError field with the structured payload as content, so the agent loop treats any failure the same way — no special parsing for your server.
The agent loop sees every tool result the same way: content blocks plus an error flag. When the flag is set, the model reads the content and decides what to do — retry with fixed arguments, ask the user for a different id, or back off. That reaction is only possible because the error was structured data, not a prose sentence the model has to interpret.
Quiz
1. Why start a new MCP server with one or two read-only tools?
2. What do vague tool parameters cause?
3. Why authenticate per call against the invoking user instead of a shared service key?
4. Why return structured error codes instead of prose messages?
Flashcards
Question
What is the smallest useful MCP server?
Click to reveal answer
Answer
One tool over stdio: list_tools advertises the schema, call_tool dispatches, stdio_server wires stdin/stdout to JSON-RPC.
Question
What makes an input schema precise?
Click to reveal answer
Answer
Explicit required fields, enums for categorical values, patterns for formats, and descriptions that explain what the value means.
Question
Why authenticate per call instead of with a shared service key?
Click to reveal answer
Answer
A shared key makes every call identical — no audit trail, no least privilege. Per-call user identity gives you both.
Question
Why structured error codes?
Click to reveal answer
Answer
Agents react programmatically — fix arguments, back off, ask the user — without parsing free-form prose.
Question
What is the recommended rollout order for tools?
Click to reveal answer
Answer
One or two read-only tools first; add write tools only after the safety review passes.
Revision Notes
Key Takeaways
- 1. A minimal server is one tool over stdio: list_tools advertises, call_tool dispatches, stdio_server transports
- 2. Precise schemas — required fields, enums, patterns, meaningful descriptions — prevent agent guesswork and retries
- 3. Authenticate per call against the invoking user's scopes; a shared key destroys auditability and least privilege
- 4. Structured error codes let agents react programmatically instead of parsing prose
- 5. Ship read-only tools first; write tools only after safety review
Interview Tips
- • Walk the request path: list_tools advertises the schema, call_tool dispatches, stdio carries JSON-RPC
- • Justify schema precision with the failure mode: vague parameters cause guesswork, failed calls, and token-burning retries
- • Explain the auth decision in one line: a shared key makes every call identical, so per-call identity is the only auditable design
- • Frame the rollout order as a safety decision, not a technical one
Cheat Sheet
Building an MCP Server Cheat Sheet
Minimal server (one tool, stdio):
server = Server("orders")
@server.list_tools()
async def list_tools() -> list[Tool]: ...
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]: ...
async def main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
Schema precision rules:
- Required fields explicit
- Enums replace free text for categorical values
- Patterns constrain formats (^ORD-\d+$)
- Descriptions explain meaning, not type
Auth:
- Resolve the invoking user per call, never a shared service key
- Check scopes before tool logic:
required & set(identity.scopes) - Prevents: key leaks, stale sessions, write scopes on read-only agents
Structured errors:
| Code | Agent reaction |
|---|---|
| invalid_arguments | Fix arguments, retry |
| order_not_found | Ask user for correct id |
| invalid_state | Explain, offer alternative |
| rate_limited | Back off, retry later |
Rollout order: read-only tools first, write tools after safety review.
Continue Learning
Before You Start
- No prerequisites listed.