Files
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

369 lines
12 KiB
Go

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