Building an MCP Server with the Python SDK
The official Python MCP SDK gives you a small set of decorators for registering tools, resources, and prompts.
Everything else, the connection, capability negotiation, and request routing, is handled for you.
Summary
A Python MCP server is built around FastMCP, the SDK's high-level server class.
You register capabilities with three decorators: @mcp.tool(), @mcp.resource(), and @mcp.prompt().
Type hints on each function double as the input schema the client sees during capability negotiation.
Errors raised inside a handler are converted into structured error responses rather than crashing the server.
This page builds on the minimal skeleton from the basics page and goes further into typed arguments, parameterized resources, and error handling.
Recipe
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("tickets-server")
@mcp.tool()
def create_ticket(title: str, priority: str = "normal") -> str:
"""Create a support ticket and return its ID."""
return "T-1042"
@mcp.resource("tickets://{ticket_id}")
def get_ticket(ticket_id: str) -> str:
"""Fetch a single ticket by ID."""
return f"Ticket {ticket_id}: open"
@mcp.prompt()
def triage_prompt(ticket_id: str) -> str:
return f"Review ticket {ticket_id} and recommend a next step."
if __name__ == "__main__":
mcp.run(transport="stdio")When to reach for this:
- You want to expose an internal API, database, or file system to Claude as callable tools.
- You need read-only data (config, docs, records) available to a client without it counting as an "action."
- You want reusable prompt templates shared across every client that connects to this server.
- You're building for local development first, stdio is the right transport before adding a network layer.
Working Example
from dataclasses import dataclass
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("tickets-server")
@dataclass
class Ticket:
id: str
title: str
priority: str
status: str
_tickets: dict[str, Ticket] = {
"T-1": Ticket(id="T-1", title="Printer offline", priority="low", status="open"),
"T-2": Ticket(id="T-2", title="VPN outage", priority="high", status="closed"),
}
_next_id = 3
@mcp.tool()
def create_ticket(title: str, priority: str = "normal") -> str:
"""Create a new support ticket and return its ID.
priority must be one of: low, normal, high.
"""
global _next_id
if priority not in ("low", "normal", "high"):
raise ValueError(f"invalid priority '{priority}', expected low, normal, or high")
ticket_id = f"T-{_next_id}"
_next_id += 1
_tickets[ticket_id] = Ticket(id=ticket_id, title=title, priority=priority, status="open")
return ticket_id
@mcp.tool()
def close_ticket(ticket_id: str) -> str:
"""Mark a ticket as closed."""
ticket = _tickets.get(ticket_id)
if ticket is None:
raise ValueError(f"no ticket with id '{ticket_id}'")
ticket.status = "closed"
return f"{ticket_id} closed"
@mcp.resource("tickets://{ticket_id}")
def get_ticket(ticket_id: str) -> str:
"""Fetch a single ticket's details by ID."""
ticket = _tickets.get(ticket_id)
if ticket is None:
raise ValueError(f"no ticket with id '{ticket_id}'")
return f"{ticket.id} [{ticket.priority}] {ticket.title} - {ticket.status}"
@mcp.resource("tickets://open")
def list_open_tickets() -> str:
"""List every open ticket."""
open_ids = [t.id for t in _tickets.values() if t.status == "open"]
return "\n".join(open_ids) if open_ids else "no open tickets"
@mcp.prompt()
def triage_prompt(ticket_id: str) -> str:
"""Build a prompt asking for a triage recommendation on a ticket."""
return f"Review ticket {ticket_id} and recommend whether to escalate or close it."
if __name__ == "__main__":
mcp.run(transport="stdio")What this demonstrates:
- Two tools,
create_ticketandclose_ticket, that mutate server-side state and validate their own input before doing so. - A parameterized resource,
tickets://{ticket_id}, next to a fixed resource,tickets://open, showing both styles. - Validation errors raised as plain
ValueError, which the SDK turns into a structured error response instead of crashing the process. - A prompt template that references a ticket ID, kept intentionally simple since prompts should stay declarative.
Deep Dive
How It Works
FastMCP("tickets-server")creates a server instance and immediately wires up the connection and negotiation stages of the request lifecycle.- Each
@mcp.tool()call registers the decorated function in the server's internal tool table, keyed by its function name unless you pass an explicit name. - The function's type hints and docstring are introspected at registration time to build the JSON schema sent to clients during capability negotiation.
- When a client calls a tool, the SDK validates incoming arguments against that schema before your function body ever runs.
- Resources follow the same registration pattern but are addressed by URI; a
{placeholder}in the URI template becomes a parameter passed to your function.
Error Handling
Raising a standard Python exception inside any handler is the correct way to signal failure.
@mcp.tool()
def get_priority_multiplier(priority: str) -> float:
multipliers = {"low": 0.5, "normal": 1.0, "high": 2.0}
if priority not in multipliers:
raise ValueError(f"unknown priority '{priority}'")
return multipliers[priority]The SDK catches the exception and turns it into a structured error result the client can display or reason about, rather than letting it crash the server process.
Prefer raising early with a clear message over returning a string like "error: bad input", which the client would treat as a successful result.
Tools vs Resources vs Prompts at a Glance
| Capability | Purpose | Has Side Effects | Example |
|---|---|---|---|
| Tool | Perform an action | Often yes | create_ticket, send_email |
| Resource | Read data by URI | No | tickets://open, config://settings |
| Prompt | Reusable request template | No | triage_prompt |
Python Notes
# Use precise type hints, they become the schema the client validates against.
@mcp.tool()
def set_priority(ticket_id: str, priority: str) -> str:
...
# Optional arguments with defaults are reflected as optional in the schema.
@mcp.tool()
def search_tickets(query: str, limit: int = 10) -> list[str]:
...Precise type hints matter more here than in typical internal code, since they become part of a contract a client depends on, not just documentation for another developer.
Parameters & Return Values
| Decorator | Registers | Return Type Convention |
|---|---|---|
@mcp.tool() | A callable action | Any JSON-serializable value, or a string summary |
@mcp.resource(uri) | Read-only content at a URI | A string or structured content block |
@mcp.prompt() | A reusable prompt template | A string, or a list of message objects for multi-turn templates |
Gotchas
- Vague type hints produce a useless schema. A parameter typed as
strwhere it's really a fixed set of values (likepriority) gives the client no hint about valid inputs. Fix: validate against an explicit set inside the function and raise a clearValueError, or use aLiteraltype hint where the SDK supports it. - Returning an error string instead of raising. Returning
"error: not found"from a tool looks like a successful result to the client. Fix: raise an exception; let the SDK convert it to a proper error response. - Mutating shared state without guarding for concurrent calls. A module-level dict like
_ticketscan be touched by overlapping requests if the server handles more than one session. Fix: back real state with a database or another concurrency-safe store once you're past local prototyping. - Forgetting the docstring. A tool without a docstring registers fine but gives Claude nothing to reason about when deciding whether to call it. Fix: always write a one-line docstring describing what the tool does and any constraints on its arguments.
- Renaming a tool's arguments in a later version. Existing clients that call the tool with the old argument names will start failing validation. Fix: treat argument names as part of your public contract, and see schema versioning strategies before changing them.
- Blocking I/O inside a handler. A tool that makes a slow synchronous network call ties up the server for that request. Fix: use
async deffor tool functions that do I/O, the SDK supports async handlers directly.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| TypeScript MCP SDK | Your existing service or team is TypeScript/Node-based | You want to reuse an existing Python data or ML stack |
Low-level MCP protocol classes (no FastMCP) | You need fine-grained control over the protocol not exposed by the high-level API | You just need standard tools, resources, and prompts, FastMCP covers this |
| A plain REST API (no MCP) | The consumer is not an LLM client and doesn't need capability negotiation | You want Claude to discover and call your functionality dynamically |
FAQs
Do I have to use FastMCP, or can I use the protocol directly?
FastMCP is the recommended high-level API and covers the vast majority of use cases. The SDK also exposes lower-level protocol classes for advanced scenarios that need finer control, but most servers never need them.
How does the SDK know what arguments a tool expects?
- It introspects the function's type hints at registration time.
- Those hints are converted into a JSON schema sent to clients during capability negotiation.
- The SDK validates incoming arguments against that schema before your function runs.
What happens if my tool function raises an exception?
@mcp.tool()
def divide(a: float, b: float) -> float:
if b == 0:
raise ValueError("cannot divide by zero")
return a / bThe SDK catches the exception and returns a structured error to the client instead of crashing the server process.
Can a resource take parameters like a tool does?
Yes. A URI template with a {placeholder}, such as tickets://{ticket_id}, passes that segment to your function as an argument, the same way a tool argument works.
Should I use a tool or a resource for read-only data?
Prefer a resource. Tools imply an action the client is choosing to invoke, while resources are meant for content a client reads directly. Using a tool for pure data retrieval works but is a semantic mismatch that can confuse how a client presents the capability.
Can tool handlers be async?
Yes, async def is supported directly by the decorator. Use it for any handler doing network or file I/O, so a slow call doesn't block other requests on the same server.
How do I test a server without a real client?
Write unit tests that call your handler functions directly, and separately exercise them through a mock client that speaks the protocol. See the dedicated page on testing MCP servers for the full pattern.
What's the difference between a tool's docstring and its description shown to Claude?
The docstring is the description. The SDK reads it directly from the function and includes it in the capability manifest, so Claude sees exactly what you wrote as the doc comment.
Can one server register both sync and async tools?
Yes, the SDK handles both. Choose based on whether the individual handler does blocking work, not based on a server-wide rule.
Is there a limit to how many tools a server can register?
The SDK itself imposes no fixed limit, but a server with dozens of loosely related tools becomes hard for both Claude and human maintainers to reason about. Group related capabilities into focused servers instead of one large catch-all server.
How do I change a tool's arguments without breaking existing clients?
Add new arguments as optional with sensible defaults rather than renaming or removing existing ones. For larger changes, see the dedicated page on versioning tool schemas.
Does FastMCP require a specific Python version?
The SDK targets modern Python (3.10+ at minimum) to support the type hint features it relies on for schema generation. Check the SDK's published requirements for the exact minimum at install time.
Related
- MCP Server Basics - the minimal skeleton this article builds on
- How MCP Servers Handle Requests - the lifecycle these handlers plug into
- Building an MCP Server with the TypeScript SDK - the same patterns in TypeScript
- Testing MCP Servers Before Deployment - exercising these handlers with a mock client
- Versioning and Rate Limiting MCP Tool Schemas Reference - keeping tool changes backward compatible
Stack versions: Written against the Claude model lineup current as of ~June 2026 - Claude Fable 5, Claude Opus 4.8, Claude Sonnet 5 (the default), and Claude Haiku 4.5 - and the current Model Context Protocol Python/TypeScript SDKs. Model names, SDK versions, and the MCP spec move quickly - verify current specifics at platform.claude.com/docs and modelcontextprotocol.io before relying on them.