# Review package: 24ceff3..HEAD ## Commits 30bd535 docs(readme): CORS-Konfiguration und MCP-Endpoint mit curl-Handshake dokumentiert 150c374 test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake a577597 feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool 156c20d feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config cef6e6b feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS) ## Files changed README.md | 63 ++++++- cmd/talkd/main.go | 13 +- internal/api/cors.go | 78 +++++++++ internal/api/doc.go | 11 +- internal/api/mcp.go | 371 +++++++++++++++++++++++++++++++++++++++++ internal/api/server.go | 96 ++++++++--- internal/api/types.go | 11 +- internal/config/config.go | 44 +++++ internal/config/config_test.go | 103 ++++++++++++ test/cors_test.go | 195 ++++++++++++++++++++++ test/harness_test.go | 72 +++++++- test/mcp_test.go | 300 +++++++++++++++++++++++++++++++++ 12 files changed, 1318 insertions(+), 39 deletions(-) ## Diff README.md --- 1/3 --- Text 33 `talkd`, siehe unten), `mute`, `unm 33 `talkd`, siehe unten), `mute`, `unm .. ute`, `status`, `cancel`. Die übrig .. ute`, `status`, `cancel`. Die übrig .. en .. en 34 Befehle sind Stubs und melden `not 34 Befehle sind Stubs und melden `not .. implemented yet` (Exit-Code ≠ 0). .. implemented yet` (Exit-Code ≠ 0). 35 35 36 ## Daemon talkd (Task 04 + 05) 36 ## Daemon talkd (Task 04–06) 37 37 38 `talkd` besitzt die Ansage-Queue un 38 `talkd` besitzt die Ansage-Queue un .. d spricht seriell ab — genau eine A .. d spricht seriell ab — genau eine A .. nsage .. nsage 39 gleichzeitig, Reihenfolge `now` > ` 39 gleichzeitig, Reihenfolge `now` > ` .. high` > `normal` > `low` (FIFO je .. high` > `normal` > `low` (FIFO je README.md --- 2/3 --- Text 43 | Endpunkt | Wirkung | 43 | Endpunkt | Wirkung | 44 | --- | --- | 44 | --- | --- | 45 | `POST /speak` | `{text, priority? 45 | `POST /speak` | `{text, priority? .. , provider?, voice?, lang?, now?}` .. , provider?, voice?, lang?, now?}` .. → `202 {"id":"…"}`; bei Mute: `202 .. → `202 {"id":"…"}`; bei Mute: `202 .. {"id":"","muted":true}`; bei Gesetz .. {"id":"","muted":true}`; bei Gesetz .. -Verwurf: `202 {"id":"","discarded" .. -Verwurf: `202 {"id":"","discarded" .. :"rate_limit"\|"quiet_hours"}` (+ ` .. :"rate_limit"\|"quiet_hours"}` (+ ` .. "toast":true` im Toast-Modus) — ver .. "toast":true` im Toast-Modus) — ver .. worfen, dokumentiertes Verhalten | .. worfen, dokumentiertes Verhalten | 46 | `GET /status` | `{"version", "que 46 | `OPTIONS /speak` | CORS-Preflight .. ue", "queue_ids", "current", "muted .. → `204` mit Freigabe-Headern, wenn .. ", "provider", "rate_limit": {"inte .. die `Origin` in `server.allowed_or .. rval", "allow_high_burst", "next_sl .. igins` steht (siehe unten); sonst ` .. ot"}, "quiet_hours": {"window", "ac .. 204` ohne CORS-Header | .. tive", "mode", "until"}}` | .. .. 47 | `GET /status` | `{"version", "que .. .. ue", "queue_ids", "current", "muted .. .. ", "provider", "rate_limit": {"inte .. .. rval", "allow_high_burst", "next_sl .. .. ot"}, "quiet_hours": {"window", "ac .. .. tive", "mode", "until"}, "mcp": tru .. .. e, "cors": [origins]}` | 47 | `POST /cancel` | bricht die laufe 48 | `POST /cancel` | bricht die laufe .. nde Ansage ab + leert die Queue → ` .. nde Ansage ab + leert die Queue → ` .. 200 {"cancelled":n}` | .. 200 {"cancelled":n}` | 48 | `POST /mute` / `POST /unmute` | p 49 | `POST /mute` / `POST /unmute` | p .. ersistenter Mute-State → `200 {"mut .. ersistenter Mute-State → `200 {"mut .. ed":bool}` | .. ed":bool}` | .. 50 | `POST /mcp` | MCP (Streamable HTT .. .. P): JSON-RPC `initialize`, `notific .. .. ations/initialized`, `ping`, `tools .. .. /list`, `tools/call` — ein Tool `sp .. .. eak` (siehe unten) | .. 51 | `GET /mcp` | `405` — talkd bietet .. .. keine server-initiierten Streams a .. .. n | 49 52 50 - **CLI-Verhalten:** `talk say` (un 53 - **CLI-Verhalten:** `talk say` (un .. d `talk "Text"`) erkennt den Daemon .. d `talk "Text"`) erkennt den Daemon .. via .. via 51 `GET /status`. Läuft er: enqueue 54 `GET /status`. Läuft er: enqueue .. + sofortige Rückkehr (non-blocking, .. + sofortige Rückkehr (non-blocking, README.md --- 3/3 --- Text 105 108 erlaubt), nächster slot …` und `ruhezeit: 18:00-08:00 (modus discard, 106 109 aktiv bis 08:00)`. 107 110 ... 111 ### CORS-Tür + MCP-Endpoint (Task 06) ... 112 ... 113 **CORS** gilt bewusst nur für `POST /speak` — genau die eine Tür, die eine ... 114 spätere Browser-Anbindung (z. B. LibreChat) braucht; Steuer-Endpunkte ... 115 bleiben browserseitig unerreicht. Die Erlaubnis ist eine exakte Origin- ... 116 Liste; `"*"` ist erlaubt, aber nicht Default; **leere Liste (Default) = ... 117 gar keine CORS-Header = der Browser blockt**: ... 118 ... 119 ```toml ... 120 [server] ... 121 allowed_origins = ["https://chat.az-gruppe.com"] # Env TALK_ALLOWED_ORIGINS ... 122 ``` ... 123 ... 124 - `OPTIONS /speak` (Preflight) von gelisteter Origin → `204` mit ... 125 `Access-Control-Allow-Origin` (die Origin bzw. `*`), `-Methods: POST, ... 126 OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`. ... 127 - Echter `POST /speak` mit gelisteter `Origin` trägt denselben ACAO-Header. ... 128 - Ungelistete Origins bekommen saubere `204`/`202` **ohne** CORS-Header — ... 129 die Blockade macht der Browser. ... 130 ... 131 **MCP** (`POST /mcp`, Streamable HTTP): schlanke eigene Implementierung ... 132 des JSON-RPC-Subsets ohne SDK — `initialize` (angefragte ... 133 `protocolVersion` wird geechot; Ergebnis `protocolVersion`, ... 134 `capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized` ... 135 (Notification → `202` ohne Body), `ping`, `tools/list`, ... 136 `tools/call`. Der Server ist zustandslos (keine Session-IDs) und pusht ... 137 nichts (daher `GET /mcp` → `405`). Ein Tool: ... 138 ... 139 ```json ... 140 {"name": "speak", ... 141 "inputSchema": {"type": "object", ... 142 "properties": {"text": {"type": "string"}, ... 143 "priority": {"enum": ["low","normal","high"]}, ... 144 "provider": {"type": "string"}, ... 145 "lang": {"enum": ["de","en"]}, ... 146 "now": {"type": "boolean"}}, ... 147 "required": ["text"]}} ... 148 ``` ... 149 ... 150 `tools/call` fährt exakt denselben Aufnahme-Pfad wie `POST /speak` ... 151 (inklusive Mute, Rate-Limit, Ruhezeiten). Ergebnis ist immer Text-Content: ... 152 `"queued: "` bei Aufnahme; `muted`/`discarded: ` mit Erklärung ... 153 — bewusst `isError: false`, denn ein Verwurf ist das korrekte ... 154 dokumentierte Ergebnis, kein Protokollfehler. Validierungsfehler ... 155 (fehlender Text, ungültige Priorität, unbekanntes Tool) sind dagegen ... 156 JSON-RPC-Fehler (`-32602`). Handshake als curl-Sequenz: ... 157 ... 158 ```sh ... 159 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ ... 160 -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' ... 161 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ ... 162 -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' ... 163 curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ ... 164 -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build fertig"}}}' ... 165 ``` ... 166 108 167 ### Provider-Kette (Task 03) 109 168 cmd/talkd/main.go --- 1/3 --- Go 5 // Default 1 Ansage / 30 s; low übe 5 // Default 1 Ansage / 30 s; low übe . r Budget wird verworfen, normal par . r Budget wird verworfen, normal par . kt, . kt, 6 // high darf mit queue.allow_high_b 6 // high darf mit queue.allow_high_b . urst verstoßen) plus Ruhezeiten . urst verstoßen) plus Ruhezeiten 7 // (quiet_hours/quiet_mode; now:tru 7 // (quiet_hours/quiet_mode; now:tru . e bricht bewusst aus beiden aus). . e bricht bewusst aus beiden aus). 8 // CORS/MCP folgen in Task 06. 8 // Task 06 ergänzt die CORS-Tür für . . POST /speak (server.allowed_origin . . s / . 9 // TALK_ALLOWED_ORIGINS; leer = kei . . n CORS) und den MCP-Endpoint POST / . . mcp . 10 // (Streamable HTTP, ein speak-Tool . .. ). 9 package main 11 package main 10 12 11 import ( 13 import ( cmd/talkd/main.go --- 2/3 --- Go 120 122 Mute: mute, 121 123 Version: versionString(), 122 124 KnownProviders: provider.Names(), ... 125 AllowedOrigins: cfg.Server.AllowedOrigins, 123 126 Provider: func() string { 124 127 if cur, ok := q.Current(); ok { 125 128 return resolveProviderName(baseChain, cur) cmd/talkd/main.go --- 3/3 --- Go 139 srv := &http.Server{Handler: ha 142 srv := &http.Server{Handler: ha ... ndler, ReadHeaderTimeout: 5 * time. ... ndler, ReadHeaderTimeout: 5 * time. ... Second} ... Second} 140 errCh := make(chan error, 1) 143 errCh := make(chan error, 1) 141 go func() { errCh <- srv.Serve( 144 go func() { errCh <- srv.Serve( ... ln) }() ... ln) }() ... 145 corsLog := "zu (kein CORS)" ... 146 if len(cfg.Server.AllowedOrigin ... ... s) > 0 { ... 147 corsLog = fmt.Sprintf("%v", ... ... cfg.Server.AllowedOrigins) ... 148 } 142 log.Printf("listening on http:/ 149 log.Printf("listening on http:/ ... /%s (version %s, audio sink %s, mut ... /%s (version %s, audio sink %s, mut ... e=%v, rate-limit %s, high-burst=%v, ... e=%v, rate-limit %s, high-burst=%v, ... ruhezeit=%q modus %s)", ... ruhezeit=%q modus %s, cors=%s, mcp ... ... =/mcp)", 143 addr, versionString(), sink 150 addr, versionString(), sink ... .Name(), mute.Muted(), ... .Name(), mute.Muted(), 144 cfg.Queue.RateLimit, cfg.Qu 151 cfg.Queue.RateLimit, cfg.Qu ... eue.AllowHighBurst, cfg.QuietHours, ... eue.AllowHighBurst, cfg.QuietHours, ... cfg.QuietMode) ... cfg.QuietMode, corsLog) 145 152 146 sig := make(chan os.Signal, 1) 153 sig := make(chan os.Signal, 1) 147 signal.Notify(sig, os.Interrupt 154 signal.Notify(sig, os.Interrupt ... , syscall.SIGTERM) ... , syscall.SIGTERM) internal/api/cors.go --- Go 1 package api 2 3 import ( 4 "net/http" 5 ) 6 7 // CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein 8 // Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage- 9 // Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis 10 // kommt ausschließlich aus der Config-Liste server.allowed_origins 11 // (Env TALK_ALLOWED_ORIGINS): exakte Origins wie 12 // "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin 13 // ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine 14 // CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird). 15 // 16 // Ungelistete Origins bekommen bewusst trotzdem 204/202 ohne CORS-Header: 17 // HTTP-seitig ist die Anfrage sauber, die Blockade macht der Browser (so 18 // verrät der Daemon nichts über seine Erlaubnisliste). 19 20 // CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST 21 // dieselben setzen). 22 const ( 23 headerAllowOrigin = "Access-Control-Allow-Origin" 24 headerAllowMethods = "Access-Control-Allow-Methods" 25 headerAllowHeaders = "Access-Control-Allow-Headers" 26 headerMaxAge = "Access-Control-Max-Age" 27 headerVary = "Vary" 28 ) 29 30 // preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine 31 // Chat-Session und halten den OPTIONS-Chatter klein. 32 const preflightAge = "600" 33 34 // corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard- 35 // Konfiguration das literale "*", sonst die exakt gematchte Origin (die 36 // identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt. 37 func (h *handler) corsACAO(r *http.Request) string { 38 origin := r.Header.Get("Origin") 39 if origin == "" { 40 return "" 41 } 42 for _, allowed := range h.allowedOrigins { 43 if allowed == "*" { 44 return "*" 45 } 46 if allowed == origin { 47 return allowed 48 } 49 } 50 return "" 51 } 52 53 // setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten, 54 // wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei 55 // geschlossener Tür), damit Caches die Antwort nie fälschlich origin- 56 // übergreifend wiederverwenden. 57 func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) { 58 w.Header().Add(headerVary, "Origin") 59 if acao := h.corsACAO(r); acao != "" { 60 w.Header().Set(headerAllowOrigin, acao) 61 } 62 } 63 64 // speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den 65 // Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header 66 // sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft 67 // den eigentlichen Request, während der Daemon für Nicht-Browser-Clients 68 // erreichbar bleibt. 69 func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) { 70 w.Header().Add(headerVary, "Origin") 71 if acao := h.corsACAO(r); acao != "" { 72 w.Header().Set(headerAllowOrigin, acao) 73 w.Header().Set(headerAllowMethods, "POST, OPTIONS") 74 w.Header().Set(headerAllowHeaders, "Content-Type") 75 w.Header().Set(headerMaxAge, preflightAge) 76 } 77 w.WriteHeader(http.StatusNoContent) 78 } 79 internal/api/doc.go --- Go 1 // Package api definiert die HTTP-Ob 1 // Package api definiert die HTTP-Ob . erfläche des Daemons auf 127.0.0.1: . erfläche des Daemons auf 127.0.0.1: 2 // POST /speak (enqueue, 202 {id}; b 2 // POST /speak (enqueue, 202 {id}; b . ei Mute 202 {muted:true}; bei Gesetz . ei Mute 202 {muted:true}; bei Gesetz . - . - 3 // Verwurf 202 {discarded:"rate_limi 3 // Verwurf 202 {discarded:"rate_limi . t"|"quiet_hours"} — Task 05), . t"|"quiet_hours"} — Task 05), 4 // GET /status (inkl. Rate-Limit- un 4 // GET /status (inkl. Gesetz-State, . d Ruhezeit-State), POST /cancel, . mcp/cors — Task 06), POST /cancel, 5 // POST /mute, POST /unmute. CORS fü 5 // POST /mute, POST /unmute. Task 06 . r /speak und der MCP-Endpoint . ergänzt die CORS-Tür für POST /spea . . k . 6 // (OPTIONS-Preflight + ACAO aus ser . . ver.allowed_origins, siehe cors.go) . 7 // und den MCP-Endpoint POST /mcp al . . s schlankes eigenes JSON-RPC-Subset . 8 // (initialize, notifications/initia . . lized, tools/list, tools/call mit de . . m 6 // (Streamable HTTP) folgen in Task 9 // speak-Tool — siehe mcp.go). Der C . 06. Der Client-Teil ist die CLI-Seit . lient-Teil ist die CLI-Seite derselb . e . en 7 // derselben Vertragsschnittstelle. 10 // Vertragsschnittstelle. 8 package api 11 package api internal/api/mcp.go --- Go 1 package api 2 3 import ( 4 "encoding/json" 5 "io" 6 "net/http" 7 "strconv" 8 "strings" 9 10 "code.m3ta.dev/m3tam3re/talk/internal/queue" 11 ) 12 13 // MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene, 14 // schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich 15 // braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier 16 // Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei 17 // dokumentierten Vereinfachungen, die der Transport hergibt: 18 // 19 // - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs 20 // (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung 21 // der Initialize-vor-allem-Regel — ein curl kann tools/call direkt 22 // rufen. MCP-Clients stört das nicht: Sie fahren den Handshake 23 // freiwillig. 24 // - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste 25 // ist statisch). GET /mcp antwortet daher 405 — die vom Streamable- 26 // HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream. 27 // 28 // Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte 29 // ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202 30 // ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht 31 // mehr Teil von MCP → -32600. 32 33 // mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der 34 // Client keine angibt (initialize ohne protocolVersion). Angefragte 35 // Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich 36 // implementiert ist der oben dokumentierte 2025-06-18-Umfang. 37 const mcpProtocolVersion = "2025-06-18" 38 39 // JSON-RPC-2.0-Fehlercodes (benutztes Subset). 40 const ( 41 rpcParseError = -32700 42 rpcInvalidRequest = -32600 43 rpcMethodNotFound = -32601 44 rpcInvalidParams = -32602 45 rpcInternalError = -32603 46 ) 47 48 // mcpServerName ist der serverInfo-Name im Initialize-Ergebnis. 49 const mcpServerName = "talkd" 50 51 // jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als 52 // RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0 53 // bzw. id:string unterscheidbar. 54 type jsonRPCRequest struct { 55 JSONRPC string `json:"jsonrpc"` 56 ID json.RawMessage `json:"id"` 57 Method string `json:"method"` 58 Params json.RawMessage `json:"params"` 59 } 60 61 // jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus 62 // Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern 63 // ohne bekannte ID null (JSON-RPC 2.0). 64 type jsonRPCResponse struct { 65 JSONRPC string `json:"jsonrpc"` 66 ID json.RawMessage `json:"id"` 67 Result any `json:"result,omitempty"` 68 Error *rpcError `json:"error,omitempty"` 69 } 70 71 // rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort. 72 type rpcError struct { 73 Code int `json:"code"` 74 Message string `json:"message"` 75 } 76 77 // mcpInitializeParams sind die relevanten Teile der initialize-Parameter 78 // (Client-Capabilities interessieren talkd nicht — Tools genügen). 79 type mcpInitializeParams struct { 80 ProtocolVersion string `json:"protocolVersion"` 81 } 82 83 // mcpServerInfo beschreibt den Server im Initialize-Ergebnis. 84 type mcpServerInfo struct { 85 Name string `json:"name"` 86 Version string `json:"version"` 87 } 88 89 // mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet: 90 // Tools (speak) — sonst nichts. 91 type mcpCapabilities struct { 92 Tools map[string]any `json:"tools"` 93 } 94 95 // mcpInitializeResult ist das Ergebnis von initialize: die (geechote) 96 // Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form. 97 type mcpInitializeResult struct { 98 ProtocolVersion string `json:"protocolVersion"` 99 Capabilities mcpCapabilities `json:"capabilities"` 100 ServerInfo mcpServerInfo `json:"serverInfo"` 101 } 102 103 // mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool 104 // (speak); ohne nextCursor (die Liste passt immer in eine Seite). 105 type mcpToolsListResult struct { 106 Tools []mcpTool `json:"tools"` 107 } 108 109 // mcpTool ist ein Tool-Eintrag in tools/list. 110 type mcpTool struct { 111 Name string `json:"name"` 112 Description string `json:"description"` 113 InputSchema mcpSchema `json:"inputSchema"` 114 } 115 116 // mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs. 117 type mcpSchema struct { 118 Type string `json:"type"` 119 Properties map[string]mcpProperty `json:"properties"` 120 Required []string `json:"required"` 121 } 122 123 // mcpProperty ist eine einzelne Schema-Property. 124 type mcpProperty struct { 125 Type string `json:"type"` 126 Description string `json:"description,omitempty"` 127 Enum []string `json:"enum,omitempty"` 128 } 129 130 // mcpToolCallParams sind die Parameter von tools/call. 131 type mcpToolCallParams struct { 132 Name string `json:"name"` 133 Arguments json.RawMessage `json:"arguments"` 134 } 135 136 // mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text). 137 type mcpContent struct { 138 Type string `json:"type"` 139 Text string `json:"text"` 140 } 141 142 // mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen) 143 // auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein 144 // Protokollfehler (bewusste Entscheidung, siehe speakToolResult). 145 type mcpToolResult struct { 146 Content []mcpContent `json:"content"` 147 IsError bool `json:"isError,omitempty"` 148 } 149 150 // serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request. 151 func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) { 152 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) 153 if err != nil { 154 writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error()) 155 return 156 } 157 if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") { 158 writeRPCError(w, nil, rpcInvalidRequest, 159 "Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST") 160 return 161 } 162 163 var req jsonRPCRequest 164 if err := json.Unmarshal(body, &req); err != nil { 165 writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error()) 166 return 167 } 168 if req.JSONRPC != "2.0" { 169 writeRPCError(w, nil, rpcInvalidRequest, 170 `jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`) 171 return 172 } 173 if isNotification(req.ID) { 174 // Notifications (notifications/initialized, …) bekommen nie eine 175 // JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte 176 // Notifications werden still ignoriert (JSON-RPC 2.0). 177 w.WriteHeader(http.StatusAccepted) 178 return 179 } 180 181 switch req.Method { 182 case "initialize": 183 h.mcpInitialize(w, req) 184 case "notifications/initialized": 185 // Mit ID gesendet: kein regulärer Client — die korrekte Antwort 186 // ist ein leeres Ergebnis, kein Fehler. 187 writeRPCResult(w, req.ID, struct{}{}) 188 case "ping": 189 writeRPCResult(w, req.ID, struct{}{}) 190 case "tools/list": 191 writeRPCResult(w, req.ID, mcpToolsListResult{Tools: []mcpTool{speakTool()}}) 192 case "tools/call": 193 h.mcpToolsCall(w, req) 194 default: 195 writeRPCError(w, req.ID, rpcMethodNotFound, 196 "Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)") 197 } 198 } 199 200 // mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine 201 // server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP- 202 // Antwort), Tools-Änderungen gibt es nicht. 203 func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) { 204 w.Header().Set("Allow", "POST, OPTIONS") 205 w.WriteHeader(http.StatusMethodNotAllowed) 206 } 207 208 // mcpInitialize behandelt initialize: Die angefragte Protokollversion wird 209 // geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities: 210 // Tools; serverInfo: talkd + Daemon-Version. 211 func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) { 212 var params mcpInitializeParams 213 if len(req.Params) > 0 { 214 if err := json.Unmarshal(req.Params, ¶ms); err != nil { 215 writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error()) 216 return 217 } 218 } 219 version := params.ProtocolVersion 220 if version == "" { 221 version = mcpProtocolVersion 222 } 223 writeRPCResult(w, req.ID, mcpInitializeResult{ 224 ProtocolVersion: version, 225 Capabilities: mcpCapabilities{Tools: map[string]any{}}, 226 ServerInfo: mcpServerInfo{Name: mcpServerName, Version: h.version}, 227 }) 228 } 229 230 // mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen 231 // Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive 232 // Mute und Gesetz (Rate-Limit/Ruhezeit). 233 func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) { 234 var params mcpToolCallParams 235 if err := json.Unmarshal(req.Params, ¶ms); err != nil { 236 writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error()) 237 return 238 } 239 if params.Name != "speak" { 240 writeRPCError(w, req.ID, rpcInvalidParams, 241 "unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)") 242 return 243 } 244 245 args := params.Arguments 246 if len(args) == 0 { 247 args = json.RawMessage("{}") 248 } 249 var speakReq SpeakRequest 250 if err := json.Unmarshal(args, &speakReq); err != nil { 251 writeRPCError(w, req.ID, rpcInvalidParams, "arguments: "+err.Error()) 252 return 253 } 254 255 adm := h.admitSpeak(speakReq) 256 if adm.status != http.StatusAccepted { 257 // Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als 258 // JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis. 259 code := rpcInvalidParams 260 if adm.status != http.StatusBadRequest { 261 code = rpcInternalError 262 } 263 writeRPCError(w, req.ID, code, adm.errMsg) 264 return 265 } 266 writeRPCResult(w, req.ID, speakToolResult(adm.resp)) 267 } 268 269 // speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem 270 // Task-06-Brief (text Pflicht; priority/provider/lang/now optional). 271 func speakTool() mcpTool { 272 return mcpTool{ 273 Name: "speak", 274 Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " + 275 "Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " + 276 "Queue, Rate-Limit, Mute und Ruhezeiten gelten.", 277 InputSchema: mcpSchema{ 278 Type: "object", 279 Properties: map[string]mcpProperty{ 280 "text": { 281 Type: "string", 282 Description: "Der Ansage-Text (1–2 Sätze Etikette)", 283 }, 284 "priority": { 285 Type: "string", 286 Enum: []string{"low", "normal", "high"}, 287 Description: "Abarbeitungs-Priorität (Default normal)", 288 }, 289 "provider": { 290 Type: "string", 291 Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette", 292 }, 293 "lang": { 294 Type: "string", 295 Enum: []string{"de", "en"}, 296 Description: "Sprache pin; leer = automatische Erkennung", 297 }, 298 "now": { 299 Type: "boolean", 300 Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus", 301 }, 302 }, 303 Required: []string{"text"}, 304 }, 305 } 306 } 307 308 // speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool- 309 // Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind 310 // das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler) 311 // — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme: 312 // "queued: ". 313 func speakToolResult(resp SpeakResponse) mcpToolResult { 314 switch { 315 case resp.Muted: 316 return mcpToolResult{Content: []mcpContent{{ 317 Type: "text", 318 Text: "muted: Ansage verworfen (Mute ist aktiv)", 319 }}} 320 case resp.Discarded == queue.DiscardRateLimit: 321 return mcpToolResult{Content: []mcpContent{{ 322 Type: "text", 323 Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)", 324 }}} 325 case resp.Discarded == queue.DiscardQuietHours: 326 text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen" 327 if resp.Toast { 328 text += " (durch Toast ersetzt, kein Audio)" 329 } 330 return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} 331 case resp.Discarded != "": 332 return mcpToolResult{Content: []mcpContent{{ 333 Type: "text", 334 Text: "discarded: " + resp.Discarded, 335 }}} 336 default: 337 return mcpToolResult{Content: []mcpContent{{ 338 Type: "text", 339 Text: "queued: " + resp.ID, 340 }}} 341 } 342 } 343 344 // isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt 345 // oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort). 346 func isNotification(id json.RawMessage) bool { 347 return len(id) == 0 || strings.TrimSpace(string(id)) == "null" 348 } 349 350 // writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200, 351 // application/json). 352 func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { 353 writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result}) 354 } 355 356 // writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die 357 // Anfrage-ID unbekannt ist (Parse-Fehler). 358 func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) { 359 writeRPC(w, jsonRPCResponse{ 360 JSONRPC: "2.0", 361 ID: id, 362 Error: &rpcError{Code: code, Message: msg}, 363 }) 364 } 365 366 // writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer. 367 func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) { 368 w.Header().Set("Content-Type", "application/json") 369 w.WriteHeader(http.StatusOK) 370 _ = json.NewEncoder(w).Encode(resp) 371 } 372 internal/api/server.go --- 1/7 --- Go 24 // Mute ist der persistente Mut 24 // Mute ist der persistente Mut .. e-State (Datei mute im Config-Dir). .. e-State (Datei mute im Config-Dir). 25 Mute queue.MuteStore 25 Mute queue.MuteStore 26 26 27 // Version ist die Daemon-Versi 27 // Version ist die Daemon-Versi .. on für GET /status. .. on für GET /status und serverInfo. 28 Version string 28 Version string 29 29 30 // KnownProviders sind die gült 30 // KnownProviders sind die gült .. igen Provider-Namen (Validierung de .. igen Provider-Namen (Validierung de .. s .. s internal/api/server.go --- 2/7 --- Go 35 35 // Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional. 36 36 Provider func() string .. 37 .. 38 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer = .. 39 // keine CORS-Header, der Browser blockt; siehe cors.go). .. 40 AllowedOrigins []string 37 41 } 38 42 39 43 // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per internal/api/server.go --- 3/7 --- Go 45 49 version: cfg.Version, 46 50 known: make(map[string]bool, len(cfg.KnownProviders)), 47 51 provider: cfg.Provider, .. 52 allowedOrigins: cfg.AllowedOrigins, 48 53 } 49 54 for _, name := range cfg.KnownProviders { 50 55 h.known[name] = true 51 56 } 52 57 mux := http.NewServeMux() 53 58 mux.HandleFunc("POST /speak", h.speak) .. 59 mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06) 54 60 mux.HandleFunc("GET /status", h.status) 55 61 mux.HandleFunc("POST /cancel", h.cancel) 56 62 mux.HandleFunc("POST /mute", h.muteEndpoint(true)) 57 63 mux.HandleFunc("POST /unmute", h.muteEndpoint(false)) .. 64 mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06) .. 65 mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams 58 66 return mux 59 67 } 60 68 internal/api/server.go --- 4/7 --- Go 65 version string 73 version string 66 known map[string]bool 74 known map[string]bool 67 provider func() string 75 provider func() string .. 76 allowedOrigins []string 68 } 77 } 69 78 70 // speak behandelt POST /speak: val 79 // speak behandelt POST /speak: val .. idieren, bei Mute verwerfen (202, .. idieren, bei Mute verwerfen (202, 71 // dokumentiertes Verhalten — bewus 80 // dokumentiertes Verhalten — bewus .. st kein Fehler, damit Aufrufer kein .. st kein Fehler, damit Aufrufer kein .. e .. e 72 // Sonderbehandlung brauchen), sons 81 // Sonderbehandlung brauchen), sons .. t enqueue → 202 {id}. Das Gesetz .. t enqueue → 202 {id}. Das Gesetz 73 // (Rate-Limit-Budget, Ruhezeit) ka 82 // (Rate-Limit-Budget, Ruhezeit) ka .. nn beim Eintritt ebenfalls verwerfe .. nn beim Eintritt ebenfalls verwerfe .. n — .. n — 74 // die Antwort nennt dann den Grund 83 // die Antwort nennt dann den Grund .. ({"discarded":"…"}). .. ({"discarded":"…"}). CORS-Header n .. .. ur .. 84 // hier und nur bei gelisteter Orig .. .. in (siehe cors.go). 75 func (h *handler) speak(w http.Resp 85 func (h *handler) speak(w http.Resp .. onseWriter, r *http.Request) { .. onseWriter, r *http.Request) { 76 var req SpeakRequest 86 h.setSpeakCors(w, r) .. 87 .. 88 var req SpeakRequest .. 89 if err := json.NewDecoder(http. .. .. MaxBytesReader(w, r.Body, maxBodyBy .. .. tes)).Decode(&req); err != nil { .. 90 writeError(w, http.StatusBa .. .. dRequest, "ungültiger JSON-Body: "+ .. .. err.Error()) .. 91 return .. 92 } .. 93 adm := h.admitSpeak(req) .. 94 if adm.errMsg != "" { .. 95 writeError(w, adm.status, a .. .. dm.errMsg) .. 96 return .. 97 } .. 98 writeJSON(w, adm.status, adm.re .. .. sp) .. 99 } .. 100 .. 101 // speakAdmission ist das Ergebnis .. ... des gemeinsamen Aufnahme-Pfads von .. 102 // POST /speak und dem MCP-Tool spe .. ... ak: HTTP-Status-Äquivalent plus Bod .. ... y — .. 103 // 202 mit {id}/{muted}/{discarded} .. ... als Aufnahme-Ergebnis, 400/503 mit .. 104 // errMsg als Fehler (MCP übersetzt .. ... beides, siehe mcp.go). .. 105 type speakAdmission struct { .. 106 status int .. 107 resp SpeakResponse .. 108 errMsg string .. 109 } .. 110 .. 111 // admitSpeak ist der eine Aufnahme .. ... -Pfad hinter POST /speak und dem .. 112 // MCP-Tool speak (Task 06): Validi .. ... erung, Mute-Check, Enqueue inklusiv .. ... e .. 113 // Gesetz (Rate-Limit-Budget, Ruhez .. ... eit) — identische Semantik für beid .. ... e .. 114 // Türen, kein Contract-Drift. 77 if err := json.NewDecoder(http. 115 func (h *handler) admitSpeak(req Sp .. MaxBytesReader(w, r.Body, maxBodyBy ... eakRequest) speakAdmission { .. tes)).Decode(&req); err != nil { ... 78 writeError(w, http.StatusBa ... .. dRequest, "ungültiger JSON-Body: "+ ... .. err.Error()) ... 79 return ... 80 } ... 81 if strings.TrimSpace(req.Text) 116 if strings.TrimSpace(req.Text) .. == "" { ... == "" { 82 writeError(w, http.StatusBa 117 return speakAdmission{statu .. dRequest, "text fehlt oder ist leer ... s: http.StatusBadRequest, errMsg: " .. ") ... text fehlt oder ist leer"} 83 return ... 84 } 118 } 85 if req.Priority == "" { 119 if req.Priority == "" { 86 req.Priority = string(queue 120 req.Priority = string(queue .. .PriorityNormal) ... .PriorityNormal) 87 } 121 } 88 if !queue.Priority(req.Priority 122 if !queue.Priority(req.Priority .. ).Valid() { ... ).Valid() { 89 writeError(w, http.StatusBa 123 return speakAdmission{statu .. dRequest, fmt.Sprintf("priority: un ... s: http.StatusBadRequest, .. gültiger Wert %q (erlaubt: low, nor ... .. mal, high)", req.Priority)) ... 90 return 124 errMsg: fmt.Sprintf("pr .. ... iority: ungültiger Wert %q (erlaubt .. ... : low, normal, high)", req.Priority .. ... )} 91 } 125 } 92 if req.Provider != "" && !h.kno 126 if req.Provider != "" && !h.kno .. wn[req.Provider] { ... wn[req.Provider] { 93 writeError(w, http.StatusBa 127 return speakAdmission{statu .. dRequest, fmt.Sprintf("provider: un ... s: http.StatusBadRequest, .. bekannter Wert %q", req.Provider)) ... 94 return 128 errMsg: fmt.Sprintf("pr .. ... ovider: unbekannter Wert %q", req.P .. ... rovider)} 95 } 129 } 96 if req.Lang != "" && req.Lang ! 130 if req.Lang != "" && req.Lang ! .. = "de" && req.Lang != "en" { ... = "de" && req.Lang != "en" { 97 writeError(w, http.StatusBa 131 return speakAdmission{statu .. dRequest, fmt.Sprintf("lang: ungült ... s: http.StatusBadRequest, .. iger Wert %q (erlaubt: de, en)", re ... .. q.Lang)) ... 98 return 132 errMsg: fmt.Sprintf("la .. ... ng: ungültiger Wert %q (erlaubt: de .. ... , en)", req.Lang)} 99 } 133 } 100 134 101 if h.mute.Muted() { 135 if h.mute.Muted() { 102 writeJSON(w, http.StatusAcc 136 return speakAdmission{statu ... epted, SpeakResponse{Muted: true}) ... s: http.StatusAccepted, resp: Speak ... ... Response{Muted: true}} 103 return ... 104 } 137 } 105 138 106 adm, err := h.queue.Enqueue(que 139 adm, err := h.queue.Enqueue(que ... ue.Item{ ... ue.Item{ internal/api/server.go --- 5/7 --- Go 112 Now: req.Now, 145 Now: req.Now, 113 }) 146 }) 114 if err != nil { 147 if err != nil { 115 writeError(w, http.StatusSe 148 return speakAdmission{statu ... rviceUnavailable, "enqueue: "+err.E ... s: http.StatusServiceUnavailable, e ... rror()) ... rrMsg: "enqueue: " + err.Error()} 116 return ... 117 } 149 } 118 if adm.Discarded != "" { 150 if adm.Discarded != "" { 119 // Gesetz-Verwurf (Rate-Lim 151 // Gesetz-Verwurf (Rate-Lim ... it-Budget / Ruhezeit): dokumentiert ... it-Budget / Ruhezeit): dokumentiert ... es ... es 120 // Verhalten wie bei Mute — 152 // Verhalten wie bei Mute — ... 202 mit Grund statt Fehler, damit ... 202 mit Grund statt Fehler, damit 121 // Aufrufer keine Sonderbeh 153 // Aufrufer keine Sonderbeh ... andlung brauchen. ... andlung brauchen. 122 writeJSON(w, http.StatusAcc 154 return speakAdmission{statu ... epted, SpeakResponse{Discarded: adm ... s: http.StatusAccepted, ... .Discarded, Toast: adm.Toast}) ... 123 return 155 resp: SpeakResponse{Dis ... ... carded: adm.Discarded, Toast: adm.T ... ... oast}} 124 } 156 } 125 writeJSON(w, http.StatusAccepte 157 return speakAdmission{status: h ... d, SpeakResponse{ID: adm.ID}) ... ttp.StatusAccepted, resp: SpeakResp ... ... onse{ID: adm.ID}} 126 } 158 } 127 159 128 // status behandelt GET /status: Qu 160 // status behandelt GET /status: Qu ... eue-Länge (+ wartende IDs), aktuell ... eue-Länge (+ wartende IDs), aktuell ... es ... es 129 // Item, Mute-State, aktiver Provid 161 // Item, Mute-State, aktiver Provid ... er, Version — und seit Task 05 den ... er, Version — und seit Task 05 den internal/api/server.go --- 6/7 --- Go 139 171 Queue: len(pending), 140 172 QueueIDs: ids, 141 173 Muted: h.mute.Muted(), ... 174 MCP: true, ... 175 Cors: corsList(h.allowedOrigins), 142 176 } 143 177 if cur, ok := h.queue.Current(); ok { 144 178 st.Current = &CurrentItem{ internal/api/server.go --- 7/7 --- Go 155 189 writeJSON(w, http.StatusOK, st) 156 190 } ... 191 ... 192 // corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status ... 193 // soll "cors": [] zeigen, wenn die Tür zu ist. ... 194 func corsList(origins []string) []string { ... 195 out := make([]string, len(origins)) ... 196 copy(out, origins) ... 197 return out ... 198 } 157 199 158 200 // lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von 159 201 // /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot internal/api/types.go --- 1/2 --- Go 55 // Item (null im Leerlauf), Mute-Sta 55 // Item (null im Leerlauf), Mute-Sta .. te, aktiver Provider, Daemon-Version .. te, aktiver Provider, Daemon-Version .. . .. . 56 // QueueIDs ist die Ergänzung der wa 56 // QueueIDs ist die Ergänzung der wa .. rtenden IDs in Abarbeitungs-Reihenfo .. rtenden IDs in Abarbeitungs-Reihenfo .. lge .. lge 57 // — die CLI braucht sie für ihr --w 57 // — die CLI braucht sie für ihr --w .. ait-Polling. Task 05 ergänzt additiv .. ait-Polling. Task 05 ergänzt additiv 58 // den Gesetz-State (rate_limit, qui 58 // den Gesetz-State (rate_limit, qui .. et_hours). .. et_hours); Task 06 additiv mcp/cors .. 59 // (Compat-Felder: MCP-Endpoint vorh .. .. anden, konfigurierte CORS-Origins). 59 type StatusResponse struct { 60 type StatusResponse struct { 60 Version string `jso 61 Version string `jso .. n:"version"` .. n:"version"` 61 Queue int `jso 62 Queue int `jso .. n:"queue"` .. n:"queue"` internal/api/types.go --- 2/2 --- Go 66 67 RateLimit *RateLimitState `json:"rate_limit,omitempty"` 67 68 QuietHours *QuietHoursState `json:"quiet_hours,omitempty"` .. 69 .. 70 // MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true; .. 71 // Feld existiert für additive Compat-Sicht von Clients/Browsern). .. 72 MCP bool `json:"mcp"` .. 73 .. 74 // Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak .. 75 // (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty. .. 76 Cors []string `json:"cors"` 68 77 } 69 78 70 79 // RateLimitState ist der Rate-Limit-State in GET /status (Task 05). internal/config/config.go --- 1/5 --- Go 6 6 "os" 7 7 "path/filepath" 8 8 "strconv" . 9 "strings" 9 10 "time" 10 11 11 12 "github.com/BurntSushi/toml" internal/config/config.go --- 2/5 --- Go 86 87 // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800. 87 88 Port int `toml:"port"` .. 89 .. 90 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06): .. 91 // exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*" .. 92 // erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne .. 93 // Einträge sendet der Daemon gar keine CORS-Header und der Browser .. 94 // blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert .. 95 // wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert). .. 96 AllowedOrigins []string `toml:"allowed_origins"` 88 97 } 89 98 90 99 // Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`). internal/config/config.go --- 3/5 --- Go 178 187 } 179 188 cfg.Server.Port = port 180 189 } ... 190 if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" { ... 191 if v == "off" { ... 192 cfg.Server.AllowedOrigins = nil ... 193 } else { ... 194 origins, err := splitOrigins(v) ... 195 if err != nil { ... 196 return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err) ... 197 } ... 198 cfg.Server.AllowedOrigins = origins ... 199 } ... 200 } 181 201 if v := getenv("TALK_RATE_LIMIT"); v != "" { 182 202 d, err := time.ParseDuration(v) 183 203 if err != nil || d < 0 { internal/config/config.go --- 4/5 --- Go 215 235 return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", 216 236 cfg.Audio.Sink, SinkOto, SinkNull) 217 237 } ... 238 // allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine ... 239 // leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber ... 240 // substanziell sein — klarer Fehler statt stiller Ignoranz. ... 241 for _, o := range cfg.Server.AllowedOrigins { ... 242 if strings.TrimSpace(o) == "" { ... 243 return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins) ... 244 } ... 245 } 218 246 // quiet_hours aus der Datei validieren (Env wurde oben geprüft) und 219 247 // quiet_mode leer (Datei ohne Wert) auf den Default heben. 220 248 if cfg.QuietHours != "" { internal/config/config.go --- 5/5 --- Go 228 256 return cfg, nil 229 257 } 258 259 // splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von 260 // server.allowed_origins, z. B. "https://a.example, https://b.example"). 261 // Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler. 262 func splitOrigins(v string) ([]string, error) { 263 parts := strings.Split(v, ",") 264 origins := make([]string, 0, len(parts)) 265 for _, p := range parts { 266 p = strings.TrimSpace(p) 267 if p == "" { 268 return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v) 269 } 270 origins = append(origins, p) 271 } 272 return origins, nil 273 } internal/config/config_test.go --- Go 356 356 }) 357 357 } ... 358 ... 359 func TestLoadAllowedOrigins(t *testing.T) { ... 360 writeTOML := func(t *testing.T, content string) string { ... 361 t.Helper() ... 362 path := filepath.Join(t.TempDir(), "config.toml") ... 363 if err := os.WriteFile(path, []byte(content), 0o644); err != nil { ... 364 t.Fatal(err) ... 365 } ... 366 return path ... 367 } ... 368 ... 369 t.Run("Default: leere Liste (kein CORS)", func(t *testing.T) { ... 370 cfg, err := Load(Options{ ... 371 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), ... 372 Getenv: env(nil), ... 373 }) ... 374 if err != nil { ... 375 t.Fatalf("Load: %v", err) ... 376 } ... 377 if len(cfg.Server.AllowedOrigins) != 0 { ... 378 t.Errorf("AllowedOrigins = %v, want leer (Tür zu)", cfg.Server.AllowedOrigins) ... 379 } ... 380 }) ... 381 ... 382 t.Run("TOML-Liste", func(t *testing.T) { ... 383 cfg, err := Load(Options{ ... 384 File: writeTOML(t, "[server]\nallowed_origins = [\"https://chat.az-gruppe.com\"]\n"), ... 385 Getenv: env(nil), ... 386 }) ... 387 if err != nil { ... 388 t.Fatalf("Load: %v", err) ... 389 } ... 390 want := []string{"https://chat.az-gruppe.com"} ... 391 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { ... 392 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) ... 393 } ... 394 }) ... 395 ... 396 t.Run("TALK_ALLOWED_ORIGINS schlägt Datei (mehrere, mit Leerzeichen)", func(t *testing.T) { ... 397 cfg, err := Load(Options{ ... 398 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), ... 399 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "https://chat.az-gruppe.com, https://grid.ag"}), ... 400 }) ... 401 if err != nil { ... 402 t.Fatalf("Load: %v", err) ... 403 } ... 404 want := []string{"https://chat.az-gruppe.com", "https://grid.ag"} ... 405 if strings.Join(cfg.Server.AllowedOrigins, "|") != strings.Join(want, "|") { ... 406 t.Errorf("AllowedOrigins = %v, want %v", cfg.Server.AllowedOrigins, want) ... 407 } ... 408 }) ... 409 ... 410 t.Run("TALK_ALLOWED_ORIGINS=off leert die Datei-Liste", func(t *testing.T) { ... 411 cfg, err := Load(Options{ ... 412 File: writeTOML(t, "[server]\nallowed_origins = [\"https://datei.example\"]\n"), ... 413 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "off"}), ... 414 }) ... 415 if err != nil { ... 416 t.Fatalf("Load: %v", err) ... 417 } ... 418 if len(cfg.Server.AllowedOrigins) != 0 { ... 419 t.Errorf("AllowedOrigins = %v, want leer (off)", cfg.Server.AllowedOrigins) ... 420 } ... 421 }) ... 422 ... 423 t.Run("Wildcard ist erlaubter Wert", func(t *testing.T) { ... 424 cfg, err := Load(Options{ ... 425 File: filepath.Join(t.TempDir(), "gibt-es-nicht.toml"), ... 426 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": "*"}), ... 427 }) ... 428 if err != nil { ... 429 t.Fatalf("Load: %v", err) ... 430 } ... 431 if strings.Join(cfg.Server.AllowedOrigins, "|") != "*" { ... 432 t.Errorf("AllowedOrigins = %v, want [*]", cfg.Server.AllowedOrigins) ... 433 } ... 434 }) ... 435 ... 436 t.Run("leere Einträge sind klare Fehler", func(t *testing.T) { ... 437 for _, tc := range []struct { ... 438 name string ... 439 file string ... 440 envv string ... 441 }{ ... 442 {"env", "", "https://a.example,,https://b.example"}, ... 443 {"datei", "[server]\nallowed_origins = [\"https://a.example\", \"\"]\n", ""}, ... 444 } { ... 445 t.Run(tc.name, func(t *testing.T) { ... 446 file := filepath.Join(t.TempDir(), "gibt-es-nicht.toml") ... 447 if tc.file != "" { ... 448 file = writeTOML(t, tc.file) ... 449 } ... 450 _, err := Load(Options{ ... 451 File: file, ... 452 Getenv: env(map[string]string{"TALK_ALLOWED_ORIGINS": tc.envv}), ... 453 }) ... 454 if err == nil || !strings.Contains(err.Error(), "allowed_origins") && !strings.Contains(err.Error(), "ALLOWED_ORIGINS") { ... 455 t.Fatalf("Fehler = %v, will klaren allowed_origins-Fehler", err) ... 456 } ... 457 }) ... 458 } ... 459 }) ... 460 } 358 461 359 462 // TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s, 360 463 // "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus; test/cors_test.go --- Go 1 package test 2 3 // Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und 4 // echter POST auf /speak mit Headern aus der Config-Liste 5 // TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests. 6 // Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine 7 // CORS-Header (der Browser blockt); CORS nur auf /speak. 8 9 import ( 10 "net/http" 11 "testing" 12 ) 13 14 const azOrigin = "https://chat.az-gruppe.com" 15 16 // TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter 17 // Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE 18 // Access-Control-Allow-Origin. 19 func TestCORSPraeflightGelisted(t *testing.T) { 20 root := t.TempDir() 21 fake := newFakeProvider(t, 0) 22 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 23 "TALK_ALLOWED_ORIGINS": azOrigin, 24 }) 25 26 raw := preflight(t, d, azOrigin) 27 if raw.status != http.StatusNoContent { 28 t.Fatalf("Preflight-Status = %d, want 204", raw.status) 29 } 30 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { 31 t.Errorf("ACAO = %q, want %q", got, azOrigin) 32 } 33 if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" { 34 t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS") 35 } 36 if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" { 37 t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type") 38 } 39 if vary := raw.header.Get("Vary"); vary != "Origin" { 40 t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary) 41 } 42 43 // Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header — 44 // der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar. 45 raw = preflight(t, d, "https://evil.example") 46 if raw.status != http.StatusNoContent { 47 t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status) 48 } 49 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { 50 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) 51 } 52 } 53 54 // TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO, 55 // wenn die Anfrage-Origin gelistet ist — sonst nicht. 56 func TestCORSPostGelisted(t *testing.T) { 57 root := t.TempDir() 58 fake := newFakeProvider(t, 0) 59 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 60 "TALK_ALLOWED_ORIGINS": azOrigin, 61 }) 62 63 raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ 64 "Origin": azOrigin, 65 "Content-Type": "application/json", 66 }, `{"text":"browser sagt hallo"}`) 67 if raw.status != http.StatusAccepted { 68 t.Fatalf("POST /speak Status = %d, want 202", raw.status) 69 } 70 if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin { 71 t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin) 72 } 73 fake.waitCompleted(t, 1) 74 75 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ 76 "Origin": "https://evil.example", 77 "Content-Type": "application/json", 78 }, `{"text":"böser browser"}`) 79 if raw.status != http.StatusAccepted { 80 t.Fatalf("POST /speak (ungelisted) Status = %d, want 202 (HTTP bleibt sauber)", raw.status) 81 } 82 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { 83 t.Errorf("ACAO für ungeliste Origin = %q, want leer", got) 84 } 85 } 86 87 // TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon 88 // GAR KEINE CORS-Header — Tür zu, der Browser blockt. 89 func TestCORSLeereListe(t *testing.T) { 90 root := t.TempDir() 91 fake := newFakeProvider(t, 0) 92 d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert 93 94 raw := preflight(t, d, azOrigin) 95 if raw.status != http.StatusNoContent { 96 t.Fatalf("Preflight-Status = %d, want 204", raw.status) 97 } 98 for _, h := range []string{ 99 "Access-Control-Allow-Origin", 100 "Access-Control-Allow-Methods", 101 "Access-Control-Allow-Headers", 102 } { 103 if got := raw.header.Get(h); got != "" { 104 t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got) 105 } 106 } 107 108 raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{ 109 "Origin": azOrigin, 110 "Content-Type": "application/json", 111 }, `{"text":"ohne cors"}`) 112 if raw.status != http.StatusAccepted { 113 t.Fatalf("POST /speak Status = %d, want 202", raw.status) 114 } 115 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { 116 t.Errorf("ACAO auf POST bei leerer Liste = %q, want leer", got) 117 } 118 } 119 120 // TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich — 121 // ACAO ist dann das literale "*". 122 func TestCORSWildcard(t *testing.T) { 123 root := t.TempDir() 124 fake := newFakeProvider(t, 0) 125 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 126 "TALK_ALLOWED_ORIGINS": "*", 127 }) 128 129 for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} { 130 raw := preflight(t, d, origin) 131 if raw.status != http.StatusNoContent { 132 t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status) 133 } 134 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" { 135 t.Errorf("ACAO von %s = %q, want *", origin, got) 136 } 137 } 138 } 139 140 // TestCORSNurAufSpeak: Kein anderer Endpunkt trägt CORS-Header — OPTIONS 141 // auf /status ist 405 (kein Preflight-Support), GET /status ohne ACAO. 142 func TestCORSNurAufSpeak(t *testing.T) { 143 root := t.TempDir() 144 fake := newFakeProvider(t, 0) 145 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 146 "TALK_ALLOWED_ORIGINS": azOrigin, 147 }) 148 149 raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{ 150 "Origin": azOrigin, 151 "Access-Control-Request-Method": http.MethodGet, 152 }, "") 153 if raw.status != http.StatusMethodNotAllowed { 154 t.Fatalf("OPTIONS /status Status = %d, want 405 (kein CORS-Support)", raw.status) 155 } 156 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { 157 t.Errorf("OPTIONS /status ACAO = %q, want leer", got) 158 } 159 160 raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{ 161 "Origin": azOrigin, 162 }, "") 163 if raw.status != http.StatusOK { 164 t.Fatalf("GET /status Status = %d, want 200", raw.status) 165 } 166 if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" { 167 t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got) 168 } 169 } 170 171 // TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld — 172 // gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06). 173 func TestStatusCorsFeld(t *testing.T) { 174 t.Run("gelistet", func(t *testing.T) { 175 root := t.TempDir() 176 fake := newFakeProvider(t, 0) 177 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 178 "TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag", 179 }) 180 st := getStatus(t, d) 181 if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" { 182 t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors) 183 } 184 }) 185 186 t.Run("leer = [] (nicht null)", func(t *testing.T) { 187 root := t.TempDir() 188 fake := newFakeProvider(t, 0) 189 d := startDaemon(t, root, fake.srv.URL, nil) 190 st := getStatus(t, d) 191 if st.Cors == nil || len(st.Cors) != 0 { 192 t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors) 193 } 194 }) 195 } 196 test/harness_test.go --- 1/3 --- Go 13 13 "bytes" 14 14 "encoding/json" 15 15 "fmt" .. 16 "io" 16 17 "net" 17 18 "net/http" 18 19 "net/http/httptest" test/harness_test.go --- 2/3 --- Go 403 404 } `json:"current"` 404 405 Muted bool `json:"muted"` 405 406 Provider string `json:"provider"` ... 407 MCP bool `json:"mcp"` ... 408 Cors []string `json:"cors"` 406 409 } 407 410 408 411 // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist test/harness_test.go --- 3/3 --- Go 472 475 return resp.StatusCode, m 473 476 } 477 478 // rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header, 479 // Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests. 480 type rawResponse struct { 481 status int 482 header http.Header 483 body string 484 } 485 486 // doRequest macht einen rohen HTTP-Request mit beliebigen Headern und 487 // optionalem Body und liest die Antwort komplett (CORS-Preflights und 488 // MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen). 489 func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse { 490 t.Helper() 491 var rd io.Reader 492 if body != "" { 493 rd = strings.NewReader(body) 494 } 495 req, err := http.NewRequest(method, url, rd) 496 if err != nil { 497 t.Fatalf("%s %s bauen: %v", method, url, err) 498 } 499 for k, v := range headers { 500 req.Header.Set(k, v) 501 } 502 resp, err := http.DefaultClient.Do(req) 503 if err != nil { 504 t.Fatalf("%s %s: %v", method, url, err) 505 } 506 defer resp.Body.Close() 507 raw, err := io.ReadAll(resp.Body) 508 if err != nil { 509 t.Fatalf("%s %s Antwort lesen: %v", method, url, err) 510 } 511 return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)} 512 } 513 514 // preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser 515 // es vor einem echten POST tut). 516 func preflight(t *testing.T, d *daemonProc, origin string) rawResponse { 517 t.Helper() 518 return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{ 519 "Origin": origin, 520 "Access-Control-Request-Method": http.MethodPost, 521 }, "") 522 } 523 524 // mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp. 525 func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse { 526 t.Helper() 527 return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{ 528 "Content-Type": "application/json", 529 }, payload) 530 } 531 532 // mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für 533 // Feld-Zugriffe der literalen Payload-Tests). 534 func mcpDecode(t *testing.T, raw rawResponse) map[string]any { 535 t.Helper() 536 var m map[string]any 537 if err := json.Unmarshal([]byte(raw.body), &m); err != nil { 538 t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body) 539 } 540 return m 541 } test/mcp_test.go --- Go 1 package test 2 3 // Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette 4 // Handshake (initialize → notifications/initialized → tools/list → 5 // tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd — 6 // literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak 7 // verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz. 8 9 import ( 10 "fmt" 11 "net/http" 12 "strings" 13 "testing" 14 "time" 15 ) 16 17 // mcpToolText liest content[0].text aus einem tools/call-Ergebnis. 18 func mcpToolText(t *testing.T, m map[string]any) string { 19 t.Helper() 20 result, ok := m["result"].(map[string]any) 21 if !ok { 22 t.Fatalf("tools/call ohne result: %v", m) 23 } 24 content, ok := result["content"].([]any) 25 if !ok || len(content) == 0 { 26 t.Fatalf("tools/call ohne content: %v", result) 27 } 28 first, ok := content[0].(map[string]any) 29 if !ok { 30 t.Fatalf("content[0] ist kein Objekt: %v", content[0]) 31 } 32 text, _ := first["text"].(string) 33 return text 34 } 35 36 // mcpIsError liest isError (fehlt = false — bewusste Semantik). 37 func mcpIsError(m map[string]any) bool { 38 if result, ok := m["result"].(map[string]any); ok { 39 if e, ok := result["isError"].(bool); ok { 40 return e 41 } 42 } 43 return false 44 } 45 46 // TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize → 47 // notifications/initialized → tools/list → tools/call sieht das speak-Tool 48 // und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider. 49 func TestMCPHandshake(t *testing.T) { 50 root := t.TempDir() 51 fake := newFakeProvider(t, 0) 52 d := startDaemon(t, root, fake.srv.URL, nil) 53 54 // 1. initialize: angefragte Version wird geechot; capabilities nennen 55 // tools; serverInfo ist talkd. 56 raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`) 57 if raw.status != http.StatusOK { 58 t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body) 59 } 60 if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { 61 t.Errorf("initialize Content-Type = %q, want application/json", ct) 62 } 63 m := mcpDecode(t, raw) 64 if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" { 65 t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m) 66 } 67 result, ok := m["result"].(map[string]any) 68 if !ok { 69 t.Fatalf("initialize ohne result: %v", m) 70 } 71 if result["protocolVersion"] != "2025-06-18" { 72 t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"]) 73 } 74 caps, ok := result["capabilities"].(map[string]any) 75 if !ok { 76 t.Fatalf("capabilities = %v, want Objekt", result["capabilities"]) 77 } 78 if _, ok := caps["tools"]; !ok { 79 t.Errorf("capabilities ohne tools: %v", caps) 80 } 81 serverInfo, _ := result["serverInfo"].(map[string]any) 82 if serverInfo["name"] != "talkd" { 83 t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"]) 84 } 85 if v, _ := serverInfo["version"].(string); v == "" { 86 t.Error("serverInfo.version ist leer") 87 } 88 89 // Andere angefragte Version wird ebenso geechot (akzeptiert). 90 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`) 91 m = mcpDecode(t, raw) 92 if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" { 93 t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"]) 94 } 95 96 // 2. notifications/initialized: Notification → 202 ohne Body. 97 raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) 98 if raw.status != http.StatusAccepted { 99 t.Fatalf("notifications/initialized: Status %d, want 202", raw.status) 100 } 101 if raw.body != "" { 102 t.Errorf("notifications/initialized Body = %q, want leer", raw.body) 103 } 104 105 // 3. tools/list: genau das speak-Tool mit dem Brief-Schema. 106 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`) 107 m = mcpDecode(t, raw) 108 result = m["result"].(map[string]any) 109 tools, ok := result["tools"].([]any) 110 if !ok || len(tools) != 1 { 111 t.Fatalf("tools = %v, want genau ein Tool", result["tools"]) 112 } 113 tool := tools[0].(map[string]any) 114 if tool["name"] != "speak" { 115 t.Errorf("tool.name = %v, want speak", tool["name"]) 116 } 117 schema, ok := tool["inputSchema"].(map[string]any) 118 if !ok { 119 t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"]) 120 } 121 if schema["type"] != "object" { 122 t.Errorf("inputSchema.type = %v, want object", schema["type"]) 123 } 124 props := schema["properties"].(map[string]any) 125 for _, name := range []string{"text", "priority", "provider", "lang", "now"} { 126 if _, ok := props[name]; !ok { 127 t.Errorf("inputSchema ohne property %q: %v", name, props) 128 } 129 } 130 if len(props) != 5 { 131 t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props) 132 } 133 if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" { 134 t.Errorf("required = %v, want [text]", schema["required"]) 135 } 136 137 // 4. tools/call: Aufnahme wie POST /speak — "queued: " und die 138 // Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg). 139 raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`) 140 if raw.status != http.StatusOK { 141 t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body) 142 } 143 m = mcpDecode(t, raw) 144 if fmt.Sprint(m["id"]) != "4" { 145 t.Errorf("tools/call id = %v, want Echo 4", m["id"]) 146 } 147 text := mcpToolText(t, m) 148 if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") { 149 t.Fatalf("tools/call text = %q, want \"queued: \"", text) 150 } 151 if mcpIsError(m) { 152 t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m) 153 } 154 hits := fake.waitCompleted(t, 1) 155 if hits[0].Text != "hallo über mcp" { 156 t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text) 157 } 158 } 159 160 // TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE 161 // (isError:false mit erklärendem Text), keine Protokollfehler — und die 162 // Ansage bleibt stumm. 163 func TestMCPToolsCallGesetze(t *testing.T) { 164 t.Run("muted", func(t *testing.T) { 165 root := t.TempDir() 166 fake := newFakeProvider(t, 0) 167 d := startDaemon(t, root, fake.srv.URL, nil) 168 postPath(t, d, "/mute") 169 170 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`)) 171 if mcpIsError(m) { 172 t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m) 173 } 174 if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") { 175 t.Errorf("text = %q, will muted-Erklärung", text) 176 } 177 fake.assertNoHits(t, 300*time.Millisecond) 178 }) 179 180 t.Run("rate_limit verwirft low", func(t *testing.T) { 181 root := t.TempDir() 182 fake := newFakeProvider(t, 0) 183 d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"}) 184 185 // Erste low-Ansage spielt (Fenster frei) und bucht es. 186 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`)) 187 if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") { 188 t.Fatalf("erste low: text = %q, want queued", text) 189 } 190 fake.waitCompleted(t, 1) 191 192 // Zweite low innerhalb des Fensters: Verwurf als Ergebnis. 193 m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`)) 194 if mcpIsError(m) { 195 t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m) 196 } 197 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") { 198 t.Errorf("text = %q, want discarded: rate_limit …", text) 199 } 200 fake.assertNoFurtherArrivals(t, 300*time.Millisecond) 201 }) 202 203 t.Run("quiet_hours verwirft", func(t *testing.T) { 204 root := t.TempDir() 205 fake := newFakeProvider(t, 0) 206 d := startDaemon(t, root, fake.srv.URL, map[string]string{ 207 "TALK_QUIET_HOURS": fensterUm(), 208 }) 209 210 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`)) 211 if mcpIsError(m) { 212 t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m) 213 } 214 if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") { 215 t.Errorf("text = %q, want discarded: quiet_hours …", text) 216 } 217 fake.assertNoHits(t, 300*time.Millisecond) 218 }) 219 } 220 221 // TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602), 222 // unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version 223 // und Batches -32600, GET /mcp 405 (keine server-initiierten Streams). 224 func TestMCPFehlerPfade(t *testing.T) { 225 root := t.TempDir() 226 fake := newFakeProvider(t, 0) 227 d := startDaemon(t, root, fake.srv.URL, nil) 228 229 cases := []struct { 230 name string 231 payload string 232 code float64 233 msgPart string 234 }{ 235 {"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"}, 236 {"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"}, 237 {"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"}, 238 {"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"}, 239 {"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"}, 240 {"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"}, 241 {"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"}, 242 {"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"}, 243 } 244 for _, tc := range cases { 245 t.Run(tc.name, func(t *testing.T) { 246 raw := mcpPost(t, d, tc.payload) 247 if raw.status != http.StatusOK { 248 t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status) 249 } 250 m := mcpDecode(t, raw) 251 rpcErr, ok := m["error"].(map[string]any) 252 if !ok { 253 t.Fatalf("kein error-Objekt: %v", m) 254 } 255 if rpcErr["code"] != tc.code { 256 t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body) 257 } 258 if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) { 259 t.Errorf("message = %q, will Teil %q", msg, tc.msgPart) 260 } 261 if _, hasResult := m["result"]; hasResult { 262 t.Errorf("error-Antwort mit result: %v", m) 263 } 264 }) 265 } 266 267 t.Run("ping", func(t *testing.T) { 268 m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`)) 269 if _, ok := m["result"]; !ok { 270 t.Fatalf("ping ohne result: %v", m) 271 } 272 }) 273 274 t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) { 275 raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "") 276 if raw.status != http.StatusMethodNotAllowed { 277 t.Fatalf("GET /mcp Status = %d, want 405", raw.status) 278 } 279 if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") { 280 t.Errorf("Allow = %q, will POST nennen", allow) 281 } 282 }) 283 284 t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) { 285 raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`) 286 if raw.status != http.StatusAccepted || raw.body != "" { 287 t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body) 288 } 289 }) 290 } 291 292 // TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06). 293 func TestStatusMCPFeld(t *testing.T) { 294 root := t.TempDir() 295 fake := newFakeProvider(t, 0) 296 d := startDaemon(t, root, fake.srv.URL, nil) 297 if st := getStatus(t, d); !st.MCP { 298 t.Error("mcp = false, want true (Endpoint ist vorhanden)") 299 } 300 } 301