# 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: `; 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: }`; 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: `, 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.