package api import ( "encoding/json" "io" "net/http" "strconv" "strings" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // MCP endpoint: Streamable HTTP on POST /mcp, a lean implementation of our // own of the JSON-RPC 2.0 subset that MCP actually needs (deliberately NO // SDK dependency; the protocol scope here is four methods plus ping). // Semantics per MCP 2025-06-18, with two documented simplifications the // transport allows: // // - Stateless: every POST is independent; there are no session IDs // (Mcp-Session-Id is ignored) and therefore no enforcement of the // initialize-first rule; a curl can call tools/call directly. MCP // clients do not mind: they run the handshake voluntarily. // - No server-initiated streams: talkd never pushes (the tool list is // static). GET /mcp therefore answers 405, the response the Streamable // HTTP transport specifies for servers without an SSE stream. // // Responses: requests get 200 application/json (JSON-RPC error objects also // 200; the HTTP part did succeed); notifications get 202 without a body // (JSON-RPC 2.0). Batches (arrays) are no longer part of MCP since // 2025-06-18 and yield -32600. // mcpProtocolVersion is the version talkd itself speaks when the client // names none (initialize without protocolVersion). Requested versions are // accepted and echoed back; what is implemented is the 2025-06-18 scope // documented above. const mcpProtocolVersion = "2025-06-18" // JSON-RPC 2.0 error codes (subset in use). const ( rpcParseError = -32700 rpcInvalidRequest = -32600 rpcMethodNotFound = -32601 rpcInvalidParams = -32602 rpcInternalError = -32603 ) // mcpServerName is the serverInfo name in the initialize result. const mcpServerName = "talkd" // jsonRPCRequest is an incoming JSON-RPC message. ID as RawMessage keeps // "field missing" (notification) distinguishable from id:0 or id:string. type jsonRPCRequest struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` } // jsonRPCResponse is an outgoing JSON-RPC message. Exactly one of // Result/Error is set. ID is never omitted; null on parse errors without a // known ID (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 is the error object of a JSON-RPC response. type rpcError struct { Code int `json:"code"` Message string `json:"message"` } // mcpInitializeParams holds the relevant parts of the initialize params // (client capabilities do not interest talkd; tools suffice). type mcpInitializeParams struct { ProtocolVersion string `json:"protocolVersion"` } // mcpServerInfo describes the server in the initialize result. type mcpServerInfo struct { Name string `json:"name"` Version string `json:"version"` } // mcpCapabilities reports in the initialize result what talkd offers: // tools (speak), nothing else. type mcpCapabilities struct { Tools map[string]any `json:"tools"` } // mcpInitializeResult is the result of initialize: the (echoed) protocol // version, the capabilities and the server info, in 2025-06-18 form. type mcpInitializeResult struct { ProtocolVersion string `json:"protocolVersion"` Capabilities mcpCapabilities `json:"capabilities"` ServerInfo mcpServerInfo `json:"serverInfo"` } // mcpToolsListResult is the result of tools/list: exactly one tool (speak); // no nextCursor (the list always fits on one page). type mcpToolsListResult struct { Tools []mcpTool `json:"tools"` } // mcpTool is a tool entry in tools/list. type mcpTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema mcpSchema `json:"inputSchema"` } // mcpSchema is the JSON Schema object of a tool input. type mcpSchema struct { Type string `json:"type"` Properties map[string]mcpProperty `json:"properties"` Required []string `json:"required"` } // mcpProperty is a single schema property. type mcpProperty struct { Type string `json:"type"` Description string `json:"description,omitempty"` Enum []string `json:"enum,omitempty"` } // mcpToolCallParams are the parameters of tools/call. type mcpToolCallParams struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` } // mcpContent is a content block of a tool result (here: text). type mcpContent struct { Type string `json:"type"` Text string `json:"text"` } // mcpToolResult is the result of tools/call. isError:false (omitted) also // for muted/discarded: that is a RESULT of admission, not a protocol error // (deliberate decision, see speakToolResult). type mcpToolResult struct { Content []mcpContent `json:"content"` IsError bool `json:"isError,omitempty"` } // serveMCP handles POST /mcp: one JSON-RPC message per 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, "reading body: "+err.Error()) return } if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") { writeRPCError(w, nil, rpcInvalidRequest, "batch requests are not supported (MCP 2025-06-18 removed batches); one message per POST") return } var req jsonRPCRequest if err := json.Unmarshal(body, &req); err != nil { writeRPCError(w, nil, rpcParseError, "invalid JSON: "+err.Error()) return } if req.JSONRPC != "2.0" { writeRPCError(w, nil, rpcInvalidRequest, `jsonrpc must be "2.0" (got `+strconv.Quote(req.JSONRPC)+`)`) return } if isNotification(req.ID) { // Notifications (notifications/initialized, ...) never get a // JSON-RPC response, only the transport's 202. Unknown // notifications are silently ignored (JSON-RPC 2.0). w.WriteHeader(http.StatusAccepted) return } switch req.Method { case "initialize": h.mcpInitialize(w, req) case "notifications/initialized": // Sent with an ID: not a regular client; the correct answer is an // empty result, not an error. 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, "method "+strconv.Quote(req.Method)+" is not implemented (available: initialize, notifications/initialized, ping, tools/list, tools/call)") } } // mcpGet handles GET /mcp: 405 with Allow. talkd offers no server-initiated // streams (the Streamable HTTP response for that case); there are no tool // changes. func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) { w.Header().Set("Allow", "POST, OPTIONS") w.WriteHeader(http.StatusMethodNotAllowed) } // mcpInitialize handles initialize: the requested protocol version is // echoed (accepted); empty means our own (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 handles tools/call: exactly one tool (speak) whose arguments // take the same admission path as POST /speak, including mute and the laws // (rate limit/quiet hours). 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, "unknown tool "+strconv.Quote(params.Name)+" (available: 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 { // Validation/internal error (HTTP equivalent 400/503): report as a // JSON-RPC error; this was not an admission result. 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 is the tool description of speak: input schema with text // required; priority/provider/lang/now optional. func speakTool() mcpTool { return mcpTool{ Name: "speak", Description: "Speaks a short announcement (status, next step, what the user is needed for; " + "never long answers). Goes through the daemon: queue, rate limit, mute and quiet hours apply.", InputSchema: mcpSchema{ Type: "object", Properties: map[string]mcpProperty{ "text": { Type: "string", Description: "The announcement text (1 to 2 sentences by etiquette)", }, "priority": { Type: "string", Enum: []string{"low", "normal", "high"}, Description: "Processing priority (default normal)", }, "provider": { Type: "string", Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); empty = default chain", }, "lang": { Type: "string", Enum: []string{"de", "en"}, Description: "Language pin; empty = automatic detection", }, "now": { Type: "boolean", Description: "Jumps ahead of the queue and deliberately bypasses rate limit and quiet hours", }, }, Required: []string{"text"}, }, } } // speakToolResult translates a /speak admission response into the MCP tool // result. Mute and law discards are deliberately NOT isError: they are the // correct, documented result of admission (no protocol error); the agent // should read the reason, not crash. Successful admission: "queued: ". func speakToolResult(resp SpeakResponse) mcpToolResult { switch { case resp.Muted: return mcpToolResult{Content: []mcpContent{{ Type: "text", Text: "muted: announcement discarded (mute is on)", }}} case resp.Discarded == queue.DiscardRateLimit: return mcpToolResult{Content: []mcpContent{{ Type: "text", Text: "discarded: rate_limit; announcement discarded (priority low over the window budget)", }}} case resp.Discarded == queue.DiscardQuietHours: text := "discarded: quiet_hours; quiet hours active, announcement discarded" if resp.Toast { text += " (replaced by a toast, no 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 truncated to max_text; announcements are 1 to 2 sentences, not long answers)" } return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}} } } // isNotification reports whether the ID marks a notification (field missing // or explicitly null; both get no response per JSON-RPC 2.0). func isNotification(id json.RawMessage) bool { return len(id) == 0 || strings.TrimSpace(string(id)) == "null" } // writeRPCResult writes a successful JSON-RPC response (200, // application/json). func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) { writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result}) } // writeRPCError writes a JSON-RPC error response; id null when the request // ID is unknown (parse error). 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 writes the JSON-RPC response to the 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) }