MCP-Server mit Python II: stdio vs. HTTP in der Praxis
Wann welcher Transport und was das für die Architektur bedeutet
Nach meinem ersten MCP-Server (Teil 1) kam schnell die Frage auf: Warum eigentlich stdio? Die meisten Web-Tutorials zeigen HTTP. Was ist der Unterschied, und wann nimmt man was?
Ich habe beide Varianten gebaut und einiges dabei gelernt.
Kurze Wiederholung: Was macht ein MCP-Server?
Ein MCP-Server stellt Tools bereit, die ein LLM aufrufen kann. Dateien lesen, Befehle ausführen, APIs abfragen – was auch immer man braucht. Die Frage ist nur: Wie kommuniziert der Server mit dem Client?
stdio: Der direkte Draht
Bei stdio startet Claude Desktop den Server als Subprocess. Die Kommunikation läuft über Standard-Input und Standard-Output – wie bei einem klassischen Unix-Tool.
from mcp.server.stdio import stdio_server
async def main():
async with stdio_server() as (read, write):
await mcp.run(read, write)
Vorteile:
- Kein Netzwerk, kein Port, keine Firewall-Probleme
- Server läuft nur wenn Claude Desktop läuft
- Einfaches Debugging (print geht nach stderr)
Nachteile:
- Nur ein Client gleichzeitig
- Server muss auf demselben Rechner laufen
- Kein Zugriff von außen
Für meinen Anwendungsfall – lokale Entwicklung auf meinem Rechner – ist stdio perfekt.
HTTP: Der Netzwerk-Weg
Bei HTTP läuft der Server als eigenständiger Dienst und wartet auf Anfragen. Das ist flexibler, aber auch aufwändiger.
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
transport = SseServerTransport("/messages")
async def handle_sse(request):
async with transport.connect_sse(
request.scope, request.receive, request._send
) as streams:
await mcp.run(
streams[0], streams[1], mcp.create_initialization_options()
)
app = Starlette(routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=transport.handle_post_message, methods=["POST"]),
])
Das ist deutlich mehr Boilerplate. Man braucht einen ASGI-Server (uvicorn), muss sich um CORS kümmern, und der Server läuft dauerhaft.
Vorteile:
- Mehrere Clients gleichzeitig möglich
- Server kann auf anderem Rechner laufen
- Integration in bestehende Web-Infrastruktur
Nachteile:
- Mehr Setup (Port, evtl. SSL, Firewall)
- Server muss separat gestartet werden
- Debugging ist umständlicher
Mein Setup: Beides
Nach einigem Hin und Her habe ich mich für einen hybriden Ansatz entschieden. Derselbe Server-Code, aber zwei Startmodi:
# server.py
import sys
from mcp.server import Server
mcp = Server("workstation")
# ... Tool-Definitionen ...
async def run_stdio():
from mcp.server.stdio import stdio_server
async with stdio_server() as (read, write):
await mcp.run(read, write)
async def run_http(port: int = 8080):
import uvicorn
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
transport = SseServerTransport("/messages")
async def handle_sse(request):
async with transport.connect_sse(
request.scope, request.receive, request._send
) as streams:
await mcp.run(streams[0], streams[1],
mcp.create_initialization_options())
app = Starlette(routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=transport.handle_post_message,
methods=["POST"]),
])
config = uvicorn.Config(app, host="127.0.0.1", port=port)
server = uvicorn.Server(config)
await server.serve()
if __name__ == "__main__":
import asyncio
if "--http" in sys.argv:
port = 8080
if "--port" in sys.argv:
idx = sys.argv.index("--port")
port = int(sys.argv[idx + 1])
asyncio.run(run_http(port))
else:
asyncio.run(run_stdio())
Aufruf für Claude Desktop (stdio):
python server.py
Aufruf als HTTP-Server:
python server.py --http --port 8080
Wann HTTP Sinn macht
Ich nutze HTTP in zwei Szenarien:
1. Entwicklung und Debugging
Wenn ich am Server selbst arbeite, ist HTTP praktischer. Ich kann den Server in einem Terminal laufen lassen, Logs beobachten, und mit curl testen:
# Server starten
python server.py --http --port 8080
# In einem anderen Terminal testen
curl -X POST http://localhost:8080/messages \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_dir","arguments":{"path":"."}},"id":1}'
Das geht mit stdio nicht so einfach.
2. Zugriff vom Homelab
Ich habe einen kleinen Server im Keller (ein GEEKOM NUC), der rund um die Uhr läuft. Dort laufen Docker-Container, Datenbanken, verschiedene Dienste. Mit einem HTTP-MCP-Server auf dem NUC kann ich von meinem Arbeitsrechner aus damit interagieren.
Die Config in Claude Desktop sieht dann anders aus:
{
"mcpServers": {
"homelab": {
"command": "npx",
"args": [
"-y", "@anthropic/mcp-remote-client",
"http://192.168.1.50:8080/sse"
]
}
}
}
Der mcp-remote-client ist ein Wrapper, der HTTP zu stdio übersetzt – so kann Claude Desktop mit einem Remote-Server sprechen.
Tools, die remote Sinn machen
Auf meinem Homelab-Server habe ich andere Tools als lokal:
Docker-Container steuern
@mcp.tool()
def docker_ps() -> str:
"""Listet laufende Docker-Container."""
result = subprocess.run(
["docker", "ps", "--format", "table {{.Names}}\t{{.Status}}\t{{.Ports}}"],
capture_output=True, text=True
)
return result.stdout
@mcp.tool()
def docker_logs(container: str, lines: int = 50) -> str:
"""Zeigt die letzten Log-Zeilen eines Containers."""
result = subprocess.run(
["docker", "logs", "--tail", str(lines), container],
capture_output=True, text=True
)
return result.stdout + result.stderr
Systemstatus abfragen
@mcp.tool()
def system_status() -> str:
"""Zeigt CPU, RAM und Disk-Nutzung."""
import psutil
cpu = psutil.cpu_percent(interval=1)
ram = psutil.virtual_memory()
disk = psutil.disk_usage('/')
return f"""CPU: {cpu}%
RAM: {ram.percent}% ({ram.used // 1024**3}GB / {ram.total // 1024**3}GB)
Disk: {disk.percent}% ({disk.used // 1024**3}GB / {disk.total // 1024**3}GB)"""
Datenbank-Abfragen
import sqlite3
@mcp.tool()
def query_db(sql: str, db_path: str = "/data/app.db") -> str:
"""Führt eine SELECT-Abfrage aus. Nur SELECT erlaubt."""
if not sql.strip().upper().startswith("SELECT"):
return "Fehler: Nur SELECT-Abfragen erlaubt"
conn = sqlite3.connect(db_path)
try:
cursor = conn.execute(sql)
rows = cursor.fetchall()
if not rows:
return "(keine Ergebnisse)"
# Einfache Tabellenformatierung
headers = [desc[0] for desc in cursor.description]
result = "\t".join(headers) + "\n"
result += "\n".join("\t".join(str(cell) for cell in row) for row in rows)
return result
finally:
conn.close()
Sicherheit bei HTTP
Bei stdio ist Sicherheit relativ einfach – der Server läuft unter meinem User und hat dessen Rechte. Bei HTTP wird es komplizierter:
- Nur localhost oder LAN – niemals ins Internet exponieren
- Authentifizierung – zumindest ein API-Key im Header
- TLS – wenn’s über’s Netzwerk geht, dann verschlüsselt
Mein Homelab-Server lauscht nur auf der internen IP und ist per Firewall abgeschottet. Für einen produktiven Einsatz müsste man mehr tun.
stdio für lokal, HTTP für alles andere
Nach ein paar Wochen mit beiden Varianten:
- stdio für die tägliche Arbeit auf meinem Rechner – einfach, robust, keine Konfiguration
- HTTP für Remote-Zugriff und Debugging – flexibler, aber mehr Aufwand
Die Möglichkeit, beides im selben Server zu haben, war eine gute Entscheidung. Je nach Situation starte ich ihn anders.
Nächste Schritte
Was mich noch interessiert:
- WebSocket statt SSE – angeblich performanter für viele Requests
- Mehrere Server kombinieren – ein “Router”, der Anfragen an spezialisierte Server verteilt
- Browser-Integration – MCP-Server, der mit Playwright Webseiten steuert
Aber das ist Stoff für einen dritten Teil.
Disclaimer: Der Code in diesem Artikel ist ein Proof of Concept – funktioniert bei mir, aber keine Garantie. Wer MCP-Server im Netzwerk exponiert, sollte wissen was er tut.
GitHub: cuber-it/mcp_shell_tools
Aktualisiert: 2025-01-06
Teil der Serie MCP-Experimente · Nächster Teil: Playwright-Integration