An MCP server in Python takes about 15 lines of code. I'm not exaggerating. The FastMCP library handles the protocol layer, so you write normal Python functions and decorate them. FastMCP deals with everything else.
By the end of this walkthrough, you'll have a working server with tools, resources, and prompts that connects to both Claude Desktop and OpenClaw. Every code example here works if you paste it into a file and run it.
If you're wondering why this matters for OpenClaw specifically: MCP servers are how OpenClaw talks to anything outside itself. Databases, APIs, file systems, whatever. When your agent needs to do something beyond text generation, it reaches for an MCP server. Python happens to be the fastest way to stand one up.
What you need before starting
Python environment
You need Python 3.10 or higher. Check your version:
python --version
I'd recommend using uv instead of pip for managing your project. It handles virtual environments and dependencies without the usual headaches. But pip works fine too.
Installing FastMCP
FastMCP has 23,000+ GitHub stars and gets about a million downloads a day from PyPI. Some version of it powers roughly 70% of MCP servers across all languages, including the official MCP Python SDK (which incorporated FastMCP 1.0 in 2024).
Install it:
# With uv (recommended)
uv add fastmcp
# Or with pip
pip install fastmcp
If you want the CLI debugging tools (useful later for testing), install the official MCP package with CLI extras:
uv add "mcp[cli]"
Create a project directory. The structure is simple:
my-mcp-server/
server.py # Your server code
requirements.txt # Or pyproject.toml
That's the whole project. There's no build step and no config to mess with.
Your first MCP server: hello world
The minimal server
Create server.py:
from fastmcp import FastMCP
mcp = FastMCP("my-first-server")
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool()
def greet(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run()
That's a complete MCP server. Nine lines of actual code.
FastMCP reads your function signatures, type hints, and docstrings, then generates the MCP tool metadata automatically. The @mcp.tool() decorator (note the parentheses, they matter) registers each function as a tool that any MCP client can discover and call.
Run it:
python server.py
The server starts and listens on stdio by default. Nothing visible happens because there's no client connected yet. You need a way to test it.
Testing with MCP Inspector
The MCP Inspector is the official testing tool for MCP servers. It gives you a web UI where you can see your tools, fill in parameters, and call them manually.
npx @modelcontextprotocol/inspector python server.py
This opens a browser window with three panels: Tools, Resources, and a log. Click on the "add" tool, type 3 and 5 into the parameter fields, hit "Call," and you should see 8 come back.
If something goes wrong, the log panel at the bottom shows every JSON-RPC message between the Inspector and your server. I've debugged more issues by reading those logs than by looking at my actual Python code.
Adding tools that do real work
A calculator is fine for proving the wiring works. But the whole point of MCP servers is connecting to external systems. Here are two tools I keep coming back to: one that hits an HTTP API, and one that queries a SQLite database.
An HTTP API tool
This tool fetches weather data from Open-Meteo (a free API, no key needed). Install httpx for async HTTP requests:
uv add httpx
Then add the tool to your server:
import httpx
@mcp.tool()
async def get_weather(city: str) -> str:
"""Get current weather for a city. Returns temperature
and conditions."""
# First, geocode the city name
geo_url = "https://geocoding-api.open-meteo.com/v1/search"
async with httpx.AsyncClient() as client:
geo = await client.get(geo_url, params={"name": city, "count": 1})
results = geo.json().get("results")
if not results:
return f"Could not find city: {city}"
lat = results[0]["latitude"]
lon = results[0]["longitude"]
name = results[0]["name"]
# Fetch weather
weather_url = "https://api.open-meteo.com/v1/forecast"
weather = await client.get(weather_url, params={
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,weather_code"
})
current = weather.json()["current"]
temp = current["temperature_2m"]
return f"{name}: {temp}°C"
FastMCP handles async functions without any extra setup. The agent sends "What's the weather in Berlin?" to your tool, and it comes back with a real temperature from a live API.
A SQLite database tool
Database access is where things get interesting. This tool lets an AI agent run read-only SQL queries against a local SQLite database. (If you want something more polished, there's a full SQLite MCP server on GitHub that handles edge cases I'm skipping here.)
import sqlite3
@mcp.tool()
def query_database(sql: str) -> str:
"""Run a read-only SQL query against the local database.
Only SELECT statements are allowed."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: only SELECT queries are allowed."
conn = sqlite3.connect("data.db")
try:
cursor = conn.execute(sql)
columns = [d[0] for d in cursor.description]
rows = cursor.fetchall()
# Format as readable text
header = " | ".join(columns)
lines = [header, "-" * len(header)]
for row in rows:
lines.append(" | ".join(str(v) for v in row))
return "\n".join(lines)
except sqlite3.Error as e:
return f"SQL error: {e}"
finally:
conn.close()
The SELECT-only check is the minimum safety guard. In a real deployment, you'd want parameterized queries and a read-only database connection. But for a local dev setup with OpenClaw, this gets you started.
Be careful with database tools. By default, OpenClaw runs with no permission restrictions. If your MCP server exposes write access to a database, the agent can use it. See our OpenClaw MCP security guide for hardening advice.
Adding resources and prompts
Tools get most of the attention, but MCP has two other primitives: resources and prompts. Most servers I've seen don't use them, but they're worth knowing about.
Exposing data as resources
A resource is a chunk of data that the MCP client can pull into context, kind of like a GET endpoint. The agent doesn't call a function here. It just reads a named piece of data.
@mcp.resource("config://app-settings")
def get_app_settings() -> str:
"""Current application configuration."""
import json
with open("config.json") as f:
return json.dumps(json.load(f), indent=2)
@mcp.resource("schema://database/{table_name}")
def get_table_schema(table_name: str) -> str:
"""Get the schema for a database table."""
conn = sqlite3.connect("data.db")
cursor = conn.execute(
f"PRAGMA table_info({table_name})"
)
columns = cursor.fetchall()
conn.close()
return "\n".join(
f"{col[1]} ({col[2]})" for col in columns
)
The second resource uses a URI template ({table_name}), so the agent can request the schema for any table dynamically. When you connect this to OpenClaw, the agent looks up a table's columns before writing a SQL query, which means fewer queries that blow up because a column name was wrong.
Creating prompts
Prompts are pre-built templates that users (or the agent) can invoke with parameters. They're less common than tools, but useful for standardizing workflows.
@mcp.prompt()
def code_review(language: str, code: str) -> str:
"""Generate a code review prompt for the given code."""
return f"""Review the following {language} code.
Check for bugs, security issues, and style problems.
Be specific about line numbers.
```{language}
{code}
```"""
This gives the MCP client a structured way to request code reviews without the user having to write the prompt from scratch each time.
Connecting your server to Claude Desktop and OpenClaw
At this point you've got a server that works in the Inspector. Time to connect it to something real.
Claude Desktop configuration
Claude Desktop reads MCP server configs from a JSON file at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json
Add your server:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"]
}
}
}
Restart Claude Desktop completely (just closing the window isn't enough, you need to quit the app). Claude reads this file on startup only. After restarting, you should see your tools listed when you click the MCP icon in the chat input.
If you're using uv to manage your environment, point the command at uv instead:
{
"mcpServers": {
"my-server": {
"command": "uv",
"args": ["run", "--directory", "/absolute/path/to/project", "python", "server.py"]
}
}
}
OpenClaw configuration
OpenClaw uses openclaw.json (usually at ~/.openclaw/openclaw.json) with a similar format:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"transport": "stdio"
}
}
}
Or you can add it via the CLI:
openclaw mcp add --transport stdio my-server python /absolute/path/to/server.py
Once registered, any OpenClaw agent can discover and call your tools. The agent sees the tool names, descriptions (from your docstrings), and parameter types (from your type hints). Write good docstrings. The agent literally reads them to decide when to use each tool.
For servers that need API keys or other secrets, use environment variables in the config:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absolute/path/to/server.py"],
"env": {
"DATABASE_URL": "${DATABASE_URL}",
"API_KEY": "${MY_API_KEY}"
}
}
}
}
The ${VAR} syntax pulls values from your shell environment. Don't hardcode secrets in the config file. It'll end up in a backup or git repo eventually.
Making it production-ready
Error handling
When a tool crashes in development, you see the error in the Inspector. In production, the agent just gets a vague failure and moves on with no useful information. So handle errors yourself:
@mcp.tool()
async def fetch_data(url: str) -> str:
"""Fetch data from a URL."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.text[:5000] # Limit response size
except httpx.TimeoutException:
return "Error: request timed out after 10 seconds."
except httpx.HTTPStatusError as e:
return f"Error: HTTP {e.response.status_code}"
except Exception as e:
return f"Error: {type(e).__name__}: {str(e)}"
Return errors as strings, not exceptions. The agent can read an error message and try a different approach. An unhandled exception just kills the tool call with no useful information.
Logging
Use Python's standard logging. FastMCP respects it:
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("my-server")
@mcp.tool()
def query_database(sql: str) -> str:
logger.info(f"Executing query: {sql}")
# ... rest of the tool
Logs go to stderr by default, which stays separate from the stdio MCP protocol on stdout. When debugging OpenClaw connections, check both the OpenClaw gateway logs and your server's stderr output.
A note on authentication
If you're running your server locally over stdio (which is what the configs above do), authentication isn't needed. The MCP client and server communicate through process pipes, not a network socket.
If you switch to HTTP transport for remote access, you'd need OAuth or API key middleware on top. That's its own can of worms, and probably a future post on MCP server authentication.
Frequently asked questions
Can I use Flask or FastAPI instead of FastMCP?
You can, but you'd be writing a lot of plumbing. FastMCP handles tool discovery, JSON-RPC message framing, transport negotiation, and schema generation from your type hints. Reimplementing that in Flask or FastAPI is possible, but I'm not sure why you'd want to unless you have a very specific reason.
That said, if you already have a FastAPI app and want to add MCP support to it, FastMCP 3.0 supports mounting MCP servers inside existing ASGI apps.
How do I deploy my MCP server remotely?
For local use (connecting to Claude Desktop or a local OpenClaw instance), just run the Python script directly. For remote deployments, you'd switch from stdio to HTTP/SSE transport and host it on any container platform. Docker works well. Cloudflare Workers are another option for serverless. The details fill their own article.
Does OpenClaw support MCP natively?
OpenClaw didn't ship with native MCP support originally. Peter Steinberger (OpenClaw's creator) preferred skills as the primary extension system. MCP support was added later, and now over 65% of active OpenClaw skills wrap MCP servers underneath. You can configure MCP servers directly in openclaw.json or use mcporter to manage them.
The complete server
Here's everything from this tutorial in a single file. Copy it, swap out the placeholder tools for your own, and you have a working MCP server that connects to both Claude Desktop and OpenClaw.
from fastmcp import FastMCP
import httpx
import sqlite3
mcp = FastMCP("my-server")
# --- Tools ---
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool()
async def get_weather(city: str) -> str:
"""Get current weather for a city."""
geo_url = "https://geocoding-api.open-meteo.com/v1/search"
async with httpx.AsyncClient() as client:
geo = await client.get(geo_url, params={"name": city, "count": 1})
results = geo.json().get("results")
if not results:
return f"Could not find city: {city}"
lat = results[0]["latitude"]
lon = results[0]["longitude"]
name = results[0]["name"]
weather_url = "https://api.open-meteo.com/v1/forecast"
weather = await client.get(weather_url, params={
"latitude": lat, "longitude": lon,
"current": "temperature_2m,weather_code"
})
temp = weather.json()["current"]["temperature_2m"]
return f"{name}: {temp}°C"
@mcp.tool()
def query_database(sql: str) -> str:
"""Run a read-only SQL query. Only SELECT allowed."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: only SELECT queries are allowed."
conn = sqlite3.connect("data.db")
try:
cursor = conn.execute(sql)
columns = [d[0] for d in cursor.description]
rows = cursor.fetchall()
header = " | ".join(columns)
lines = [header, "-" * len(header)]
for row in rows:
lines.append(" | ".join(str(v) for v in row))
return "\n".join(lines)
except sqlite3.Error as e:
return f"SQL error: {e}"
finally:
conn.close()
# --- Resources ---
@mcp.resource("schema://database/{table_name}")
def get_table_schema(table_name: str) -> str:
"""Get the column schema for a database table."""
conn = sqlite3.connect("data.db")
cursor = conn.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
conn.close()
return "\n".join(f"{col[1]} ({col[2]})" for col in columns)
# --- Prompts ---
@mcp.prompt()
def code_review(language: str, code: str) -> str:
"""Generate a structured code review prompt."""
return f"Review this {language} code for bugs and security issues:\n\n```{language}\n{code}\n```"
if __name__ == "__main__":
mcp.run()
About 60 lines total. Most of that is actual logic, not protocol wiring. You could strip out the weather and database parts and replace them with whatever your OpenClaw agent actually needs: file management, GitHub API access, Slack notifications, internal APIs. Each tool is just a decorated Python function.
I'm curious what you end up building with this. The MCP servers that have surprised me most were things I never would have thought of, people connecting agents to their own weird internal tools. If you make something, let us know on X/Twitter.