KIMCPPythonAutomatisierungClaude

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