MCP-Server in Python entwickeln: Vom leeren Repo zum ersten Tool
FastMCP, stdio & HTTP/SSE, Tool-Definitionen, Claude Code Integration
Warum ein eigener MCP-Server?
Fertige MCP-Server gibt es für Browser-Steuerung, Git, Datenbanken oder sogar Home Assistant. Wer aber eine eigene API, ein internes CMS oder ein Homelab-Setup hat, kommt schnell an ihre Grenzen: Die Tools passen nicht, die Auth fehlt, oder die Datenstruktur ist zu generisch.
Ein eigener MCP-Server löst das. Du definierst genau die Tools, die du brauchst – und Claude (oder jeder andere MCP-fähige Client) kann sie direkt aufrufen.
Dieser Artikel zeigt den Weg von Null bis zu einem funktionierenden Server in zwei Stufen:
- Stufe 1: Lokaler Server mit stdio-Transport, ideal für Claude Code
- Stufe 2: Remote-Server mit HTTP/SSE, für Production-Setups und mehrere Clients
Basis für die Beispiele ist der devmaker-MCP-Server, der diese Website steuert.
Setup
Du brauchst Python 3.10+ und fastmcp. FastMCP ist der De-facto-Standard für Python-MCP-Server – es abstrahiert das Low-Level-Protokoll und lässt dich direkt mit Tool-Definitionen starten.
pip install fastmcp
# oder mit uv (empfohlen)
uv add fastmcp
Für async-Tools mit HTTP-Calls:
uv add fastmcp httpx
Stufe 1: stdio-Transport (Claude Code lokal)
stdio ist der einfachste Einstieg: Claude Code startet den Server-Prozess direkt und kommuniziert über stdin/stdout – kein Port, keine Auth. Ideal für lokale Entwicklung und persönliche Tools.
Hello World
# server.py
from fastmcp import FastMCP
mcp = FastMCP("my-server")
@mcp.tool()
def hello(name: str) -> str:
"""Say hello to someone."""
return f"Hello, {name}!"
if __name__ == "__main__":
mcp.run()
Das war's. @mcp.tool() registriert die Funktion als MCP-Tool. Der Docstring ist entscheidend – das ist exakt das, was Claude liest, um zu verstehen wofür das Tool da ist.
Integration in Claude Code
In ~/.claude.json oder in der Projekt-Config .claude/settings.json:
{
"mcpServers": {
"my-server": {
"command": "python",
"args": ["/absoluter/pfad/zu/server.py"]
}
}
}
Mit uv:
{
"mcpServers": {
"my-server": {
"command": "uv",
"args": ["run", "/absoluter/pfad/zu/server.py"]
}
}
}
Nach /mcp in Claude Code sollte der Server erscheinen. Wenn nicht: claude --mcp-debug zeigt stderr des Server-Prozesses.
Echtes Tool mit Parameter-Validierung
FastMCP nutzt Pydantic automatisch für alle Parameter. Type Hints sind Pflicht – ohne sie hat Claude keinen Kontext für die Eingaben.
from fastmcp import FastMCP
from pydantic import Field
from typing import Optional
import httpx
mcp = FastMCP("devmaker")
@mcp.tool()
async def get_articles(
limit: int = Field(default=10, ge=1, le=100, description="Anzahl Artikel"),
topic: Optional[str] = Field(default=None, description="Topic-Slug, z.B. 'linux'")
) -> str:
"""Artikel aus dem CMS abrufen. Gibt Titel, ID und URL zurück."""
params = {"limit": limit}
if topic:
params["topic"] = topic
async with httpx.AsyncClient() as client:
resp = await client.get("http://localhost:8000/api/articles/", params=params)
resp.raise_for_status()
data = resp.json()
lines = [f"- [{a['title']}]({a['url']}) (ID {a['id']})" for a in data["results"]]
return "\n".join(lines) or "Keine Artikel gefunden."
Wichtige Punkte:
async defsobald du externe Calls machst- Field-Constraints (
ge,le) werden als JSON Schema an Claude übergeben - Rückgabe als lesbarer String, nicht als JSON-Dump
Fehlerbehandlung
@mcp.tool()
async def publish_article(page_id: int) -> str:
"""Artikel veröffentlichen. Gibt URL zurück oder Fehlermeldung."""
try:
async with httpx.AsyncClient() as client:
resp = await client.post(f"http://localhost:8000/api/pages/{page_id}/publish/")
resp.raise_for_status()
return f"Veröffentlicht: {resp.json()['url']}"
except httpx.HTTPStatusError as e:
return f"Fehler {e.response.status_code}: {e.response.text}"
Nicht mit Exceptions werfen – Claude bekommt dann eine kryptische MCP-Fehlermeldung. Lieber sprechende Strings zurückgeben.
Stufe 2: HTTP/SSE-Transport (Remote, Production)
stdio funktioniert nur lokal. Sobald der Server auf einem anderen Host läuft – oder mehrere Clients gleichzeitig zugreifen sollen – brauchst du HTTP mit Server-Sent Events (SSE).
Wann HTTP/SSE?
- Der Server läuft auf einem Homelab-Host, nicht auf dem Laptop
- Claude API (nicht Claude Code) soll den Server nutzen
- Mehrere Nutzer oder Clients gleichzeitig
- Auth und Rate-Limiting nötig
Server auf HTTP/SSE umstellen
Der Tool-Code bleibt identisch. Nur der Start ändert sich:
# server.py
from fastmcp import FastMCP
import uvicorn
mcp = FastMCP("devmaker", stateless_http=True)
# ... Tool-Definitionen wie bisher ...
if __name__ == "__main__":
uvicorn.run(mcp.http_app(), host="0.0.0.0", port=8080)
stateless_http=True ist für die meisten Use Cases richtig – kein Session-State, jeder Request ist unabhängig.
Docker Compose
services:
mcp-server:
build: .
ports:
- "8080:8080"
environment:
- API_BASE_URL=${API_BASE_URL}
- API_TOKEN=${API_TOKEN}
restart: unless-stopped
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY server.py .
CMD ["python", "server.py"]
Claude Code mit Remote-Server
{
"mcpServers": {
"devmaker-remote": {
"type": "http",
"url": "http://mein-server.intern:8080/mcp"
}
}
}
Mit HTTPS und Auth-Token:
{
"mcpServers": {
"devmaker-remote": {
"type": "http",
"url": "https://mcp.devmaker.net/mcp",
"headers": {
"Authorization": "Bearer ${MCP_TOKEN}"
}
}
}
}
Ressourcen (Resources)
Neben Tools gibt es Resources – schreibgeschützte Datenquellen, die Claude bei Bedarf lesen kann, ohne dass du explizit ein Tool aufrufst.
import json
@mcp.resource("config://server-info")
def server_info() -> str:
"""Gibt Server-Konfiguration und verfügbare Topics zurück."""
return json.dumps({
"version": "1.0",
"topics": ["linux", "homelab", "home-automation", "elektronik", "3d-druck"],
"max_articles_per_request": 50
}, ensure_ascii=False)
Auth: Bearer Token
from fastmcp import FastMCP
from fastmcp.server.auth import BearerAuthProvider
import os
mcp = FastMCP("devmaker", stateless_http=True)
# FastMCP >= 2.x: auth via server-Parameter
mcp_with_auth = FastMCP(
"devmaker",
stateless_http=True,
auth=BearerAuthProvider(token=os.environ["MCP_TOKEN"])
)
Kein Token, kein Zugriff. Reicht für Homelab-Setups. Die genaue API variiert je nach FastMCP-Version – fastmcp --version und Changelog prüfen.
Lessons Learned
Docstrings sind dein API-Vertrag. Claude liest den Docstring und entscheidet, ob und wie es das Tool aufruft. Schlecht beschriebene Tools werden ignoriert oder falsch genutzt.
stdio zuerst, HTTP wenn nötig. Der Schritt von stdio zu HTTP klingt klein, ist aber operativ aufwändiger (Docker, Reverse Proxy, Auth). stdio reicht für 90% der persönlichen Setups.
Rückgabe als Text, nicht JSON. Claude kann JSON parsen, aber ein lesbarer String ist direkter.
Ein Server, zwei Transporte. Du kannst denselben FastMCP-Server als stdio und als HTTP betreiben. Nur der Einstiegspunkt ändert sich:
if __name__ == "__main__":
import sys
if "--http" in sys.argv:
import uvicorn
uvicorn.run(mcp.http_app(), host="0.0.0.0", port=8080)
else:
mcp.run() # stdio
Tool-Granularität: Lieber mehrere kleine Tools als ein Mega-Tool mit 15 Parametern. Claude wählt gezielter, wenn Tools eng gefasst sind.
Fazit
Ein eigener MCP-Server in Python ist kein Hexenwerk. FastMCP nimmt den Protokoll-Overhead weg – du schreibst Python-Funktionen, dekorierst sie mit @mcp.tool(), und Claude kann sie sofort nutzen.
Der devmaker-MCP-Server, der diese Website steuert, ist nach diesem Prinzip aufgebaut: ~40 Tools, stdio lokal für Entwicklung, HTTP für den Produktiv-Server. Die Gesamtkomplexität ist überschaubar – der größte Aufwand liegt im Schreiben guter Docstrings und klarer Tool-Grenzen.