Skip to main content
Mag&Cie
Back to tutorials
FreeIntermediateIA

Build your own MCP server with FastMCP — connect a LLM to your business tools in Python

Step-by-step guide to build your own MCP server in Python with FastMCP: minimal skeleton, 6 realistic tools (Notion search, task upsert, list projects, page summary, email dispatch, get metrics), in-memory cache, stdio and HTTP transports with bearer auth, VPS deployment, connection to Claude Desktop and Hermes Agent. Field notes from Roger, MAG&Cie's internal Notion assistant.

July 9, 202645 min

What you'll learn

  • Understand what MCP is and why FastMCP is the best entry point in Python
  • Install FastMCP and get a minimal server running in 10 lines
  • Write 6 realistic tools with strict typing, docstrings and input validation
  • Add an in-memory cache to absorb third-party API rate limits
  • Switch from stdio transport (local) to HTTP transport with bearer auth
  • Deploy the server on a VPS with systemd and TLS via Caddy
  • Connect the server to Claude Desktop and to Hermes Agent
  • Harden for production: rate limiting, secret rotation, structured logging

Prerequisites

  • Python 3.10 or later installed
  • Basic understanding of REST APIs and secret management
  • An account on at least one source tool (Notion, Linear, GitHub, Airtable…) with an API token
  • An MCP client to test with: Claude Desktop, Hermes Agent, or a custom Python client
On this page18

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:

RoleReal example
ClientClaude Desktop, Hermes Agent, ChatGPT via connectors, a homegrown Python agent. The LLM speaks to the client.
ServerYour Python code (via FastMCP) that exposes tools, resources, prompts.
Transportstdio (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.

Bash
mkdir roger-mcp && cd roger-mcp
python -m venv .venv && source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install fastmcp

Create server.py:

Python
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:

Bash
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.

PrimitiveWhat it doesWho decidesExample
ToolAction the LLM can executeLLM decidesupsert_task, dispatch_email
ResourceContent a client can load into contextClient / user decidesREADME file, pinned Notion page
PromptReusable user-side templateUser 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…

Python
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.

Python
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.

Python
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.

Python
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:

TransportUse caseAuth
stdioLocal, single user, Claude Desktop clientNone (process isolation)
HTTPRemote, multi-user, 24/7 agentBearer token required

Same server can do both — just choose at mcp.run().

Python
# 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:

Python
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

INI
# /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:

Bash
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).

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:

JSON
{
  "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:

Bash
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:

Python
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

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.