package api import ( "encoding/json" "io" "net/http" "strconv" "strings" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene, // schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich // braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier // Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei // dokumentierten Vereinfachungen, die der Transport hergibt: // // - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs // (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung // der Initialize-vor-allem-Regel — ein curl kann tools/call direkt // rufen. MCP-Clients stört das nicht: Sie fahren den Handshake // freiwillig. // - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste // ist statisch). GET /mcp antwortet daher 405 — die vom Streamable- // HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream. // // Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte // ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202 // ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht // mehr Teil von MCP → -32600. // mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der // Client keine angibt (initialize ohne protocolVersion). Angefragte // Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich // implementiert ist der oben dokumentierte 2025-06-18-Umfang. const mcpProtocolVersion = "2025-06-18" // JSON-RPC-2.0-Fehlercodes (benutztes Subset). const ( rpcParseError = -32700 rpcInvalidRequest = -32600 rpcMethodNotFound = -32601 rpcInvalidParams = -32602 rpcInternalError = -32603 ) // mcpServerName ist der serverInfo-Name im Initialize-Ergebnis. const mcpServerName = "talkd" // jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als // RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0 // bzw. id:string unterscheidbar. type jsonRPCRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } // jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus // Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern // ohne bekannte ID null (JSON-RPC 2.0). type jsonRPCResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } // rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort. type rpcError struct { Code int `json:"code"` Message string `json:"message"` } // mcpInitializeParams sind die relevanten Teile der initialize-Parameter // (Client-Capabilities interessieren talkd nicht — Tools genügen). type mcpInitializeParams struct { ProtocolVersion string `json:"protocolVersion"` } // mcpServerInfo beschreibt den Server im Initialize-Ergebnis. type mcpServerInfo struct { Name string `json:"name"` Version string `json:"version"` } // mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet: // Tools (speak) — sonst nichts. type mcpCapabilities struct { Tools map[string]any `json:"tools"` } // mcpInitializeResult ist das Ergebnis von initialize: die (geechote) // Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form. type mcpInitializeResult struct { ProtocolVersion string `json:"protocolVersion"` Capabilities mcpCapabilities `json:"capabilities"` ServerInfo mcpServerInfo `json:"serverInfo"` } // mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool // (speak); ohne nextCursor (die Liste passt immer in eine Seite). type mcpToolsListResult struct { Tools []mcpTool `json:"tools"` } // mcpTool ist ein Tool-Eintrag in tools/list. type mcpTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema mcpSchema `json:"inputSchema"` } // mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs. type mcpSchema struct { Type string `json:"type"` Properties map[string]mcpProperty `json:"properties"` Required []string `json:"required"` } // mcpProperty ist eine einzelne Schema-Property. type mcpProperty struct { Type string `json:"type"` Description string `json:"description,omitempty"` Enum []string `json:"enum,omitempty"` } // mcpToolCallParams sind die Parameter von tools/call. type mcpToolCallParams struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } // mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text). type mcpContent struct { Type string `json:"type"` Text string `json:"text"` } // mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen) // auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein // Protokollfehler (bewusste Entscheidung, siehe speakToolResult). type mcpToolResult struct { Content []mcpContent `json:"content"` IsError bool `json:"isError,omitempty"` } // serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request. func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) if err != nil { writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error()) return } if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") { writeRPCError(w, nil, rpcInvalidRequest, "Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST") return } var req jsonRPCRequest if err := json.Unmarshal(body, &req); err != nil { writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error()) return } if req.JSONRPC != "2.0" { writeRPCError(w, nil, rpcInvalidRequest, `jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`) return } if isNotification(req.ID) { // Notifications (notifications/initialized, …) bekommen nie eine // JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte // Notifications werden still ignoriert (JSON-RPC 2.0). w.WriteHeader(http.StatusAccepted) return } switch req.Method { case "initialize": h.mcpInitialize(w, req) case "notifications/initialized": // Mit ID gesendet: kein regulärer Client — die korrekte Antwort // ist ein leeres Ergebnis, kein Fehler. writeRPCResult(w, req.ID, struct{}{}) case "ping": writeRPCResult(w, req.ID, struct{}{}) case "tools/list": writeRPCResult(w, req.ID, mcpToolsListResult{Tools: []mcpTool{speakTool()}}) case "tools/call": h.mcpToolsCall(w, req) default: writeRPCError(w, req.ID, rpcMethodNotFound, "Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)") } } // mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine // server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP- // Antwort), Tools-Änderungen gibt es nicht. func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) { w.Header().Set("Allow", "POST, OPTIONS") w.WriteHeader(http.StatusMethodNotAllowed) } // mcpInitialize behandelt initialize: Die angefragte Protokollversion wird // geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities: // Tools; serverInfo: talkd + Daemon-Version. func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) { var params mcpInitializeParams if len(req.Params) > 0 { if err := json.Unmarshal(req.Params, ¶ms); err != nil { writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error()) return } } version := params.ProtocolVersion if version == "" { version = mcpProtocolVersion } writeRPCResult(w, req.ID, mcpInitializeResult{ ProtocolVersion: version, Capabilities: mcpCapabilities{Tools: map[string]any{}}, ServerInfo: mcpServerInfo{Name: mcpServerName, Version: h.version}, }) } // mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen // Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive // Mute und Gesetz (Rate-Limit/Ruhezeit). func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) { var params mcpToolCallParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error()) return } if params.Name != "speak" { writeRPCError(w, req.ID, rpcInvalidParams, "unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)") return } args := params.Arguments if len(args) == 0 { args = json.RawMessage("{}") } var speakReq SpeakRequest if err := json.Unmarshal(args, &speakReq); err != nil { writeRPCError(w, req.ID, rpcInvalidParams, "arguments: "+err.Error()) return } adm := h.admitSpeak(speakReq) if adm.status != http.StatusAccepted { // Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als // JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis. code := rpcInvalidParams if adm.status != http.StatusBadRequest { code = rpcInternalError } writeRPCError(w, req.ID, code, adm.errMsg) return } writeRPCResult(w, req.ID, speakToolResult(adm.resp)) } // speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem // Task-06-Brief (text Pflicht; priority/provider/lang/now optional). func speakTool() mcpTool { return mcpTool{ Name: "speak", Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " + "Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " + "Queue, Rate-Limit, Mute und Ruhezeiten gelten.", InputSchema: mcpSchema{ Type: "object", Properties: map[string]mcpProperty{ "text": { Type: "string", Description: "Der Ansage-Text (1–2 Sätze Etikette)", }, "priority": { Type: "string", Enum: []string{"low", "normal", "high"}, Description: "Abarbeitungs-Priorität (Default normal)", }, "provider": { Type: "string", Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette", }, "lang": { Type: "string", Enum: []string{"de", "en"}, Description: "Sprache pin; leer = automatische Erkennung", }, "now": { Type: "boolean", Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus", }, }, Required: []string{"text"}, }, } } // speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool- // Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind // das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler) // — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme: // "queued: ". func speakToolResult(resp SpeakResponse) mcpToolResult { switch { case resp.Muted: return mcpToolResult{Content: []mcpContent{{ Type: "text", Text: "muted: Ansage verworfen (Mute ist aktiv)", }}} case resp.Discarded == queue.DiscardRateLimit: return mcpToolResult{Content: []mcpContent{{ Type: "text", Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)", }}} case resp.Discarded == queue.DiscardQuietHours: text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen" if resp.Toast { text += " (durch Toast ersetzt, kein Audio)" } return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} case resp.Discarded != "": return mcpToolResult{Content: []mcpContent{{ Type: "text", Text: "discarded: " + resp.Discarded, }}} default: text := "queued: " + resp.ID if resp.Truncated { text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)" } return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} } } // isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt // oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort). func isNotification(id json.RawMessage) bool { return len(id) == 0 || strings.TrimSpace(string(id)) == "null" } // writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200, // application/json). func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result}) } // writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die // Anfrage-ID unbekannt ist (Parse-Fehler). func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) { writeRPC(w, jsonRPCResponse{ JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}, }) } // writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer. func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(resp) }