package test // Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette // Handshake (initialize → notifications/initialized → tools/list → // tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd — // literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak // verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz. import ( "fmt" "net/http" "strings" "testing" "time" ) // mcpToolText liest content[0].text aus einem tools/call-Ergebnis. 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 ohne result: %v", m) } content, ok := result["content"].([]any) if !ok || len(content) == 0 { t.Fatalf("tools/call ohne content: %v", result) } first, ok := content[0].(map[string]any) if !ok { t.Fatalf("content[0] ist kein Objekt: %v", content[0]) } text, _ := first["text"].(string) return text } // mcpIsError liest isError (fehlt = false — bewusste Semantik). 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 (Abnahmekriterium): die curl-Sequenz initialize → // notifications/initialized → tools/list → tools/call sieht das speak-Tool // und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider. func TestMCPHandshake(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, nil) // 1. initialize: angefragte Version wird geechot; capabilities nennen // tools; serverInfo ist 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 (jsonrpc/id prüfen)", m) } result, ok := m["result"].(map[string]any) if !ok { t.Fatalf("initialize ohne result: %v", m) } if result["protocolVersion"] != "2025-06-18" { t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"]) } caps, ok := result["capabilities"].(map[string]any) if !ok { t.Fatalf("capabilities = %v, want Objekt", result["capabilities"]) } if _, ok := caps["tools"]; !ok { t.Errorf("capabilities ohne 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 ist leer") } // Andere angefragte Version wird ebenso geechot (akzeptiert). 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 ohne 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 leer", raw.body) } // 3. tools/list: genau das speak-Tool mit dem Brief-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 genau ein 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 Objekt", 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 ohne property %q: %v", name, props) } } if len(props) != 5 { t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem 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: Aufnahme wie POST /speak — "queued: " und die // Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg). raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über 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: \"", text) } if mcpIsError(m) { t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m) } hits := fake.waitCompleted(t, 1) if hits[0].Text != "hallo über mcp" { t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text) } } // TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE // (isError:false mit erklärendem Text), keine Protokollfehler — und die // Ansage bleibt stumm. func TestMCPToolsCallGesetze(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":"stille bitte"}}}`)) if mcpIsError(m) { t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m) } if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") { t.Errorf("text = %q, will muted-Erklärung", text) } fake.assertNoHits(t, 300*time.Millisecond) }) t.Run("rate_limit verwirft 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"}) // Erste low-Ansage spielt (Fenster frei) und bucht es. m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`)) if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") { t.Fatalf("erste low: text = %q, want queued", text) } fake.waitCompleted(t, 1) // Zweite low innerhalb des Fensters: Verwurf als Ergebnis. m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`)) if mcpIsError(m) { t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %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 verwirft", func(t *testing.T) { root := t.TempDir() fake := newFakeProvider(t, 0) d := startDaemon(t, root, fake.srv.URL, map[string]string{ "TALK_QUIET_HOURS": fensterUm(), }) m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`)) if mcpIsError(m) { t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %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) }) } // TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602), // unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version // und Batches -32600, GET /mcp 405 (keine server-initiierten Streams). func TestMCPFehlerPfade(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 fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"}, {"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"}, {"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"}, {"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"}, {"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"}, {"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"}, {"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"}, {"Batch (seit 2025-06-18 entfernt)", `[{"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-Fehler im Body)", raw.status) } m := mcpDecode(t, raw) rpcErr, ok := m["error"].(map[string]any) if !ok { t.Fatalf("kein error-Objekt: %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, will Teil %q", msg, tc.msgPart) } if _, hasResult := m["result"]; hasResult { t.Errorf("error-Antwort mit 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 ohne result: %v", m) } }) t.Run("GET /mcp → 405 (keine server-initiierten 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, will POST nennen", allow) } }) t.Run("Notification mit unbekannter Methode → still 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 ohne Body", raw.status, raw.body) } }) } // TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06). func TestStatusMCPFeld(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 ist vorhanden)") } }