Advanced
Imagine giving an AI assistant the ability to read files on your computer, query your database, call your internal APIs, or search your company’s knowledge base — all without the AI ever having direct access to those systems. This is exactly what the Model Context Protocol (MCP) enables. MCP is an open standard from Anthropic that defines how AI assistants communicate with external tools and data sources through a simple server-client protocol. You write a Python MCP server that exposes tools, and any MCP-compatible AI client (Claude Desktop, VS Code with Copilot, and others) can discover and use those tools automatically.
Building an MCP server in Python requires the mcp package from PyPI and basic knowledge of Python async programming. The server runs locally on your machine (or on a server your AI client can reach), exposes named tools with typed parameters, and returns results that the AI can read and use in its reasoning. You define tools as Python functions decorated with @mcp.tool() — the framework handles the protocol, discovery, and transport layer for you.
In this article we will cover what MCP is and how it works, how to install the Python MCP SDK, how to define tools and resources, how to handle typed inputs and error responses, how to run and test your server locally, and how to connect it to Claude Desktop. By the end you will have a working MCP server exposing real tools to an AI assistant.
Python MCP Server: Quick Example
Here is the smallest complete Python MCP server — one tool that returns the current timestamp. Save it as server.py and run it:
# server.py
from datetime import datetime
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("My First MCP Server")
@mcp.tool()
def get_current_time() -> str:
"""Return the current date and time."""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if __name__ == "__main__":
mcp.run()
Run and test with the MCP inspector:
# Install the SDK first
pip install mcp
# Run the server
python server.py
# In another terminal, test with the MCP inspector
npx @modelcontextprotocol/inspector python server.py
The MCP inspector opens a web UI at http://localhost:5173 where you can see the registered tools, call them manually, and inspect the protocol messages. When you call get_current_time, it returns something like "2026-05-20 09:15:32". This is the same response an AI assistant would receive when it invokes your tool during a conversation.
What Is MCP and How Does It Work?
The Model Context Protocol (MCP) is a JSON-RPC 2.0 based protocol that standardizes how AI assistants discover and invoke external tools. Before MCP, every AI integration was custom — you wrote LangChain tools, OpenAI function-call schemas, or Anthropic tool definitions separately for each provider. MCP is the USB-C of AI tool integration: write one server, connect to any compatible client.
The protocol has three main concepts:
| Concept | What it is | Python equivalent |
|---|---|---|
| Tools | Functions the AI can call to perform actions | @mcp.tool() decorated functions |
| Resources | Read-only data sources the AI can query | @mcp.resource("uri://") decorators |
| Prompts | Reusable prompt templates the AI can reference | @mcp.prompt() decorators |
The transport layer can be stdio (for local tools, the most common), HTTP with Server-Sent Events (for remote servers), or WebSocket. Claude Desktop uses stdio transport: it launches your Python server as a subprocess, communicates via stdin/stdout, and manages the lifecycle automatically. Install the Python SDK with pip install mcp.
Defining Tools with Typed Parameters
The FastMCP class (the high-level API) uses Python type hints to automatically generate the JSON schema that clients use to discover your tools. Here is a server with multiple tools that demonstrate different parameter types:
# tools_server.py
from mcp.server.fastmcp import FastMCP
from typing import Optional
import os, json, math
mcp = FastMCP("Data Tools Server")
@mcp.tool()
def calculate(expression: str) -> str:
"""
Safely evaluate a mathematical expression.
Args:
expression: A math expression like '2 + 2' or 'sqrt(16)'
"""
safe_names = {k: v for k, v in math.__dict__.items() if not k.startswith("_")}
safe_names.update({"abs": abs, "round": round})
try:
result = eval(expression, {"__builtins__": {}}, safe_names)
return str(result)
except Exception as e:
return f"Error: {e}"
@mcp.tool()
def read_file(path: str, max_lines: Optional[int] = None) -> str:
"""
Read a text file and return its contents.
Args:
path: Absolute path to the file
max_lines: If set, only return this many lines from the start
"""
if not os.path.isfile(path):
return f"Error: File not found at {path}"
try:
with open(path, "r", encoding="utf-8") as f:
lines = f.readlines()
if max_lines is not None:
lines = lines[:max_lines]
return "".join(lines)
except PermissionError:
return f"Error: Permission denied reading {path}"
@mcp.tool()
def list_directory(path: str) -> str:
"""
List files and directories at a given path.
Args:
path: Directory path to list
"""
if not os.path.isdir(path):
return f"Error: Not a directory: {path}"
entries = []
for entry in sorted(os.scandir(path), key=lambda e: (not e.is_dir(), e.name)):
kind = "DIR " if entry.is_dir() else "FILE"
size = entry.stat().st_size if entry.is_file() else "-"
entries.append(f"{kind} {entry.name} ({size} bytes)")
return "\n".join(entries) if entries else "(empty directory)"
if __name__ == "__main__":
mcp.run()
Testing the calculate tool:
# Via MCP inspector or a test client:
# Tool: calculate, args: {"expression": "sqrt(144) + 2**8"}
# Response: "268.0"
# Tool: list_directory, args: {"path": "/tmp"}
# Response:
# FILE example.txt (42 bytes)
# FILE log.json (1024 bytes)
Notice that each tool has a detailed docstring — this is not just documentation for you, it is the description the AI reads to understand what the tool does and when to use it. Write tool docstrings as if you are explaining the tool to a smart but uninformed colleague. The Args: section documents each parameter, and the AI uses this to fill in the right values.
Adding Resources
Resources are read-only data sources that the AI can query by URI. Unlike tools (which perform actions), resources are for exposing structured data — configuration files, database views, API responses. Here is how to add a resource to the same server:
# resources_server.py (add to tools_server.py)
from mcp.server.fastmcp import FastMCP
import json, platform, sys
mcp = FastMCP("System Info Server")
@mcp.resource("system://info")
def get_system_info() -> str:
"""Provide current system information."""
info = {
"os": platform.system(),
"os_version": platform.version(),
"python_version": sys.version,
"processor": platform.processor(),
"hostname": platform.node(),
}
return json.dumps(info, indent=2)
@mcp.resource("config://app")
def get_app_config() -> str:
"""Return the application configuration."""
config = {
"debug": False,
"max_file_size_mb": 100,
"allowed_extensions": [".txt", ".csv", ".json", ".py"],
"log_level": "INFO",
}
return json.dumps(config, indent=2)
if __name__ == "__main__":
mcp.run()
The AI client can request the resource at its URI (system://info) and receive the JSON response. Resources differ from tools in that they are expected to be read-only and safe to call repeatedly — think of them like GET endpoints in a REST API, while tools are like POST endpoints that can have side effects.
Error Handling in MCP Tools
MCP tools communicate errors by either returning an error string or raising a McpError. The right choice depends on whether the error is expected (wrong input, file not found) or unexpected (server crash, dependency failure):
# error_handling.py
from mcp.server.fastmcp import FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
import httpx
mcp = FastMCP("Error Demo Server")
@mcp.tool()
def fetch_url(url: str) -> str:
"""
Fetch the content of a URL.
Args:
url: The URL to fetch (must start with https://)
"""
# Validate input -- return descriptive error string for bad inputs
if not url.startswith("https://"):
return "Error: Only HTTPS URLs are supported for security reasons."
try:
response = httpx.get(url, timeout=10, follow_redirects=True)
response.raise_for_status()
# Return a truncated preview
text = response.text[:2000]
return f"Status: {response.status_code}\nContent (first 2000 chars):\n{text}"
except httpx.TimeoutException:
return "Error: Request timed out after 10 seconds."
except httpx.HTTPStatusError as e:
return f"Error: HTTP {e.response.status_code} from {url}"
except Exception as e:
# For unexpected errors, raise McpError so the client knows something went wrong
raise McpError(ErrorData(code=-32603, message=f"Unexpected error: {e}"))
if __name__ == "__main__":
mcp.run()
The defensive pattern here — validate inputs first, catch specific exceptions with descriptive messages, and only raise McpError for truly unexpected failures — gives the AI client enough information to tell the user what went wrong and suggest a fix. Return strings for recoverable user errors; raise McpError for server-side failures.
Connecting to Claude Desktop
To connect your MCP server to Claude Desktop, add a configuration entry to Claude’s config file. The location depends on your OS:
# macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
# Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"my-data-tools": {
"command": "python",
"args": ["/absolute/path/to/tools_server.py"],
"env": {
"PYTHONPATH": "/absolute/path/to/your/project"
}
}
}
}
After saving this file and restarting Claude Desktop, your tools appear in the tool list. Claude will automatically use them when they are relevant to the conversation — for example, if you ask “what files are in /tmp?”, Claude will call your list_directory tool and show you the result.
Real-Life Example: Notes Manager MCP Server
This complete MCP server exposes tools for managing a simple local notes system — creating, reading, listing, and searching notes stored as plain text files:
# notes_server.py
from mcp.server.fastmcp import FastMCP
from typing import Optional
import os, glob, datetime
mcp = FastMCP("Notes Manager")
NOTES_DIR = os.path.expanduser("~/mcp_notes")
os.makedirs(NOTES_DIR, exist_ok=True)
@mcp.tool()
def create_note(title: str, content: str) -> str:
"""Create a new note with the given title and content."""
safe_title = "".join(c if c.isalnum() or c in " -_" else "_" for c in title)
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{timestamp}_{safe_title[:40]}.txt"
path = os.path.join(NOTES_DIR, filename)
with open(path, "w", encoding="utf-8") as f:
f.write(f"Title: {title}\nDate: {datetime.datetime.now().isoformat()}\n\n{content}")
return f"Note created: {filename}"
@mcp.tool()
def list_notes() -> str:
"""List all saved notes with their titles and dates."""
files = sorted(glob.glob(os.path.join(NOTES_DIR, "*.txt")), reverse=True)
if not files:
return "No notes found."
lines = []
for path in files[:20]: # limit to 20 most recent
name = os.path.basename(path)
size = os.path.getsize(path)
lines.append(f" {name} ({size} bytes)")
return f"Found {len(files)} notes:\n" + "\n".join(lines)
@mcp.tool()
def search_notes(query: str) -> str:
"""Search note contents for a keyword or phrase."""
files = glob.glob(os.path.join(NOTES_DIR, "*.txt"))
matches = []
for path in files:
try:
content = open(path, encoding="utf-8").read()
if query.lower() in content.lower():
name = os.path.basename(path)
# Extract a snippet around the match
idx = content.lower().find(query.lower())
snippet = content[max(0, idx-50):idx+100].replace("\n", " ")
matches.append(f" {name}: ...{snippet}...")
except Exception:
pass
if not matches:
return f"No notes found containing '{query}'."
return f"Found {len(matches)} matching notes:\n" + "\n".join(matches)
if __name__ == "__main__":
mcp.run()
Example AI conversation using this server:
User: Create a note called "Meeting notes" with the content "Discussed Q3 roadmap"
AI: [calls create_note("Meeting notes", "Discussed Q3 roadmap")]
Note created: 20260520_091532_Meeting_notes.txt
User: Search my notes for "roadmap"
AI: [calls search_notes("roadmap")]
Found 1 matching notes:
20260520_091532_Meeting_notes.txt: ...Discussed Q3 roadmap...
This server is a practical starting point for any personal productivity tool. You can extend it by adding a delete_note tool, a read_note tool that takes a filename, or a resource that exposes the notes directory as a browseable URI tree.
Frequently Asked Questions
Is it safe to give an AI access to my file system via MCP?
Safety depends entirely on what your tools allow. The MCP server is just Python code — you control what the AI can access. In the read_file example above, we check that the path exists and return a permission error rather than letting the AI read arbitrary system files. A good practice is to restrict tools to a specific working directory using os.path.abspath() and checking that the resolved path starts with your allowed root directory. Never expose tools that can execute arbitrary code or delete files without confirmation.
Should my MCP tools be async?
Use async when your tools make I/O-bound calls like HTTP requests, database queries, or file reads on large files. FastMCP supports both sync and async tools: just define your function with async def and use await normally. For CPU-bound operations or quick operations, sync is fine. The server handles multiple tool calls concurrently using asyncio, so async tools are more efficient when the client sends multiple requests simultaneously.
Can multiple AI clients connect to the same MCP server?
Over stdio transport (the default for Claude Desktop), each connection launches a new server process — so each AI client gets its own isolated server instance. Over HTTP transport, a single server can handle multiple concurrent clients. For most local use cases, stdio is the right choice. If you want to share a server across multiple users or machines, use HTTP transport with mcp.run(transport="sse") and deploy it as you would any web service.
How do I debug an MCP server?
The MCP inspector (npx @modelcontextprotocol/inspector python server.py) is the best tool — it shows all protocol messages in both directions and lets you call tools manually. For logging during development, use Python’s logging module and write to a file (not stdout, since stdout is the protocol channel): logging.basicConfig(filename="mcp_debug.log", level=logging.DEBUG). The log file will capture all your logging.info() and logging.error() calls without interfering with the protocol.
Which AI clients support MCP besides Claude Desktop?
The MCP ecosystem has grown rapidly. Clients that support MCP include Claude Desktop, Zed editor (built-in), Cursor IDE, VS Code with the Claude extension, Continue.dev, and several open-source chat UIs. The protocol is model-agnostic — your Python MCP server works with any client regardless of the underlying LLM. Check the official MCP clients list for the current roster, as new integrations are added frequently.
Conclusion
Building a Python MCP server turns your Python functions into AI-accessible tools without any vendor lock-in. We covered the FastMCP framework, tool definition with typed parameters and docstrings, resource registration, error handling patterns, connecting to Claude Desktop, and a complete notes management server. The protocol handles discovery, schema generation, and transport — you just write Python functions.
The best next step is to start with the notes server above and add one more tool that solves a real problem you have — maybe a tool that queries a local SQLite database, or one that calls an internal API. The MCP Inspector documentation will help you test and debug. Once your server is working locally, explore HTTP transport and the MCP server registry for sharing your server with others.