Model Context Protocol (MCP) is an open standard that lets AI assistants like Claude, GPT, and Cursor talk to your tools, databases, and APIs through a single interface. Instead of writing custom integrations for every AI client, you build one MCP server and any compatible client can use it.
This guide walks you through building a working MCP server in Python — from installation to testing with a real AI client. No prior MCP experience needed.
1. What Is MCP and Why It Matters
Anthropic introduced MCP in late 2024 as an open protocol for connecting AI models to external data sources and tools. Think of it as USB-C for AI: one standard connector that works with any device.
Before MCP, if you wanted Claude to query your database and GPT to read your files, you wrote two separate integrations. With MCP, you build one server that exposes your tools, and any MCP-compatible client can discover and use them.
| Without MCP | With MCP |
|---|---|
| Custom integration per AI client | One server, all clients |
| Vendor-locked tool definitions | Open standard (JSON-RPC) |
| Manual auth for each connection | Standardized auth flow |
| No tool discovery | Clients auto-discover tools |
MCP uses JSON-RPC 2.0 as its transport layer. Servers expose tools (executable functions), resources (readable data), and prompts (reusable templates). Clients call what they need.
2. Prerequisites and Installation
You need Python 3.10+ and the official MCP Python SDK. Create a virtual environment first:
# Create and activate virtual environment
python -m venv mcp-env
# Activate (Windows)
mcp-env\Scripts\activate
# Activate (macOS/Linux)
source mcp-env/bin/activate
# Install the MCP SDK
pip install mcp
# Verify installation
python -c "import mcp; print(mcp.__version__)"
The mcp package includes everything you need: the server framework, type definitions, and a test client for development.
3. Your First MCP Server
Let us build a server that exposes a simple calculator tool and a greeting resource. This covers the two most common MCP primitives.
# server.py
from mcp.server.fastmcp import FastMCP
# Create the server instance
mcp = FastMCP("my-first-server")
# Define a TOOL — something the AI can execute
@mcp.tool()
def calculate(expression: str) -> str:
"""Evaluate a math expression and return the result.
Args:
expression: A math expression like '2 + 3 * 4'
"""
try:
result = eval(expression, {"__builtins__": {}}, {})
return f"Result: {result}"
except Exception as e:
return f"Error: {e}"
# Define a RESOURCE — readable data the AI can access
@mcp.resource("greeting://{name}")
def get_greeting(name: str) -> str:
"""Generate a personalized greeting."""
return f"Hello, {name}! Welcome to MCP."
# Run the server
if __name__ == "__main__":
mcp.run()
That is a complete MCP server. The FastMCP class handles all the JSON-RPC protocol details. You just define Python functions and decorate them.
eval() call above is for demonstration only. In production, use ast.literal_eval() or a dedicated math parser like numexpr to avoid code injection.4. Designing Real-World Tools
Tools are the core of most MCP servers. A well-designed tool has a clear name, a descriptive docstring, and typed parameters. The AI uses the docstring to decide when to call your tool.
import json
import sqlite3
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("database-server")
@mcp.tool()
def query_database(db_path: str, sql: str) -> str:
"""Run a read-only SQL query against a SQLite database.
Args:
db_path: Path to the .sqlite or .db file
sql: A SELECT query (writes are blocked)
Returns:
JSON array of result rows, or an error message
"""
# Block any write operations
sql_stripped = sql.strip().upper()
if not sql_stripped.startswith("SELECT"):
return "Error: Only SELECT queries are allowed."
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.execute(sql)
rows = [dict(row) for row in cursor.fetchall()]
conn.close()
return json.dumps(rows, indent=2)
except Exception as e:
return f"Query error: {e}"
@mcp.tool()
def list_tables(db_path: str) -> str:
"""List all table names in a SQLite database.
Args:
db_path: Path to the .sqlite or .db file
"""
try:
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = [row[0] for row in cursor.fetchall()]
conn.close()
return json.dumps(tables)
except Exception as e:
return f"Error: {e}"
Notice the pattern: each tool does one thing, validates input, and returns a string. The AI client reads the docstring to understand what the tool does and when to use it.
5. Resources vs Tools: When to Use Which
MCP servers expose two main primitives. Understanding the difference is critical:
- Tools — Functions the AI can call to perform actions. They may have side effects (write to a file, send an API request, modify data). The AI decides when to call them based on the user's request.
- Resources — Data the AI can read. They are read-only and identified by URIs. Resources are passive — the AI fetches them when it needs context, not to perform actions.
| Aspect | Tools | Resources |
|---|---|---|
| Purpose | Execute actions | Provide context |
| Side effects | Yes (allowed) | No (read-only) |
| Identification | By name | By URI |
| Example | send_email() | file:///report.txt |
6. Building a File System Server
A common use case: let an AI read files from a specific directory. Here is a safe implementation:
import os
from pathlib import Path
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("file-server")
# Restrict access to this directory only
ALLOWED_DIR = Path("/home/user/projects").resolve()
@mcp.tool()
def read_file(relative_path: str) -> str:
"""Read the contents of a file within the allowed project directory.
Args:
relative_path: Path relative to the project root (e.g., 'src/main.py')
"""
target = (ALLOWED_DIR / relative_path).resolve()
# Prevent path traversal attacks
if not str(target).startswith(str(ALLOWED_DIR)):
return "Error: Access denied — path outside allowed directory."
if not target.is_file():
return f"Error: File not found: {relative_path}"
return target.read_text(encoding="utf-8")
@mcp.tool()
def list_files(directory: str = ".") -> str:
"""List files and directories at the given path.
Args:
directory: Subdirectory within the project root (default: root)
"""
target = (ALLOWED_DIR / directory).resolve()
if not str(target).startswith(str(ALLOWED_DIR)):
return "Error: Access denied."
if not target.is_dir():
return "Error: Not a directory."
entries = []
for item in sorted(target.iterdir()):
prefix = "[DIR] " if item.is_dir() else "[FILE] "
entries.append(f"{prefix}{item.name}")
return "\n".join(entries)
@mcp.resource("file://{relative_path}")
def file_resource(relative_path: str) -> str:
"""Expose project files as readable resources."""
target = (ALLOWED_DIR / relative_path).resolve()
if not str(target).startswith(str(ALLOWED_DIR)):
return "Access denied."
if target.is_file():
return target.read_text(encoding="utf-8")
return "File not found."
7. Testing Your MCP Server
The MCP SDK includes a built-in inspector tool for testing. Run it against your server:
# Install the inspector (one-time)
pip install mcp-cli
# Run your server with the inspector
mcp dev server.py
# Or test programmatically
python -c "
import asyncio
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def test():
server_params = StdioServerParameters(
command='python',
args=['server.py'],
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# List available tools
tools = await session.list_tools()
print('Available tools:')
for tool in tools.tools:
print(f' - {tool.name}: {tool.description}')
# Call a tool
result = await session.call_tool(
'calculate',
{'expression': '2 + 3 * 4'}
)
print(f'Result: {result.content[0].text}')
asyncio.run(test())
"
The inspector opens a web UI where you can list tools, call them with custom arguments, and see raw JSON-RPC responses. Use it during development to debug tool definitions and responses.
8. Connecting to AI Clients
Once your server works, connect it to an AI client. Each client has its own config format:
Claude Desktop
Edit claude_desktop_config.json (found in Claude's settings):
{
"mcpServers": {
"my-database-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"DB_PATH": "/data/app.db"
}
}
}
}
Restart Claude Desktop and your tools appear in the chat. Claude automatically calls them when relevant.
Cursor IDE
In Cursor settings, add the server under Settings › Features › MCP:
{
"mcpServers": {
"file-server": {
"command": "python",
"args": ["/path/to/file_server.py"]
}
}
}
9. Common Mistakes and Pitfalls
9.1. Vague docstrings
The AI relies entirely on your tool's docstring to decide when to call it. """Does stuff""" is useless. Write docstrings that explain what the tool does, what each parameter means, and what it returns.
# BAD — the AI has no idea when to use this
@mcp.tool()
def process(data: str) -> str:
"""Process data."""
...
# GOOD — the AI knows exactly what this does
@mcp.tool()
def summarize_text(text: str, max_sentences: int = 3) -> str:
"""Summarize a block of text into a concise summary.
Args:
text: The input text to summarize (any length)
max_sentences: Maximum number of sentences in the summary (default: 3)
Returns:
A summary string
"""
...
9.2. No input validation
The AI can pass unexpected values. Always validate inputs — paths, SQL queries, file sizes — before processing. Block path traversal, limit query results, and sanitize file paths.
9.3. Returning unstructured data
Return strings, not raw Python objects. If your tool returns a dict, serialize it to JSON first. The AI reads the string response, so format it clearly.
9.4. Tools that do too much
One tool should do one thing. Instead of manage_database(operation, table, data), split it into list_tables(), query_database(sql), and describe_table(name). The AI can compose small tools better than it can navigate one giant tool.
9.5. Forgetting to use absolute paths
AI clients run your server from their own working directory, not yours. Always use absolute paths in args and inside your server code. Relative paths will break.
10. Deployment Options
For local development, stdio transport (the default) is fine. For production or remote access, use SSE (Server-Sent Events) transport:
# For stdio (local, default)
mcp.run()
# For SSE (remote, network-accessible)
mcp.run(transport="sse", host="0.0.0.0", port=8080)
SSE transport lets you host your MCP server on a remote machine. Clients connect over HTTP, which means you can share one server across multiple machines or team members.
Frequently Asked Questions
Do I need to install Claude to use MCP?
No. MCP is an open protocol. Any client that implements the MCP spec can connect to your server — Claude Desktop, Cursor, Windsurf, Cline, or your own custom client built with the MCP SDK.
Can I write an MCP server in JavaScript instead of Python?
Yes. Anthropic maintains official SDKs for both Python and TypeScript. The protocol is language-agnostic — a Python server works with a TypeScript client and vice versa.
How is MCP different from regular function calling?
Function calling is tied to a specific model's API (OpenAI, Anthropic, etc.). MCP is a standard protocol that works across all compatible clients. You write your tool once, and any MCP client can use it without changes.
Can MCP servers have side effects like sending emails?
Yes. Tools can perform any action Python can do — API calls, file writes, database updates. But always validate inputs carefully, since the AI decides when to call tools. Consider adding confirmation prompts for destructive actions.