Theory: Building Your Own MCP Server
A practical guide to building MCP servers with Python using the FastMCP framework.
MCP Python SDK
The MCP Python SDK provides two levels of abstraction for building servers. The low-level API gives you full control over the JSON-RPC message handling, but requires significant boilerplate. The high-level API, called FastMCP, lets you build servers with decorators and type hints, similar to how FastAPI works for web APIs.
For most use cases, FastMCP is the right choice. It handles protocol negotiation, schema generation, transport management, and error formatting automatically. You focus on writing the business logic; FastMCP handles the protocol plumbing.
# Install the MCP SDK with CLI tools
pip install "mcp[cli]"
The [cli] extra installs the mcp command-line tool, which includes the development inspector and server runner.
Server Setup
A minimal FastMCP server has three parts: creating the server instance, defining tools and resources, and running the server.
from mcp.server.fastmcp import FastMCP
# Create the server with a name
mcp = FastMCP("notes-server")
# Define tools and resources (shown in sections below)
# Run the server (handles stdio transport by default)
if __name__ == "__main__":
mcp.run()
The server name is used for identification during the initialisation handshake. Choose a descriptive, kebab-case name. When you run the script, it starts a stdio server that reads JSON-RPC messages from stdin and writes responses to stdout. This is the standard way MCP hosts like Claude Desktop launch and communicate with servers.
Implementing Tools
Tools are the most important primitive in most MCP servers. They let the LLM perform actions: query a database, create a record, send a message, run a computation. In FastMCP, you define tools with the @mcp.tool() decorator.
notes: dict[str, str] = {}
@mcp.tool()
def add_note(title: str, content: str) -> str:
"""Add a new note to the collection.
Args:
title: Short title for the note (used as identifier)
content: Full text content of the note
"""
notes[title] = content
return f"Note '{title}' saved successfully ({len(content)} characters)"
@mcp.tool()
def list_notes() -> str:
"""List all saved notes with their titles."""
if not notes:
return "No notes saved yet."
return "\n".join(f"- {title}" for title in sorted(notes.keys()))
FastMCP extracts the tool schema from your function automatically. The function name becomes the tool name. The docstring becomes the description. Type hints become the JSON Schema input definition. The Args: section of the docstring provides parameter descriptions.
Writing good tool descriptions: The LLM reads the tool name and description to decide when to use it. Be specific about what the tool does, what it returns, and when to use it vs other tools. Avoid vague descriptions like "manage notes" — prefer "Add a new note with a title and content".
Implementing Resources
Resources provide read-only data that the host can include in the LLM's context. They are identified by URIs. In FastMCP, use the @mcp.resource() decorator with a URI pattern.
@mcp.resource("notes://all")
def get_all_notes() -> str:
"""All notes formatted as text."""
if not notes:
return "No notes saved."
return "\n\n".join(f"## {t}\n{c}" for t, c in notes.items())
@mcp.resource("notes://{title}")
def get_note_by_title(title: str) -> str:
"""A specific note by its title."""
return notes.get(title, f"Note '{title}' not found.")
URI templates use curly braces for dynamic segments. When a client requests notes://shopping-list, FastMCP extracts title="shopping-list" and passes it to your function. Static URIs like notes://all are matched exactly.
Resources differ from tools in an important way: resources are read by the application (host), not invoked by the model. The host might automatically include relevant resources in the context, or let the user browse them. Tools, by contrast, are called by the model during conversation.
Input Schemas
FastMCP generates JSON Schema from Python type hints. The mapping is straightforward:
| Python Type | JSON Schema Type |
|---|---|
str | {"type": "string"} |
int | {"type": "integer"} |
float | {"type": "number"} |
bool | {"type": "boolean"} |
list[str] | {"type": "array", "items": {"type": "string"}} |
str | None | Optional string (not in required) |
Parameters without default values are marked as required. Parameters with defaults are optional. Use Annotated[str, Field(description="...")] from Pydantic for richer schema annotations when the docstring is not sufficient.
Error Handling
MCP tools should handle errors gracefully and return informative error messages. If a tool raises an unhandled exception, FastMCP will catch it and return a generic error to the client. It is better to catch errors yourself and return a descriptive message.
@mcp.tool()
def get_note(title: str) -> str:
"""Get a note by its title."""
if title not in notes:
return f"Error: note '{title}' not found. Use list_notes to see available notes."
return notes[title]
Return error information as a string rather than raising exceptions. This gives the LLM actionable information: it can read the error message and try a different approach (for example, calling list_notes first to find the correct title).
Never expose internal errors: Stack traces, file paths, and database connection strings should never appear in tool results. Sanitise error messages before returning them.
Testing
The MCP CLI provides two testing modes:
Development Inspector
mcp dev server.py launches a web-based inspector where you can interactively call tools, browse resources, and inspect JSON-RPC messages. This is the fastest way to iterate during development.
Direct Run
mcp run server.py starts the server in stdio mode, which you can pipe to for automated testing. You can also write unit tests that import your tool functions directly (they are regular Python functions) and test them without the MCP protocol layer.
Claude Desktop Integration
Add your server to claude_desktop_config.json for end-to-end testing with Claude:
{
"mcpServers": {
"notes": {
"command": "python",
"args": ["/path/to/notes_server.py"]
}
}
}