Build an MCP Server with Python
Learn how to create a fully functional MCP server using the official Python SDK. We'll build a practical server with real tools and connect it to Claude Desktop.
Prerequisites
- Python 3.10 or higher installed
- Basic familiarity with Python and async/await
- pip or uv package manager
- Claude Desktop (optional, for final integration step)
Step 1 — Install the MCP Python SDK
The official MCP Python SDK is published as mcp on PyPI. The recommended way to manage Python MCP projects is with uv, but pip works too.
With uv (recommended)
# Create a new project uv init my-mcp-server cd my-mcp-server # Add MCP dependency uv add "mcp[cli]"
With pip
pip install "mcp[cli]"
Note: The [cli] extra includes the mcp command-line tool, which is useful for testing your server without a full AI client.
Step 2 — Create Your First MCP Server
Create a file called server.py. This minimal server exposes one tool that adds two numbers.
from mcp.server.fastmcp import FastMCP
# Create the server instance
mcp = FastMCP("my-first-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
if __name__ == "__main__":
mcp.run()FastMCP is the high-level API that handles all protocol details for you. The @mcp.tool() decorator automatically:
- Registers the function as an MCP tool
- Uses Python type annotations to build the JSON schema
- Uses the docstring as the tool description
Run and inspect it with the MCP CLI:
mcp dev server.py
This opens the MCP Inspector in your browser — you can call your tool interactively and see the JSON-RPC messages.
Step 3 — Build a Practical Server
Let's build something more useful: a filesystem utility server with multiple tools and proper error handling.
from mcp.server.fastmcp import FastMCP
from pathlib import Path
import json
mcp = FastMCP("file-utils")
# --- Tools ---
@mcp.tool()
def read_file(path: str) -> str:
"""Read the contents of a text file.
Args:
path: Absolute or relative path to the file.
Returns:
The file contents as a string.
"""
file = Path(path)
if not file.exists():
raise FileNotFoundError(f"File not found: {path}")
if not file.is_file():
raise ValueError(f"Path is not a file: {path}")
return file.read_text(encoding="utf-8")
@mcp.tool()
def list_directory(path: str = ".") -> list[str]:
"""List files and directories in a given path.
Args:
path: Directory path to list. Defaults to current directory.
Returns:
List of file/directory names with type prefix (file: or dir:).
"""
directory = Path(path)
if not directory.is_dir():
raise NotADirectoryError(f"Not a directory: {path}")
entries = []
for entry in sorted(directory.iterdir()):
prefix = "dir:" if entry.is_dir() else "file:"
entries.append(f"{prefix} {entry.name}")
return entries
@mcp.tool()
def write_file(path: str, content: str) -> str:
"""Write text content to a file.
Args:
path: Path where the file should be written.
content: Text content to write.
Returns:
Confirmation message with the number of bytes written.
"""
file = Path(path)
file.parent.mkdir(parents=True, exist_ok=True)
bytes_written = file.write_text(content, encoding="utf-8")
return f"Written {bytes_written} bytes to {path}"
# --- Resources ---
@mcp.resource("file://{path}")
def get_file_resource(path: str) -> str:
"""Expose a file as an MCP resource."""
return Path(path).read_text(encoding="utf-8")
if __name__ == "__main__":
mcp.run()Security tip: In production, always validate paths to prevent directory traversal attacks. Consider using path.resolve().is_relative_to(allowed_base) to restrict access to a safe directory.
Step 4 — Low-Level API (Optional)
For advanced use cases you can use the lower-level Server class, which gives you full control over protocol messages.
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
import mcp.types as types
server = Server("low-level-server")
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="ping",
description="Returns pong",
inputSchema={
"type": "object",
"properties": {},
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "ping":
return [types.TextContent(type="text", text="pong")]
raise ValueError(f"Unknown tool: {name}")
async def main():
async with stdio_server() as streams:
await server.run(
streams[0],
streams[1],
server.create_initialization_options(),
)
if __name__ == "__main__":
asyncio.run(main())Step 5 — Connect to Claude Desktop
To use your Python MCP server with Claude Desktop, add it to the Claude Desktop config file.
Config file location
Add your server
{
"mcpServers": {
"file-utils": {
"command": "python",
"args": ["/absolute/path/to/your/server.py"]
}
}
}If you're using uv, use the uv run command instead:
{
"mcpServers": {
"file-utils": {
"command": "uv",
"args": [
"--directory",
"/absolute/path/to/my-mcp-server",
"run",
"server.py"
]
}
}
}Restart Claude Desktop after saving. You should see a hammer icon in the chat interface indicating MCP tools are available.
Troubleshooting
Check the logs at ~/Library/Logs/Claude/mcp*.log on macOS.
Make sure you're using the absolute path to your Python interpreter and script.
The python in your PATH may not be the one with mcp installed. Use the full path: /usr/local/bin/python3 or the venv path.
You must fully restart Claude Desktop (quit, not just close window) after changing the config or server code.
What's next?
You now have a working Python MCP server. Try adding resources, async tools, or connect it to a real API like a database or REST service.