Startseite / Artikel

Führen Sie MCP-Tools als stateless-Dienste nach der Spezifikation vom 2026-07-28 aus.

Abbrechen von Sticky Sessions: MCP 2026-07-28 macht Anfragen selbstständig, indem es Header-Routing, Wiederholungsversuche bei input_required sowie gemeinsame requestState-Schlüssel zwischen den Replikas verwendet.

Verlag: REACTAPP.TOP8 Min. Lesezeit1843 Wörter

Quellen: MCP Just Went Stateless: Run Your Agent Tools Like Real Services

Warum Sessions ein Produktionsproblem darstellten

Die Verwaltung von MCP hinter einem Load Balancer erforderte früher Session-Affinität, wodurch jeder Client an einen einzigen Pod gebunden wurde, und man akzeptierte, dass herkömmliche Rollouts Agenten mitten in einer Aufgabe unterbrechen konnten. Die MCP 2026–07–28-Version streicht Sessions auf Protokollebene vollständig. Die Betreuer bezeichneten dies als den größten Wandel seit Einführung der Autorisierungsfunktion, und für Teams, die Agent-Tools in großem Umfang einsetzen, ist dies der wichtigste Wandel überhaupt.

Die nachstehende Anleitung erläutert diesen Wandel und erstellt anschließend einen kleinen, statelessen Refund-Server mit dem offiziellen Python SDK v2, einschließlich eines Schritts zur menschlichen Freigabe, der auch bei Replikationswechseln erhalten bleibt. Die Beispiele wurden mit mcp==2.2.0 getestet.

MCP begann als Brücke zwischen Desktop- und lokalen Tools über stdio, wo ein lang lebender, zustandsbehafteter Socket akzeptabel war. Auf Kubernetes, Cloud Run und anderen Mehr-Instanz-Plattformen wurde diese Architektur jedoch teuer:

  • Klebendes Routing. Die initialize-Handshake-Phase erzeugte eine Mcp-Session-Id, wodurch spätere Aufrufe unbedingt auf denselben Prozess gerichtet werden mussten.
  • Brüchige Bereitstellungen. Das Neustarten eines Pods löschte alle aktiven Sessions darauf.
  • Undurchsichtige Gateways. Rate Limits sowie Routing bei WAF- oder API-Gateways erforderten die Analyse von JSON-RPC-Datenkörpern.
  • Aufrechterhaltene Streams. Wenn ein Tool mitten im Aufruf eine menschliche Antwort benötigte, hielt der Server den Stream geöffnet und wartete.

Diese zustandsbehaftete Struktur eignete sich für lokale Programmier-Assistenten. Unternehmen benötigten hingegen einen normalen Produktivzugang mit zentralisierten Richtlinien.

Spezifikationsänderungen vom 2026-07-28

1. Handshake- und Sitzungsidentifikatoren sind entfernt. Die zusammengehörenden Schritte initialize / initialized sowie der Header Mcp-Session-Id existieren nicht mehr. Jeder Anfrage werden Version, Client-ID und Fähigkeiten innerhalb von params._meta mitgeliefert. Jede Replikation hinter einem einfachen Round-Robin-Load-Balancing kann jeden Aufruf entgegennehmen.

2. Die Routing-Funktion befindet sich in den HTTP-Headern. Streamfähiger HTTP-Traffic muss Mcp-Method und Mcp-Name enthalten. Gateways können Aufrufe an Tools routen, messen und blockieren, ohne den Anfrageinhalt öffnen zu müssen.

3. Mehrfache Hin- und Rückrufanfragen (MRTR). Anstatt einen offenen Stream für eine menschliche Antwort zu pausieren, antwortet der Server mit resultType: „input_required“, den Anfragen sowie einem unklaren requestState-Blob. Der Client sammelt die Antworten ein und versucht den ursprünglichen Aufruf erneut mit inputResponses. Dieses Muster ersetzt die vom Server gesteuerten elicitation/create, sampling/createMessage sowie roots/list-Aufrufe während des Vorgangs.

4. Cacheierbare Listen. Antworten für tools/list, prompts/list, resources/list und resources/read können ttlMs sowie cacheScope angeben, sodass die Clients keine wiederholten Anfragen an die Listendpunkte senden und die Caches der LLM-Anfragen stabiler bleiben.

