You want your AI coding assistant to read your database, search your docs, or hit your internal API. Right now you either paste context manually into the chat, or you write a brittle custom integration that breaks the moment the model vendor changes something.
The Model Context Protocol (MCP) fixes this. It is an open standard that lets any MCP-compatible client (Claude Desktop, Cursor, VS Code with Continue, and others) talk to any MCP server over a shared protocol. You write the server once. Every compatible client can use it.
This tutorial builds a working MCP server in Python with the official SDK. Not a toy demo. A server with real tools, resources, prompts, error handling, and two transport modes. You will be able to run it and test it by the end.
Prerequisites
- Python 3.10 or newer (check with
python3 --version) uvinstalled (recommended for MCP SDK development). Install it withcurl -LsSf https://astral.sh/uv/install.sh | sh- Basic Python familiarity. You should be comfortable with functions, type hints, and async basics.
- An MCP-compatible client for testing. The MCP Inspector (covered below) works without any client setup.
What MCP actually is
MCP follows a client-server architecture. An MCP host (an AI application like Claude Desktop or Cursor) creates one MCP client per server it connects to. Each client maintains a dedicated connection to its server.
The protocol runs on two layers:
- Data layer: JSON-RPC 2.0 messages. This is where tools, resources, and prompts live.
- Transport layer: how bytes move between client and server. Two options: stdio (for local servers, fast, no network) and Streamable HTTP (for remote servers, supports multiple clients).
Servers expose three primitives:
- Tools: functions the LLM can call. Think POST endpoints. Reading a database, calling an API, running a computation.
- Resources: data the LLM can read into context. Think GET endpoints. File contents, database rows, documentation.
- Prompts: reusable message templates. Pre-built instructions for specific tasks.
The SDK abstracts away the JSON-RPC plumbing. You write Python functions with type hints and docstrings. The SDK generates the schema, handles the protocol, and manages the connection lifecycle.
Step 1: Set up the project
Create a new project with uv:
uv init mcp-server-tutorial
cd mcp-server-tutorial
uv add "mcp[cli]"
This installs the MCP Python SDK with CLI tools. The stable v1.x line is what you get by default, which is what you want for production. The SDK is at version 1.28.1 on PyPI as of this writing.
Verify the install:
uv run mcp --help
You should see CLI help output listing available commands like dev and install.
Step 2: Write your first tool
Create server.py:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("Tutorial")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
if __name__ == "__main__":
mcp.run(transport="stdio")
That is a complete MCP server. The @mcp.tool() decorator registers the function as a tool. Type hints become the JSON Schema. The docstring becomes the tool description the LLM sees. No manual schema definition, no request parsing.
The transport="stdio" line tells the server to communicate over standard input and output. This is the transport for local servers. The client spawns the server process and talks to it through stdin/stdout.
Step 3: Test with the MCP Inspector
The MCP Inspector is a web UI for testing MCP servers without needing a full AI client. Run it with:
npx @modelcontextprotocol/inspector uv run server.py
This launches the Inspector and connects it to your server automatically. Open the URL it prints (typically http://localhost:6274). You will see:
- A Tools tab listing your
addtool with its schema - An input form pre-filled from the type hints
- A button to call the tool and see the result
Type a=5 and b=3, hit Call, and you get 8 back. This is the fastest way to verify your server works before plugging it into a real client.
Step 4: Add real tools with error handling
A calculator is fine for testing. Let us build something closer to a real use case: a server that reads files and searches directories. This is what the official filesystem server does, but simplified.
import os
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("FileTools")
@mcp.tool()
def read_file(path: str) -> str:
"""Read the contents of a file at the given path.
Args:
path: Absolute or relative path to the file.
"""
p = Path(path)
if not p.exists():
raise ValueError(f"File not found: {path}")
if not p.is_file():
raise ValueError(f"Not a file: {path}")
if p.stat().st_size > 1_000_000:
raise ValueError(f"File too large (max 1MB): {path}")
return p.read_text(encoding="utf-8")
@mcp.tool()
def list_directory(path: str) -> str:
"""List files and subdirectories in a directory.
Args:
path: Path to the directory to list.
"""
p = Path(path)
if not p.exists():
raise ValueError(f"Directory not found: {path}")
if not p.is_dir():
raise ValueError(f"Not a directory: {path}")
entries = []
for entry in sorted(p.iterdir()):
entry_type = "dir" if entry.is_dir() else "file"
size = entry.stat().st_size if entry.is_file() else 0
entries.append(f"[{entry_type}] {entry.name} ({size} bytes)")
return "\n".join(entries) if entries else "Directory is empty"
@mcp.tool()
def grep_files(directory: str, pattern: str) -> str:
"""Search for a text pattern in all files under a directory.
Args:
directory: Root directory to search in.
pattern: Text to search for (case-insensitive).
"""
root = Path(directory)
if not root.is_dir():
raise ValueError(f"Directory not found: {directory}")
matches = []
pattern_lower = pattern.lower()
for filepath in root.rglob("*"):
if not filepath.is_file():
continue
if filepath.suffix in (".pyc", ".so", ".png", ".jpg", ".pdf"):
continue
try:
content = filepath.read_text(encoding="utf-8", errors="ignore")
except Exception:
continue
for i, line in enumerate(content.splitlines(), 1):
if pattern_lower in line.lower():
matches.append(f"{filepath}:{i}: {line.strip()}")
if not matches:
return f"No matches found for '{pattern}' in {directory}"
return "\n".join(matches[:50])
if __name__ == "__main__":
mcp.run(transport="stdio")
A few things to notice:
- Raising
ValueErrorinside a tool sends the error message back to the client as an error result. The LLM sees it and can adjust. This is better than returning a generic error string because the protocol distinguishes successful results from errors. - The 1MB file size guard prevents accidentally loading huge files into the LLM context window.
- The grep tool skips binary file extensions and limits results to 50 matches to keep the response manageable.
Step 5: Add resources
Resources are read-only data the client can load into context. Unlike tools, the LLM does not "call" resources. The client application decides when to load them, often based on user actions or LLM suggestions.
@mcp.resource("config://app")
def get_config() -> str:
"""Return the application configuration as JSON."""
return json.dumps({
"version": "1.0.0",
"max_file_size": 1048576,
"allowed_extensions": [".txt", ".md", ".py", ".json"],
}, indent=2)
@mcp.resource("file://{path}")
def get_file_resource(path: str) -> str:
"""Return the contents of a file as a resource."""
p = Path(path)
if not p.is_file():
raise ValueError(f"File not found: {path}")
return p.read_text(encoding="utf-8")
The URI template file://{path} means the client can request file:///home/user/notes.txt and the SDK extracts /home/user/notes.txt as the path parameter. This is how dynamic resources work.
Static resources like config://app have a fixed URI. The client fetches them by that exact URI.
Step 6: Add prompts
Prompts are reusable message templates. When a user picks a prompt in their client, it generates a formatted message that goes into the conversation.
@mcp.prompt()
def review_code(filepath: str) -> str:
"""Generate a prompt for reviewing a file's code."""
return f"""Please review the code in {filepath}.
Focus on:
- Potential bugs or edge cases
- Code clarity and naming
- Missing error handling
Read the file first, then provide your review."""
@mcp.prompt()
def explain_error(error_message: str) -> str:
"""Generate a prompt for explaining an error message."""
return f"I got this error and I'm not sure what it means. Can you explain it and suggest how to fix it?\n\n{error_message}"
In a client like Claude Desktop, these prompts appear in the prompt picker. The user fills in the filepath or error_message argument, and the generated message gets inserted into the chat.
Step 7: Run as a remote server with Streamable HTTP
The stdio transport works for local tools. But if you want multiple clients connecting to the same server, or you want to deploy it on a server, you need the Streamable HTTP transport.
Change the run line:
if __name__ == "__main__":
mcp.run(transport="streamable-http")
Now run it:
uv run server.py
The server starts on http://localhost:8000/mcp. Multiple clients can connect to this endpoint simultaneously. This is the transport you would use for a deployed MCP server.
To test it with the Inspector, start the server in one terminal, then in another:
npx @modelcontextprotocol/inspector
In the Inspector UI, select "Streamable HTTP" as the transport type and enter http://localhost:8000/mcp as the URL.
Step 8: Connect to Claude Desktop
To use your server in Claude Desktop, edit the configuration file. On macOS it is at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows it is at %APPDATA%\Claude\claude_desktop_config.json.
For a stdio server:
{
"mcpServers": {
"filetools": {
"command": "uv",
"args": ["run", "--directory", "/path/to/mcp-server-tutorial", "server.py"]
}
}
}
For an HTTP server:
{
"mcpServers": {
"filetools": {
"type": "http",
"url": "http://localhost:8000/mcp"
}
}
}
Restart Claude Desktop. You should see the tools and prompts from your server available in the interface.
Common pitfalls
Stdio servers must not print to stdout. Any print() call in your server code corrupts the JSON-RPC stream. Use logging instead, which goes to stderr. The MCP SDK configures logging for you.
Large return values. Tools that return huge strings will eat the LLM context window. Cap your results. The grep tool above limits to 50 matches. Do the same for any tool that could return unbounded output.
Blocking operations in async tools. If you mark a tool as async def, do not call blocking I/O directly. Use asyncio.to_thread() or anyio.to_thread.run_sync() to offload blocking calls. The SDK supports both sync and async tools, so if your tool is naturally blocking, just use def instead of async def.
Path traversal. The file tools above do not restrict paths. For anything going near production, validate that resolved paths stay within an allowed directory:
ALLOWED_ROOT = Path("/home/user/projects").resolve()
def safe_path(path: str) -> Path:
resolved = Path(path).resolve()
if not str(resolved).startswith(str(ALLOWED_ROOT)):
raise ValueError(f"Access denied: {path} is outside allowed root")
return resolved
When to use MCP vs. alternatives
MCP is not the only way to give an LLM access to tools. You could use function calling directly through the OpenAI or Anthropic API. You could use a framework like LangChain or CrewAI. Here is when MCP makes sense:
- Multiple clients: You want the same server to work with Claude Desktop, Cursor, VS Code, and any future MCP-compatible client without rewriting anything. This is the main selling point. Function calling ties you to one vendor's API.
- Separation of concerns: The tool logic lives in the server. The LLM interaction lives in the client. Your server does not know or care which model is running.
- Local-first: stdio transport means the server runs on the user's machine with access to local files and tools. No need to expose a web API.
When MCP is overkill:
- Single client, single model, no reuse. If you are building one bot for one API, function calling is simpler.
- You need tight coupling between tool results and the prompt construction. MCP keeps these separate by design.
Summary
You now have a working MCP server with tools, resources, prompts, and two transport modes. The full server code:
import os
import json
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("FileTools")
# --- Tools ---
@mcp.tool()
def read_file(path: str) -> str:
"""Read the contents of a file."""
p = Path(path)
if not p.exists() or not p.is_file():
raise ValueError(f"File not found: {path}")
if p.stat().st_size > 1_000_000:
raise ValueError("File too large (max 1MB)")
return p.read_text(encoding="utf-8")
@mcp.tool()
def list_directory(path: str) -> str:
"""List files in a directory."""
p = Path(path)
if not p.is_dir():
raise ValueError(f"Not a directory: {path}")
entries = []
for entry in sorted(p.iterdir()):
t = "dir" if entry.is_dir() else "file"
entries.append(f"[{t}] {entry.name}")
return "\n".join(entries) if entries else "Empty"
# --- Resources ---
@mcp.resource("config://app")
def get_config() -> str:
"""Return app configuration."""
return json.dumps({"version": "1.0.0"}, indent=2)
# --- Prompts ---
@mcp.prompt()
def review_code(filepath: str) -> str:
"""Generate a code review prompt."""
return f"Please review the code in {filepath}."
if __name__ == "__main__":
mcp.run(transport="stdio")
Next steps: package your server with uv build and publish it to PyPI so others can install it with uvx your-server-name. Or connect it to your team's coding workflow by adding it to the MCP config in Cursor or VS Code.
References
- MCP Architecture Overview - official architecture and concepts documentation
- MCP Python SDK (v1.x) - the SDK used in this tutorial, stable release line
- MCP Inspector - testing and debugging tool for MCP servers