2026-10-18 20:06:11 +02:00
|
|
|
package test
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:11 +02:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// mcpToolText reads content[0].text from a tools/call result.
|
2026-10-18 20:06:11 +02:00
|
|
|
func mcpToolText(t *testing.T, m map[string]any) string {
|
|
|
|
|
t.Helper()
|
|
|
|
|
result, ok := m["result"].(map[string]any)
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("tools/call without result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
content, ok := result["content"].([]any)
|
|
|
|
|
if !ok || len(content) == 0 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("tools/call without content: %v", result)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
first, ok := content[0].(map[string]any)
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("content[0] is not an object: %v", content[0])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
text, _ := first["text"].(string)
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// mcpIsError reads isError (missing = false — deliberate semantics).
|
2026-10-18 20:06:11 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:11 +02:00
|
|
|
func TestMCPHandshake(t *testing.T) {
|
|
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 1. initialize: the requested version is echoed; capabilities name
|
|
|
|
|
// tools; serverInfo is talkd.
|
2026-10-18 20:06:11 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("initialize: status %d — body: %s", raw.status, raw.body)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("initialize envelope = %v (check jsonrpc/id)", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
result, ok := m["result"].(map[string]any)
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("initialize without result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if result["protocolVersion"] != "2025-06-18" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("protocolVersion = %v, want echo of the requested 2025-06-18", result["protocolVersion"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
caps, ok := result["capabilities"].(map[string]any)
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("capabilities = %v, want object", result["capabilities"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if _, ok := caps["tools"]; !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("capabilities without tools: %v", caps)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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 == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("serverInfo.version is empty")
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Another requested version is echoed as well (accepted).
|
2026-10-18 20:06:11 +02:00
|
|
|
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" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("protocolVersion = %v, want echo 2025-03-26", m["result"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 2. notifications/initialized: notification → 202 without body.
|
2026-10-18 20:06:11 +02:00
|
|
|
raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`)
|
|
|
|
|
if raw.status != http.StatusAccepted {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("notifications/initialized: status %d, want 202", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if raw.body != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("notifications/initialized body = %q, want empty", raw.body)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 3. tools/list: exactly the speak tool with the briefed schema.
|
2026-10-18 20:06:11 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("tools = %v, want exactly one tool", result["tools"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("inputSchema = %v, want object", tool["inputSchema"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("inputSchema without property %q: %v", name, props)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(props) != 5 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("inputSchema has %d properties (%v), want exactly the 5 from the brief", len(props), props)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" {
|
|
|
|
|
t.Errorf("required = %v, want [text]", schema["required"])
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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"}}}`)
|
2026-10-18 20:06:11 +02:00
|
|
|
if raw.status != http.StatusOK {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("tools/call: status %d — body: %s", raw.status, raw.body)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
m = mcpDecode(t, raw)
|
|
|
|
|
if fmt.Sprint(m["id"]) != "4" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("tools/call id = %v, want echo 4", m["id"])
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("tools/call isError = true on successful admission: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
hits := fake.waitCompleted(t, 1)
|
2026-10-24 11:15:35 +02:00
|
|
|
if hits[0].Text != "hello via mcp" {
|
|
|
|
|
t.Errorf("fake hit = %q, want the MCP announcement", hits[0].Text)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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")
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"silence please"}}}`))
|
2026-10-18 20:06:11 +02:00
|
|
|
if mcpIsError(m) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("muted is isError:true — should be a result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("text = %q, want the muted explanation", text)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
fake.assertNoHits(t, 300*time.Millisecond)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("rate_limit discards low", func(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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"}}}`))
|
2026-10-18 20:06:11 +02:00
|
|
|
if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("first low: text = %q, want queued", text)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
fake.waitCompleted(t, 1)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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"}}}`))
|
2026-10-18 20:06:11 +02:00
|
|
|
if mcpIsError(m) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("discard is isError:true — should be a result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("quiet_hours discards", func(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, map[string]string{
|
2026-10-24 11:15:35 +02:00
|
|
|
"TALK_QUIET_HOURS": windowAround(),
|
2026-10-18 20:06:11 +02:00
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"at night"}}}`))
|
2026-10-18 20:06:11 +02:00
|
|
|
if mcpIsError(m) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("quiet-hours discard is isError:true — should be a result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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) {
|
2026-10-18 20:06:11 +02:00
|
|
|
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
|
|
|
|
|
}{
|
2026-10-24 11:15:35 +02:00
|
|
|
{"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"},
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
for _, tc := range cases {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
raw := mcpPost(t, d, tc.payload)
|
|
|
|
|
if raw.status != http.StatusOK {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("status = %d, want 200 (JSON-RPC error in the body)", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
m := mcpDecode(t, raw)
|
|
|
|
|
rpcErr, ok := m["error"].(map[string]any)
|
|
|
|
|
if !ok {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("no error object: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if rpcErr["code"] != tc.code {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("code = %v, want %v (body: %s)", rpcErr["code"], tc.code, raw.body)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("message = %q, want part %q", msg, tc.msgPart)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if _, hasResult := m["result"]; hasResult {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("error response with result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("ping without result: %v", m)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("GET /mcp → 405 (no server-initiated streams)", func(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "")
|
|
|
|
|
if raw.status != http.StatusMethodNotAllowed {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("GET /mcp status = %d, want 405", raw.status)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Errorf("Allow = %q, want POST named", allow)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Run("notification with unknown method → silent 202", func(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`)
|
|
|
|
|
if raw.status != http.StatusAccepted || raw.body != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Fatalf("status %d body %q, want 202 without body", raw.status, raw.body)
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// TestStatusMCPField: /status reports mcp:true (compat field Task 06).
|
|
|
|
|
func TestStatusMCPField(t *testing.T) {
|
2026-10-18 20:06:11 +02:00
|
|
|
root := t.TempDir()
|
|
|
|
|
fake := newFakeProvider(t, 0)
|
|
|
|
|
d := startDaemon(t, root, fake.srv.URL, nil)
|
|
|
|
|
if st := getStatus(t, d); !st.MCP {
|
2026-10-24 11:15:35 +02:00
|
|
|
t.Error("mcp = false, want true (endpoint is present)")
|
2026-10-18 20:06:11 +02:00
|
|
|
}
|
|
|
|
|
}
|