FastMCP is the best entry point to write an MCP server in Python. This guide starts from zero and leaves you with a real server: 6 tools, an in-memory cache, HTTP transport with bearer auth, a VPS deployment and a verified connection to both Claude Desktop and Hermes Agent. The through-line: Roger, MAG&Cie's internal Notion assistant that feeds our Hermes agent with product and project data.
What this guide is not
This is not a general introduction to MCP (JSON-RPC specs, protocol versions, schemas). For that, the official documentation remains the reference. Here we build, we deploy, we connect — in that order.
MCP in 90 seconds
Model Context Protocol is an open standard published by Anthropic (November 2024, since adopted by OpenAI, Google and most serious LLM clients) to connect a model to data sources and tools.
Three roles:
| Role | Real example |
|---|---|
| Client | Claude Desktop, Hermes Agent, ChatGPT via connectors, a homegrown Python agent. The LLM speaks to the client. |
| Server | Your Python code (via FastMCP) that exposes tools, resources, prompts. |
| Transport | stdio (client and server in the same parent process), HTTP (client and server split by the network). |
The protocol uses JSON-RPC 2.0 over the transport. The official Anthropic SDK handles the details; FastMCP hides them behind Python decorators.
Install FastMCP and lay the skeleton
Create a project folder, a venv, install the lib.
mkdir roger-mcp && cd roger-mcp
python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install fastmcp
Create server.py:
from fastmcp import FastMCP
mcp = FastMCP("roger")
@mcp.tool()
def ping() -> str:
"""Sanity check tool: returns 'pong' if the server is alive."""
return "pong"
if __name__ == "__main__":
mcp.run()
Start it in stdio mode:
python server.py
The server waits for JSON-RPC messages on stdin. It's normal that nothing prints — it only talks to an MCP client.
Don't test by typing JSON in the terminal
JSON-RPC over stdio isn't meant to be typed by hand. To test, use Claude Desktop, or the small Python client at the end of this tutorial, or fastmcp inspect server.py (built-in helper) which lists the tools without spinning up a full client.
Tools, resources, prompts — the 3 MCP primitives not to confuse
MCP exposes three primitive kinds on the server side. Confusing them is the #1 cause of badly designed servers.
| Primitive | What it does | Who decides | Example |
|---|---|---|---|
| Tool | Action the LLM can execute | LLM decides | upsert_task, dispatch_email |
| Resource | Content a client can load into context | Client / user decides | README file, pinned Notion page |
| Prompt | Reusable user-side template | User invokes | "/brief-projet" that pre-fills a brief |
Rule of thumb: action → tool; content → resource; guided workflow → prompt. This tutorial focuses on tools (95 % of useful MCP servers). FastMCP exposes @mcp.resource() and @mcp.prompt() with the same ergonomics if you need them.
Write 6 realistic tools
An MCP tool = a typed Python function with an @mcp.tool() decorator. FastMCP generates the JSON Schema automatically from your type annotations and docstring — which the LLM reads to decide when to use the tool.
Six tools inspired by Roger. They use Notion as the source, but the pattern is identical for Linear, GitHub, Airtable, your custom CRM…
from fastmcp import FastMCP
from pydantic import BaseModel, Field
from typing import Literal
import httpx, os
NOTION_TOKEN = os.environ["NOTION_TOKEN"]
NOTION_DB_TASKS = os.environ["NOTION_DB_TASKS"]
mcp = FastMCP("roger")
client = httpx.Client(
base_url="https://api.notion.com/v1",
headers={
"Authorization": f"Bearer {NOTION_TOKEN}",
"Notion-Version": "2022-06-28",
},
timeout=15.0,
)
class TaskInput(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
status: Literal["todo", "doing", "done"] = "todo"
project: str | None = None
due: str | None = Field(default=None, description="ISO 8601 date (YYYY-MM-DD)")
@mcp.tool()
def search_notion(query: str, limit: int = 10) -> list[dict]:
"""Search across all Notion pages the integration has access to. Use for open-ended lookups where you don't know the database in advance."""
r = client.post("/search", json={"query": query, "page_size": limit})
r.raise_for_status()
return [{"id": p["id"], "title": _title(p), "url": p["url"]} for p in r.json()["results"]]
@mcp.tool()
def upsert_task(task: TaskInput) -> dict:
"""Create or update a task in the internal task database. Use when the user asks to add, plan or reschedule work."""
payload = {"parent": {"database_id": NOTION_DB_TASKS}, "properties": _task_to_props(task)}
r = client.post("/pages", json=payload)
r.raise_for_status()
return {"id": r.json()["id"], "url": r.json()["url"], "status": task.status}
@mcp.tool()
def list_projects(status: Literal["active", "archived", "all"] = "active") -> list[dict]:
"""List all internal projects with their current status. Use to discover which project a new task belongs to."""
r = client.post(f"/databases/{os.environ['NOTION_DB_PROJECTS']}/query")
r.raise_for_status()
return [{"id": p["id"], "name": _title(p), "status": _select(p, "Status")} for p in r.json()["results"]
if status == "all" or _select(p, "Status") == status]
@mcp.tool()
def summarize_page(page_id: str) -> str:
"""Fetch the raw markdown of a Notion page. Use when the user asks about the content of a specific page you already found via search_notion."""
r = client.get(f"/blocks/{page_id}/children")
r.raise_for_status()
return _blocks_to_markdown(r.json()["results"])
@mcp.tool()
def dispatch_email(to: str, subject: str, body_markdown: str) -> dict:
"""Send an email via the internal transactional mailer. Use only when the user explicitly asks to email someone — never proactively."""
# Real implementation: Postmark, Resend, or a local SMTP relay. Here: stubbed.
return {"queued": True, "to": to, "subject": subject, "size": len(body_markdown)}
@mcp.tool()
def get_metrics(period: Literal["day", "week", "month"] = "week") -> dict:
"""Return internal KPIs (MRR, active clients, open tasks) for the given period. Read-only. Cached 60 s."""
return {"period": period, "mrr_eur": 0, "active_clients": 0, "open_tasks": 0}
# Helpers _title, _select, _task_to_props, _blocks_to_markdown intentionally omitted for readability.
if __name__ == "__main__":
mcp.run()
Docstrings matter — for the LLM
The LLM doesn't see your code, it only sees the JSON Schema generated from your type annotations and docstrings. A vague docstring ("Search Notion") produces a poorly used tool. A precise one ("Search across all Notion pages the integration has access to. Use for open-ended lookups…") pushes the LLM to pick the right tool at the right time.
Add an in-memory cache (Roger notes)
The real problem. Roger calls list_projects and search_notion in a loop while a user chats with it. Notion enforces roughly 3 requests per second per integration. Without a cache, Roger trips the rate limit as soon as a second session runs in parallel.
The simple solution. A short-TTL @cached decorator (60 s) on read-only tools. No need for Redis — an in-memory dict is enough as long as you stay single-process.
import time
from functools import wraps
_cache: dict[tuple, tuple[float, object]] = {}
def cached(ttl: int = 60):
def deco(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
hit = _cache.get(key)
if hit and time.time() - hit[0] < ttl:
return hit[1]
result = fn(*args, **kwargs)
_cache[key] = (time.time(), result)
return result
return wrapper
return deco
@mcp.tool()
@cached(ttl=60)
def list_projects(status: Literal["active", "archived", "all"] = "active") -> list[dict]:
...
@mcp.tool()
@cached(ttl=60)
def search_notion(query: str, limit: int = 10) -> list[dict]:
...
Measured on Roger: 400 Notion calls/hour without cache → ~80 after. Zero functional regression — the 60 s TTL sits well below the freshness threshold we care about ("how many active projects?" doesn't need to be accurate to the second).
Never cache write tools
upsert_task, dispatch_email must NEVER be decorated with @cached — you'd risk returning a stale result instead of actually running the action. Cache = read-only only.
Error handling — exponential backoff + light circuit breaker
Two patterns to bake in before going to production:
1. Exponential backoff with jitter on tools that write (upsert_task, dispatch_email) — to absorb rate-limit spikes without failing the tool.
import random, time
import httpx
def with_backoff(fn, max_retries: int = 3):
for attempt in range(max_retries):
try:
return fn()
except httpx.HTTPStatusError as e:
if e.response.status_code not in (429, 502, 503, 504):
raise
if attempt == max_retries - 1:
raise
sleep_s = (2 ** attempt) + random.random() # 1-2s, 2-3s, 4-5s
time.sleep(sleep_s)
2. Minimal circuit breaker — if Notion is down, we stop hammering for 60 seconds instead of flooding the LLM with errors.
class Circuit:
def __init__(self, cool_down: int = 60):
self.failures = 0
self.opened_at: float | None = None
self.cool_down = cool_down
def check(self):
if self.opened_at and time.time() - self.opened_at < self.cool_down:
raise RuntimeError("Notion currently degraded — try again shortly.")
if self.opened_at and time.time() - self.opened_at >= self.cool_down:
self.opened_at = None
self.failures = 0
def record_failure(self):
self.failures += 1
if self.failures >= 5:
self.opened_at = time.time()
notion_circuit = Circuit()
Reuse across every tool that talks to Notion. This layer is intentionally simple: for most TPE/PME cases a lib like tenacity or pybreaker is heavier than the need.
Move from stdio to HTTP transport
Two transports to pick from, based on the use case:
| Transport | Use case | Auth |
|---|---|---|
| stdio | Local, single user, Claude Desktop client | None (process isolation) |
| HTTP | Remote, multi-user, 24/7 agent | Bearer token required |
Same server can do both — just choose at mcp.run().
# stdio (default)
mcp.run()
# HTTP on port 8000
mcp.run(transport="http", host="0.0.0.0", port=8000)
For bearer auth, FastMCP ships a simple middleware:
from fastmcp.server.auth import BearerAuth
expected_token = os.environ["MCP_BEARER_TOKEN"]
mcp = FastMCP("roger", auth=BearerAuth(token=expected_token))
Any HTTP call without the Authorization: Bearer <token> header is rejected with a 401 before touching a tool.
Deploy on a VPS with systemd
Direct answer. A 5 €/month VPS (Hetzner CX22, OVH Kimsufi, Scaleway Stardust) is enough — the MCP server isn't hungry as long as the LLM runs on the client side.
Hardened systemd unit
# /etc/systemd/system/roger-mcp.service
[Unit]
Description=Roger MCP server
After=network.target
[Service]
Type=simple
User=roger
WorkingDirectory=/opt/roger-mcp
EnvironmentFile=/etc/roger-mcp.env # NOTION_TOKEN, MCP_BEARER_TOKEN, etc.
ExecStart=/opt/roger-mcp/.venv/bin/python server.py
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/roger-mcp/data
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Enable it:
sudo systemctl enable --now roger-mcp
sudo systemctl status roger-mcp
TLS via Caddy (2 lines)
mcp.mag-cie.com {
reverse_proxy 127.0.0.1:8000
}
Caddy fetches a Let's Encrypt certificate automatically and renews it. Add a Cloudflare Tunnel if you don't want to expose a public IP.
Connect to Claude Desktop and Hermes Agent
Claude Desktop (stdio)
Edit claude_desktop_config.json (on macOS: ~/Library/Application Support/Claude/claude_desktop_config.json; on Windows: %APPDATA%\Claude\claude_desktop_config.json).
{
"mcpServers": {
"roger": {
"command": "/opt/roger-mcp/.venv/bin/python",
"args": ["/opt/roger-mcp/server.py"],
"env": {
"NOTION_TOKEN": "secret_...",
"NOTION_DB_TASKS": "..."
}
}
}
}
Restart Claude Desktop. Check the MCP icon at the bottom right — the 6 tools should appear.
Claude Desktop remote (via mcp-proxy)
Claude Desktop only speaks stdio natively. To reach a remote HTTP server, use mcp-proxy as a stdio ↔ HTTP bridge:
{
"mcpServers": {
"roger-remote": {
"command": "mcp-proxy",
"args": [
"https://mcp.mag-cie.com/",
"--headers", "Authorization=Bearer eyJhbGc..."
]
}
}
}
Hermes Agent (native HTTP)
Hermes handles MCP HTTP natively:
hermes mcp add roger \
--url https://mcp.mag-cie.com/ \
--header "Authorization: Bearer eyJhbGc..."
hermes mcp list # should list roger and its 6 tools
Harden for production
Per-consumer rate limiting
On multi-user HTTP, add per-token rate limiting (1 req/sec by default). A LLM in a loop can pull 50 requests/second without flinching — it's you paying the Notion bill.
Manual secret rotation
Automating rotation adds complexity without much gain. Instead, a quarterly calendar reminder to re-issue MCP_BEARER_TOKEN and the integration's NOTION_TOKEN is a sensible trade-off.
Structured logs with correlation
A tool that surfaces a Notion error without a correlation ID leaves you blind. Add a request_id (uuid4) at the start of every call, propagate it in logs and in the error message returned to the LLM.
Automated integration tests
A small Python client (~30 lines) is enough to validate the 6 tools in CI:
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
import asyncio
async def main():
params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
assert {t.name for t in tools.tools} >= {
"ping", "search_notion", "upsert_task",
"list_projects", "summarize_page", "get_metrics",
}
r = await session.call_tool("ping", {})
assert r.content[0].text == "pong"
print("OK — MCP server ready")
asyncio.run(main())
Add this to your CI pipeline and you have a permanent guard against regressions.
Recap
- Functional MCP server with 6 real tools
- In-memory cache absorbing Notion rate limits
- Two transports: stdio (local) and HTTP + bearer (remote)
- VPS deployment with systemd hardening and Caddy TLS
- Verified connection to Claude Desktop (stdio + mcp-proxy) and Hermes Agent (native HTTP)
- Production hardening: rate limit, quarterly rotation, correlated logs, CI tests
Go further
- Official MCP documentation
- FastMCP on GitHub
- Companion guide: Install and build your AI agent with Hermes Agent — to plug your new MCP server into an autonomous agent reachable from Telegram.
Need a custom MCP for your stack?
MAG&Cie designs bespoke MCP servers on top of your business tools (Notion, Airtable, HubSpot, PostgreSQL, your internal API). See our custom MCP offer — first 30 minutes of scoping are on us.