diff --git a/internal/api/mcp.go b/internal/api/mcp.go new file mode 100644 index 0000000..6afbd1a --- /dev/null +++ b/internal/api/mcp.go @@ -0,0 +1,371 @@ +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: + return mcpToolResult{Content: []mcpContent{{ + Type: "text", + Text: "queued: " + resp.ID, + }}} + } +} + +// 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) +}