feat(api): MCP-Endpoint POST /mcp — schlankes eigenes JSON-RPC-Subset mit speak-Tool
Streamable HTTP nach MCP 2025-06-18-Semantik, handgewirkt ohne SDK: initialize (Version-Echo), notifications/initialized (202 ohne Body), ping, tools/list, tools/call. tools/call fährt denselben Aufnahme-Pfad wie POST /speak (admitSpeak, inkl. Mute/Gesetz); muted/discarded sind bewusst isError:false mit erklärendem Text, Validierungsfehler -32602. Zustandslos, GET /mcp → 405 (keine server-initiierten Streams). /status: mcp:true.
This commit is contained in:
1 parent
156c20dbb3
commit
a577597714
1 file changed
+371
@@ -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: <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:
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user