Files
talk/internal/api/mcp.go
T

372 lines
13 KiB
Go
Raw Normal View History

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, &params); 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, &params); 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: <id>".
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)
}