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.
Search across all documentation pages
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.
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.
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:
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:
create_ticket and close_ticket, that mutate server-side state and validate their own input before doing so.tickets://{ticket_id}, next to a fixed resource, tickets://open, showing both styles.ValueError, which the SDK turns into a structured error response instead of crashing the process.FastMCP("tickets-server") creates a server instance and immediately wires up the connection and negotiation stages of the request lifecycle.@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.{placeholder} in the URI template becomes a parameter passed to your function.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.
| 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 |
# 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.
| 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 |
str where it's really a fixed set of values (like priority) gives the client no hint about valid inputs. Fix: validate against an explicit set inside the function and raise a clear ValueError, or use a Literal type hint where the SDK supports it."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._tickets can 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.async def for tool functions that do I/O, the SDK supports async handlers directly.| 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 |
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.
@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.
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.
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.
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.
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.
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.
Yes, the SDK handles both. Choose based on whether the individual handler does blocking work, not based on a server-wide rule.
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.
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.
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.
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.
Reviewed by Chris St. John·Last updated Jul 18, 2026