Files
talk/internal/api/mcp.go
T
m3ta-chironandClaude Fable 5.1 97650ad0ec feat(queue): Gesetze max_text und max_age — gekürzte Langtexte, verfallene Stau-Ansagen
Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.

- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
  werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
  /speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
  Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
  Fälligwerden länger gewartet haben, werden verworfen (discarded
  "expired", Protokoll nennt Wartezeit und Grenze). now:true ist
  ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.

Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 09:54:40 +02:00

373 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}