Files
talk/.superpowers/sdd/plan/task-6-report.md
T

9.1 KiB
Raw Blame History

Task 6 Report: CORS-Tür + MCP-Endpoint

Status: DONE Datum: 2026-10-18 Basis: 24ceff3 (Tasks 1–5 verifiziert) → HEAD 30bd535 Commits (feat/v1, kein Push, .scratch/ unberührt):

  1. cef6e6b feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)
  2. 156c20d feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config
  3. a577597 feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool
  4. 150c374 test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake
  5. 30bd535 docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert

Was geliefert wurde

1. CORS-Tür (nur POST /speak)

  • internal/api/cors.go (neu): OPTIONS /speak beantwortet Browser-Preflights mit 204 No Content + Access-Control-Allow-Origin (exakt gematchte Origin bzw. literales * bei Wildcard-Konfiguration), Access-Control-Allow-Methods: POST, OPTIONS, Access-Control-Allow-Headers: Content-Type, Access-Control-Max-Age: 600. Echte (non-preflight) POST /speak-Antworten tragen ACAO, wenn die Anfrage-Origin gelistet ist. Vary: Origin steht immer auf /speak (Cache-Korrektheit).
  • Ungelistete Origin: sauberer 204/202 ohne jegliche CORS-Header — die Blockade macht der Browser; HTTP bleibt für Nicht-Browser-Clients sauber erreichbar.
  • Leere Liste (Default): gar keine CORS-Header — Tür zu, bis konfiguriert.
  • Nur /speak: OPTIONS /status → 405 (kein Preflight-Support), GET /status ohne ACAO; kein anderer Endpunkt sendet CORS-Header (Prozessgrenzen-Test TestCORSNurAufSpeak).
  • internal/config: server.allowed_origins (TOML-Liste) + Env-Override TALK_ALLOWED_ORIGINS (kommagetrennt, Whitespace toleriert; off leert die Liste). Wildcard * ist explizit erlaubt, NICHT Default. Leere Einträge → klarer Fehler. Unit-Tests: TestLoadAllowedOrigins (Defaults, TOML, Env schlägt Datei, off, Wildcard, Fehlerfälle).
  • GET /status: additives Feld cors: [origins] — immer vorhanden, [] (nicht null) bei geschlossener Tür (Compat-Anforderung des Briefs; Test TestStatusCorsFeld).

2. MCP-Endpoint (Streamable HTTP)

  • internal/api/mcp.go (neu): POST /mcp — eigene schlanke Implementierung des JSON-RPC-2.0-Subsets, kein SDK-Dependency (bindende Decision des Briefs; es wurde auch keins in Betracht gezogen — der benötigte Umfang sind ~370 Zeilen inkl. Doku, ein SDK wäre schwerer als der Payload). Methoden: initialize, notifications/initialized, ping, tools/list, tools/call.
  • Ein Tool speak: Input-Schema exakt nach Brief — {text: string, priority?, provider?, lang?, now?}, required: ["text"], enums für priority/lang (Test prüft Exactly-those-5-Properties).
  • tools/call = POST /speak: Beide Türen fahren denselben Aufnahme-Pfad admitSpeak (Refactoring in internal/api/server.go): Validierung → Mute-Check → Enqueue inkl. Gesetz (Rate-Limit-Budget, Ruhezeit; Rangordnung mute > now > quiet > rate aus Task 05 bleibt unverändert in internal/queue). Kein Contract-Drift möglich.
  • Ergebnis-Texte: Erfolg → queued: <id>; Mute → muted: Ansage verworfen (Mute ist aktiv); Verwurf → discarded: rate_limit … / discarded: quiet_hours … (+ Toast-Hinweis). Bewusst isError: false bei muted/discarded (deliberate Decision laut Brief: dokumentiertes Aufnahme-Ergebnis, kein Protokollfehler — der Agent soll den Grund lesen, nicht crashen).
  • GET /status: additives Feld mcp: true (Test TestStatusMCPFeld).
  • Daemon-Log-Zeile nennt jetzt cors-Liste und mcp.