5. Striktere Autorisierung. Es sind Überprüfungen gemäß RFC 9207 erforderlich. Die dynamische Client-Registrierung wird durch Metadokumente für die Client-ID ersetzt.

6. Erweiterungen. Langlaufende Aufgaben belassen das experimentelle Kernmodul für die Erweiterung io.modelcontextprotocol/tasks. MCP-Apps sowie Enterprise Managed Authorization werden ebenfalls zu offiziellen Erweiterungen.

7. Veraltete Funktionen mit Übergangsfrist. Roots, Sampling, das Logging-RPC sowie das veraltete HTTP+SSE bleiben mindestens zwölf Monate lang verfügbar, während die Teams migrieren.

Es wird nicht garantiert, dass neue Server mit alten Clients funktionieren. Erstatten Sie beide Seiten gleichzeitig auf, wenn Sie die Kontrolle darüber haben. Bei benutzerdefinierten Protokollimplementierungen ist im Vergleich zu SDK-Nutzern eine erheblich größere Anpassung erforderlich.

Praxisbeispiel: Ein stateless MCP-Server in Python

Bauen Sie mit zwei Tools einen kleinen Rückerstattungs-Dienst auf. get_order sucht nach einer Bestellung. refund_order ist sensibel, weshalb eine menschliche Überprüfung erforderlich ist, bevor Geld überwiesen wird. Diese Frage während des Anrufs erforderte früher ausdrücklich eine enge Verbindung zwischen den Beteiligten.

Installieren Sie das v2 SDK und erstellen Sie ein gemeinsames Verschlüsselungssecret:

pip install "mcp[cli]>=2.2.0"
export MCP_STATE_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")

Der Server

# server.py — a stateless MCP server (spec 2026-07-28, Python SDK v2)
import os
from typing import Annotated

from pydantic import BaseModel

from mcp.server import MCPServer
from mcp.server.caching import CacheHint
from mcp.server.mcpserver import Elicit, RequestStateSecurity, Resolve

# Tell clients they may cache tools/list for 1 hour and share it across users.
mcp = MCPServer(
    "refunds",
    cache_hints={"tools/list": CacheHint(ttl_ms=3_600_000, scope="public")},
    # Every replica must share this key, or a retry that lands on another pod fails.
    request_state_security=RequestStateSecurity(keys=[os.environ["MCP_STATE_KEY"]]),
)

ORDERS = {"A-1001": 49.99, "A-1002": 1299.00}  # stand-in for your real database


class Approval(BaseModel):
    approve: bool
    reason: str = ""


def ask_for_approval(order_id: str) -> Elicit[Approval]:
    """Resolver: ask a human to confirm the refund before the tool runs."""
    amount = ORDERS[order_id]
    return Elicit(f"Refund ${amount:,.2f} for order {order_id}?", Approval)


@mcp.tool()
def get_order(order_id: str) -> dict:
    """Look up an order's refundable amount."""
    return {"order_id": order_id, "amount": ORDERS.get(order_id)}


@mcp.tool()
def refund_order(
    order_id: str,
    approval: Annotated[Approval, Resolve(ask_for_approval)],
) -> str:
    """Refund an order after a human approves it."""
    if not approval.approve:
        return f"Refund for {order_id} rejected: {approval.reason or 'no reason given'}"
    return f"Refunded ${ORDERS[order_id]:,.2f} for {order_id}"


if __name__ == "__main__":
    # No session state: any replica behind a round-robin load balancer can answer.
    mcp.run(transport="streamable-http", host="0.0.0.0", port=8000,
            stateless_http=True, json_response=True)

