Files
talk/test/mcp_test.go
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

303 lines
11 KiB
Go

package test
// Process-boundary tests for the MCP endpoint (Task 06): the complete
// handshake (initialize → notifications/initialized → tools/list →
// tools/call) as raw JSON-RPC HTTP payloads against a real talkd — literal,
// without a client SDK. tools/call must behave exactly like POST /speak: the
// same queue admission including mute and law.
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// mcpToolText reads content[0].text from a tools/call result.
func mcpToolText(t *testing.T, m map[string]any) string {
t.Helper()
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("tools/call without result: %v", m)
}
content, ok := result["content"].([]any)
if !ok || len(content) == 0 {
t.Fatalf("tools/call without content: %v", result)
}
first, ok := content[0].(map[string]any)
if !ok {
t.Fatalf("content[0] is not an object: %v", content[0])
}
text, _ := first["text"].(string)
return text
}
// mcpIsError reads isError (missing = false — deliberate semantics).
func mcpIsError(m map[string]any) bool {
if result, ok := m["result"].(map[string]any); ok {
if e, ok := result["isError"].(bool); ok {
return e
}
}
return false
}
// TestMCPHandshake (acceptance criterion): the curl sequence initialize →
// notifications/initialized → tools/list → tools/call sees the speak tool
// and enqueues successfully — the announcement runs through the queue and
// the fake provider.
func TestMCPHandshake(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
// 1. initialize: the requested version is echoed; capabilities name
// tools; serverInfo is talkd.
raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("initialize: status %d — body: %s", raw.status, raw.body)
}
if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("initialize Content-Type = %q, want application/json", ct)
}
m := mcpDecode(t, raw)
if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" {
t.Fatalf("initialize envelope = %v (check jsonrpc/id)", m)
}
result, ok := m["result"].(map[string]any)
if !ok {
t.Fatalf("initialize without result: %v", m)
}
if result["protocolVersion"] != "2025-06-18" {
t.Errorf("protocolVersion = %v, want echo of the requested 2025-06-18", result["protocolVersion"])
}
caps, ok := result["capabilities"].(map[string]any)
if !ok {
t.Fatalf("capabilities = %v, want object", result["capabilities"])
}
if _, ok := caps["tools"]; !ok {
t.Errorf("capabilities without tools: %v", caps)
}
serverInfo, _ := result["serverInfo"].(map[string]any)
if serverInfo["name"] != "talkd" {
t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"])
}
if v, _ := serverInfo["version"].(string); v == "" {
t.Error("serverInfo.version is empty")
}
// Another requested version is echoed as well (accepted).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`)
m = mcpDecode(t, raw)
if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" {
t.Errorf("protocolVersion = %v, want echo 2025-03-26", m["result"])
}
// 2. notifications/initialized: notification → 202 without body.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("notifications/initialized: status %d, want 202", raw.status)
}
if raw.body != "" {
t.Errorf("notifications/initialized body = %q, want empty", raw.body)
}
// 3. tools/list: exactly the speak tool with the briefed schema.
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`)
m = mcpDecode(t, raw)
result = m["result"].(map[string]any)
tools, ok := result["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("tools = %v, want exactly one tool", result["tools"])
}
tool := tools[0].(map[string]any)
if tool["name"] != "speak" {
t.Errorf("tool.name = %v, want speak", tool["name"])
}
schema, ok := tool["inputSchema"].(map[string]any)
if !ok {
t.Fatalf("inputSchema = %v, want object", tool["inputSchema"])
}
if schema["type"] != "object" {
t.Errorf("inputSchema.type = %v, want object", schema["type"])
}
props := schema["properties"].(map[string]any)
for _, name := range []string{"text", "priority", "provider", "lang", "now"} {
if _, ok := props[name]; !ok {
t.Errorf("inputSchema without property %q: %v", name, props)
}
}
if len(props) != 5 {
t.Errorf("inputSchema has %d properties (%v), want exactly the 5 from the brief", len(props), props)
}
if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" {
t.Errorf("required = %v, want [text]", schema["required"])
}
// 4. tools/call: admission like POST /speak — "queued: <id>" and the
// announcement shows up at the fake provider (same queue, no
// parallel path).
raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hello via mcp"}}}`)
if raw.status != http.StatusOK {
t.Fatalf("tools/call: status %d — body: %s", raw.status, raw.body)
}
m = mcpDecode(t, raw)
if fmt.Sprint(m["id"]) != "4" {
t.Errorf("tools/call id = %v, want echo 4", m["id"])
}
text := mcpToolText(t, m)
if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") {
t.Fatalf("tools/call text = %q, want \"queued: <id>\"", text)
}
if mcpIsError(m) {
t.Errorf("tools/call isError = true on successful admission: %v", m)
}
hits := fake.waitCompleted(t, 1)
if hits[0].Text != "hello via mcp" {
t.Errorf("fake hit = %q, want the MCP announcement", hits[0].Text)
}
}
// TestMCPToolsCallLaws: muted and law discards are tool RESULTS
// (isError:false with explanatory text), not protocol errors — and the
// announcement stays silent.
func TestMCPToolsCallLaws(t *testing.T) {
t.Run("muted", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
postPath(t, d, "/mute")
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"silence please"}}}`))
if mcpIsError(m) {
t.Fatalf("muted is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") {
t.Errorf("text = %q, want the muted explanation", text)
}
fake.assertNoHits(t, 300*time.Millisecond)
})
t.Run("rate_limit discards low", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
// The first low announcement plays (window free) and books it.
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"first","priority":"low"}}}`))
if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") {
t.Fatalf("first low: text = %q, want queued", text)
}
fake.waitCompleted(t, 1)
// Second low inside the window: discard as a result.
m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"second","priority":"low"}}}`))
if mcpIsError(m) {
t.Fatalf("discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") {
t.Errorf("text = %q, want discarded: rate_limit …", text)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
})
t.Run("quiet_hours discards", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_QUIET_HOURS": windowAround(),
})
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"at night"}}}`))
if mcpIsError(m) {
t.Fatalf("quiet-hours discard is isError:true — should be a result: %v", m)
}
if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") {
t.Errorf("text = %q, want discarded: quiet_hours …", text)
}
fake.assertNoHits(t, 300*time.Millisecond)
})
}
// TestMCPErrorPaths: validation errors as JSON-RPC errors (-32602), unknown
// method -32601, broken JSON -32700, wrong jsonrpc version and batches
// -32600, GET /mcp 405 (no server-initiated streams).
func TestMCPErrorPaths(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
cases := []struct {
name string
payload string
code float64
msgPart string
}{
{"text missing", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"},
{"text empty", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"},
{"invalid priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"},
{"unknown tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"},
{"unknown method", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"},
{"broken JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"},
{"wrong jsonrpc version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"},
{"batch (removed since 2025-06-18)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "batch"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw := mcpPost(t, d, tc.payload)
if raw.status != http.StatusOK {
t.Fatalf("status = %d, want 200 (JSON-RPC error in the body)", raw.status)
}
m := mcpDecode(t, raw)
rpcErr, ok := m["error"].(map[string]any)
if !ok {
t.Fatalf("no error object: %v", m)
}
if rpcErr["code"] != tc.code {
t.Errorf("code = %v, want %v (body: %s)", rpcErr["code"], tc.code, raw.body)
}
if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) {
t.Errorf("message = %q, want part %q", msg, tc.msgPart)
}
if _, hasResult := m["result"]; hasResult {
t.Errorf("error response with result: %v", m)
}
})
}
t.Run("ping", func(t *testing.T) {
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`))
if _, ok := m["result"]; !ok {
t.Fatalf("ping without result: %v", m)
}
})
t.Run("GET /mcp → 405 (no server-initiated streams)", func(t *testing.T) {
raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "")
if raw.status != http.StatusMethodNotAllowed {
t.Fatalf("GET /mcp status = %d, want 405", raw.status)
}
if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") {
t.Errorf("Allow = %q, want POST named", allow)
}
})
t.Run("notification with unknown method → silent 202", func(t *testing.T) {
raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`)
if raw.status != http.StatusAccepted || raw.body != "" {
t.Fatalf("status %d body %q, want 202 without body", raw.status, raw.body)
}
})
}
// TestStatusMCPField: /status reports mcp:true (compat field Task 06).
func TestStatusMCPField(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
if st := getStatus(t, d); !st.MCP {
t.Error("mcp = false, want true (endpoint is present)")
}
}