MCP-Protokoll-Entscheidungen (dokumentationspflichtig)

  1. Version-Handling: initialize echot die angefragte protocolVersion (akzeptiert sie); fehlt sie, antwortet talkd mit 2025-06-18. Inhaltlich implementiert ist der 2025-06-18-Umfang: Initialize-Ergebnis mit protocolVersion, capabilities: {tools: {}}, serverInfo: {name: "talkd", version: <daemon-version>}; Streamable HTTP auf einem Endpoint; keine Batches (seit 2025-06-18 entfernt → -32600 mit klarer Meldung). Getestet: Echo von 2025-06-18 und 2025-03-26.
  2. GET /mcp: talkd pusht nie (statische Tool-Liste, keine Server-Notifications, kein Sampling) — server-initiierte SSE-Streams entfallen daher komplett. GET /mcp antwortet 405 Method Not Allowed mit Allow: POST, OPTIONS — exakt die im Streamable-HTTP-Transport vorgesehene Antwort für Server ohne Server-Streams. DELETE /mcp (Session-Terminierung) fällt auf den auto-405 des Routers.
  3. Content-Types: Antworten auf Requests: 200 OK + application/json — auch für JSON-RPC-Fehlerobjekte (der HTTP-Teil war erfolgreich; getestet in TestMCPFehlerPfade). Notifications: 202 Accepted mit leerem Body (JSON-RPC 2.0: nie eine Antwort). Requests: der Daemon erzwingt kein Content-Type (curl-freundlich, strikter wäre SDK-Komfort ohne Vertragsgewinn).
  4. Zustandslos, keine Session-Enforcement: Jeder POST ist unabhängig; Mcp-Session-Id wird ignoriert (nie gesetzt), und die Initialize-vor-allem-Regel wird NICHT erzwungen — ein curl kann tools/call direkt rufen. MCP-Clients fahren den Handshake freiwillig; Tests fahren ihn als volle Sequenz.
  5. JSON-RPC-Subset-Details: Notification = id fehlt oder ist explizit null (→ 202; unbekannte Notifications werden still ignoriert). Fehlercodes: kaputtes JSON -32700, falsche jsonrpc-Version/Batch -32600, unbekannte Methode -32601, Validierung/unbekanntes Tool -32602, interner Fehler (enqueue, HTTP-Äquivalent 503) -32603. HTTP-400-Validierung mapped also auf -32602 + deutscher Klartext (identische Meldungen wie POST /speak). id wird roh geechot (number/string/ null-sicher via json.RawMessage). ping → leeres Result (billig, hilft Clients). Body-Limit 64 KiB wie /speak.
  6. Kein CORS auf /mcp (bindend: CORS nur auf POST /speak) — MCP-Clients sind keine Browser. Sollte später ein Browser-MCP-Client entstehen, muss die Tür explizit erweitert werden (bewusste Grenze, Spec: „LibreChat-Frontend-Anbindung out of scope").

Tests (Prozessgrenze, primäre Seam — harness erweitert, nicht ersetzt)

test/harness_test.go: statusBody +mcp/cors; neue Helfer doRequest (rohe HTTP-Requests mit Headern), preflight, mcpPost (literale JSON-RPC-Payloads, kein Client-SDK), mcpDecode.

  • test/cors_test.go: Preflight von gelisteter Origin → 204 + alle Header (Abnahmekriterium); ungeliste Origin → kein ACAO; echter POST mit/ohne gelistete Origin; leere Liste → gar keine CORS-Header (Default); Wildcard * → ACAO *; CORS nur auf /speak (OPTIONS /status 405, GET /status ohne ACAO); cors-Feld in /status (gelistet + [] bei leerer Liste).
  • test/mcp_test.go: kompletter Handshake als literal JSON-RPC über HTTP — initialize (Version-Echo 2025-06-18 und 2025-03-26, capabilities.tools, serverInfo) → notifications/initialized (202, leerer Body) → tools/list (genau speak, Schema mit required=[text], genau die 5 Properties) → tools/call (queued: <id>, isError falsch, Ansage am Fake-Provider sichtbar = echte Queue-Aufnahme, Abnahmekriterium). Gesetze: muted → isError:false + „muted …", keine Synthese; rate_limit (30 s) → zweite low verworfen als „discarded: rate_limit …"; quiet_hours → „discarded: quiet_hours …". Fehlerpfade: text fehlt/leer, bad priority, unbekanntes Tool, unbekannte Methode, kaputtes JSON, falsche jsonrpc-Version, Batch — je korrekter Code + Meldungsanteil, Status 200; ping; GET /mcp → 405 + Allow; unbekannte Notification still 202. Alle Daemons auf ephemeralen Ports (TALK_PORT), isoliertes HOME/XDG, sink=null, Fake-Provider.

Verifikation

  • gofmt -l . leer; go vet ./... grün; go build ./... grün; go test ./... komplett grün (inkl. aller Tasks 1–5-Tests, keine Regressionen — die CLI dekodiert /status mit den additiven Feldern ohne Anpassung).
  • Cross-Compile-Smoke (CGO_ENABLED=0): GOOS=darwin GOARCH=amd64 und GOOS=windows GOARCH=amd64 für talk UND talkd gebaut — erfolgreich.

Self-Review (git diff 24ceff3..HEAD)

  • Ein Verantwortlicher pro Datei: cors.go (CORS), mcp.go (MCP), server.go (Routing + admitSpeak-Refactoring), config.go (Liste + Env), Tests getrennt nach(cors|mcp).
  • admitSpeak ist die einzige Aufnahme-Stelle — POST /speak-Verhalten unverändert (alle Task-04/05-Prozesstests laufen unverändert grün).
  • Keine Abnahme-Lücken gegenüber dem Brief: alle vier bindenden Testszenarien (preflight gelistet / ungelistet / leere Liste / *), Handshake + tools/call mit Fake-Beobachtung, /status-Felder, curl-Sequenz in README.

Concerns / Grenzen

  • notifications/initialized MIT id (falsch benutzter Client) bekommt ein leeres Result statt Fehler (tolerante Wahl, dokumentiert im Code).
  • CORS-Preflight wird bewusst auch bei fehlendem Origin-Header mit 204 beantwortet (kein CORS-Kontext, keine Header) — harmlos, dokumentiert.
  • OS-Toast in Ruhezeit bleibt wie in Task 5 eine Log-Zeile (Notification-Anbindung folgt später) — betrifft MCP-/CORS-Pfad nicht.