Drei Details sind wichtig:

  • Resolve zusammen mit Elicit stellt den Abhängigkeits-injektions-Stil von v2 dar. Der Parameter approval wird nicht vom Modell ausgefüllt; das SDK ruft zunächst ask_for_approval auf und wandelt für Clients vom 28.07.2026 die Anfrage in ein input_required-Ergebnis um. Ältere Clients greifen auf die klassische Abfrage während des Anrufs zurück.
  • cache_hints fügt ttlMs / cacheScope zu tools/list hinzu.
  • stateless_http=True in Kombination mit json_response=True liefert pro POST eine JSON-Antwort – es wird weder ein Session-Objekt noch ein laufender Stream verwendet.
  • Der Client

    # client.py — call the server; the SDK handles the input_required retry loop
    import asyncio
    
    from mcp import Client
    from mcp.types import ElicitResult
    
    
    async def on_elicit(context, params):
        # In a real app, render params.message in your UI and collect the answer.
        print(f"[server asks] {params.message}")
        return ElicitResult(action="accept", content={"approve": True})
    
    
    async def main():
        async with Client("http://localhost:8000/mcp", elicitation_callback=on_elicit) as client:
            tools = await client.list_tools()
            print("tools:", [t.name for t in tools.tools], "| ttlMs:", tools.ttl_ms)
    
            result = await client.call_tool("refund_order", {"order_id": "A-1002"})
            print(result.content[0].text)
    
    
    asyncio.run(main())
    

    Eine typische Ausführung gibt etwas wie Folgendes aus:

    tools: ['get_order', 'refund_order'] | ttlMs: 3600000
    [server asks] Refund $1,299.00 for order A-1002?
    Refunded $1,299.00 for A-1002
    

    Was tatsächlich über das Netzwerk gesendet wird

    Ein erster Aufruf mit curl zeigt die Routing-Header sowie das _meta-Envelope, das den alten Handshake ersetzt:

    curl -s -X POST http://localhost:8000/mcp \
      -H 'Content-Type: application/json' \
      -H 'Accept: application/json, text/event-stream' \
      -H 'MCP-Protocol-Version: 2026-07-28' \
      -H 'Mcp-Method: tools/call' \
      -H 'Mcp-Name: refund_order' \
      -d '{
        "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": {
          "name": "refund_order",
          "arguments": {"order_id": "A-1002"},
          "_meta": {
            "io.modelcontextprotocol/protocolVersion": "2026-07-28",
            "io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0"},
            "io.modelcontextprotocol/clientCapabilities": {"elicitation": {"form": {}}}
          }
        }
      }'
    

    Der Server antwortet sofort anstelle davon, den Vorgang zu blockieren:

    {
      "resultType": "input_required",
      "inputRequests": {
        "__main__:ask_for_approval": {
          "method": "elicitation/create",
          "params": {
            "message": "Refund $1,299.00 for order A-1002?",
            "mode": "form",
            "requestedSchema": {
              "type": "object",
              "properties": {"approve": {"type": "boolean"}, "reason": {"type": "string"}},
              "required": ["approve"]
            }
          }
        }
      },
      "requestState": "v1.bOKJzdeMdGCz-ezv..."
    }
    

    Sobald ein Mensch antwortet, versucht der Client erneut den gleichen tools/call und fügt die Antworten zusammen mit dem unklaren Zustandsblob hinzu:

    "inputResponses": {
      "__main__:ask_for_approval": {"action": "accept", "content": {"approve": true}}
    },
    "requestState": "v1.bOKJzdeMdGCz-ezv..."
    

    Die nachfolgende Anfrage gibt resultType: „complete“ zurück, zusammen mit einem Text wie Refunded $1,299.00 for A-1002. Bei diesen beiden POST-Anfragen wurde kein Prozess das gesprochene Gedächtnis lokal aufbewahrt; das undurchsichtige Feld requestState enthielt alle für die Fortsetzung erforderlichen Informationen.

    Das Problem in der Produktion: requestState-Schlüssel

    Um diese Behauptung zu beweisen, starten Sie Replikate auf 8000 und 8001, richten die erste Anfrage an A und die nachfolgende an B. Wenn sich die Verschlüsselungsmethoden unterscheiden, lehnt der zweite Schritt den State-Blob ab:

    {"code": -32602, "message": "Invalid or expired requestState",
     "data": {"reason": "invalid_request_state"}}
    

    Dieser Fehler ist absichtlich. Die Verschlüsselung verwendet innerhalb des SDK AES-256-GCM, sodass Aufrufer den Blob nicht fälschen oder bearbeiten können. Jeder Prozess erstellt eine private Schlüssel, es sei denn, es ist anders konfiguriert – das bedeutet, dass Replica B einen auf A verschlossenen Blob nicht entschlüsseln kann. Konfigurieren Sie RequestStateSecurity(keys=[...]) und laden Sie einen geheimen Schlüssel von mindestens 32 Bytes aus Ihrem Secret Manager in jedes Replica. Mit einem gemeinsamen Schlüssel gelingt die Wiederholung über verschiedene Replicas.

    Das SDK unterstützt außerdem einen fehlerfreien Schlüsselwechsel. Der erste Schlüssel in der Liste verschlüsselt den neuen Zustand; jeder aufgelistete Schlüssel kann ihn entschlüsseln. Senden Sie zunächst eine Liste mit zwei Schlüsseln – den alten geheimen Schlüssel und den Ersatzschlüssel – wechseln Sie bei der nächsten Veröffentlichung die Reihenfolge und entfernen Sie schließlich den alten Eintrag, sobald eine volle TTL-Zeit (600 Sekunden, sofern nicht anders festgelegt) vergangen ist. Verwenden Sie codec=, wenn Sie Ihren eigenen KMS statt roher Schlüssel bevorzugen.

    Produktions-Checkliste

    • Führen Sie den Betrieb mit stateless_http=True durch und entfernen Sie die Regeln für sticky Sessions vom Load Balancer.
    • Teilen Sie den Schlüssel requestState zwischen den Replicas und planen Sie deren Rotation.
    • Bewahren Sie den dauerhaften Geschäftsstatus in Ihrem eigenen Speicher auf. Lange Aufgaben sollten einen Handle zurückgeben, den das Modell später weitergeben kann, oder die Tasks-Erweiterung verwenden – verlassen Sie sich nicht auf die Verbindung zur Speicherung im Arbeitsspeicher.
    • Steuern Sie an der Peripherie mithilfe von Mcp-Method / Mcp-Name die Limits, Allow-Lists und Messungen pro Tool.
    • Veröffentlichen Sie Cache-Hinweise: lange TTLs für stabile Tool-Listen, scope="private", wenn die Ergebnisse je nach Benutzer unterschiedlich sind.
    • Lassen Sie veraltete RPCs bestehen: Ersetzen Sie das Logging durch OpenTelemetry oder stderr und planen Sie den Ausstieg aus Sampling und Roots innerhalb des zwölfmonatigen Zeitraums.
    • Erneuern Sie die Authentifizierung: Überprüfen Sie die Aussteller (RFC 9207) und wechseln Sie schrittweise zu Client ID Metadata Documents.
  • Erweitern Sie Clients und Server gemeinsam; eine rückwärtskompatible Funktionalität ist nicht garantiert.
  • Hauptergebnisse

    • Durch die Revision vom 2026–07–28 ist jede Anfrage selbstständig; Handshake- und Session-ID werden nicht mehr verwendet.
    • Fragen während des Anrufs nutzen input_required in Kombination mit einer Wiederholung durch den Client, sodass Server niemals einen offenen Stream blockieren.
    • Das Python SDK v2 übernimmt den größten Teil der Aufgaben: Resolve(...) in Kombination mit Elicit(...) führt zu einer menschlichen Freigabe, die sowohl für alte als auch für neue Clients funktioniert.
    • Für Wiederholungsversuche zwischen Replikaten ist ein gemeinsamer requestState-Schlüssel erforderlich, bevor Sie skalieren.
    • Header-Routing und Cache-Hinweise ermöglichen es MCP, sich wie herkömmliche HTTP-Dienste zu verhalten.

    Fazit

    MCP ist nicht mehr nur ein Desktop-Plugin-Protokoll. Es kann wie jeder andere Microservice ausgeführt werden, was bedeutet, dass Automaskierung, Blue/Green-Deployment, API-Gateways und WAFs wieder anwendbar sind. Wenn Sie bereits MCP-Server betreiben, wechseln Sie auf das v2 SDK, legen Sie einen gemeinsamen Zustands Schlüssel fest und deaktivieren Sie Sticky Sessions – die Betreuer werden den Unterschied bemerken.

    Weiterführende Literatur

    • Spezifikationsankündigung: blog.modelcontextprotocol.io
    • Protokolltext: modelcontextprotocol.io/specification/2026-07-28
    • Artikel in The Register über MCPs Verzicht auf sein zustandsbasiertes Design
    • Offizielles Python-SDK-Repository