1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "log"
10 "strings"
11 "testing"
12 "testing/iotest"
13 "unicode/utf8"
14
15 "github.com/mark3labs/mcp-go/mcp"
16)
17
18func TestManagedServerStatusCompact(t *testing.T) {
19 s := newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
20
21 s.state = stateReady
22 s.tools = []mcp.Tool{{Name: "searchGitHub"}}
23 if got, want := s.status(), "grep (1 tool)"; got != want {
24 t.Fatalf("ready status = %q, want %q", got, want)
25 }
26
27 s.state = stateStopped
28 if got, want := s.status(), "grep (sleeping, 1 tool)"; got != want {
29 t.Fatalf("stopped status = %q, want %q", got, want)
30 }
31
32 s.state = stateError
33 s.startErr = errors.New("authorization required")
34 if got, want := s.status(), "grep (auth failed)"; got != want {
35 t.Fatalf("error status = %q, want %q", got, want)
36 }
37}
38
39func TestFormatStatusSummaryCompact(t *testing.T) {
40 b := &bridge{servers: map[string]*managedServer{}}
41 b.servers["grep"] = newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
42 b.servers["grep"].state = stateReady
43 b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
44 b.servers["n8n-mcp"] = newManagedServer("n8n-mcp", ServerConfig{}, "", log.New(io.Discard, "", 0))
45 b.servers["n8n-mcp"].state = stateReady
46 b.servers["n8n-mcp"].tools = make([]mcp.Tool, 26)
47
48 if got, want := formatStatusSummary(b), "grep (1 tool) | n8n-mcp (26 tools)"; got != want {
49 t.Fatalf("summary = %q, want %q", got, want)
50 }
51 if got, want := b.notifyLevel(), "success"; got != want {
52 t.Fatalf("notify level = %q, want %q", got, want)
53 }
54}
55
56func TestManagedServerStopBeforeStartDoesNotPanic(t *testing.T) {
57 s := newManagedServer("never-started", ServerConfig{}, "", log.New(io.Discard, "", 0))
58 s.stop()
59 if got, want := s.state, stateStopped; got != want {
60 t.Fatalf("state = %s, want %s", got, want)
61 }
62}
63
64func TestMCPToolSchemaPreservesExtraFields(t *testing.T) {
65 schema := mcpToolSchema(mcp.Tool{
66 Name: "query",
67 InputSchema: mcp.ToolInputSchema{
68 Type: "object",
69 Properties: map[string]any{"sql": map[string]any{"type": "string"}},
70 Required: []string{"sql"},
71 Defs: map[string]any{"Thing": map[string]any{"type": "object"}},
72 AdditionalProperties: false,
73 },
74 })
75
76 data, err := json.Marshal(schema)
77 if err != nil {
78 t.Fatalf("marshal schema: %v", err)
79 }
80 var got map[string]any
81 if err := json.Unmarshal(data, &got); err != nil {
82 t.Fatalf("unmarshal schema: %v", err)
83 }
84 if _, ok := got["$defs"]; !ok {
85 t.Fatalf("schema did not preserve $defs: %#v", got)
86 }
87 if got["additionalProperties"] != false {
88 t.Fatalf("schema did not preserve additionalProperties=false: %#v", got)
89 }
90}
91
92func TestCompactErrShortensConnectionFailures(t *testing.T) {
93 msg := "initialize: transport error: failed to send request: failed to send request"
94 if got, want := compactErr(msg), "connection failed"; got != want {
95 t.Fatalf("compactErr = %q, want %q", got, want)
96 }
97}
98
99func TestNotifyLevelWarnOnPartialFailure(t *testing.T) {
100 b := &bridge{servers: map[string]*managedServer{}}
101 b.servers["grep"] = newManagedServer("grep", ServerConfig{}, "", log.New(io.Discard, "", 0))
102 b.servers["grep"].state = stateReady
103 b.servers["grep"].tools = []mcp.Tool{{Name: "searchGitHub"}}
104 b.servers["broken"] = newManagedServer("broken", ServerConfig{}, "", log.New(io.Discard, "", 0))
105 b.servers["broken"].state = stateError
106 b.servers["broken"].startErr = errors.New("authorization required\nmore detail")
107
108 if got, want := formatStatusSummary(b), "broken (auth failed) | grep (1 tool)"; got != want {
109 t.Fatalf("summary = %q, want %q", got, want)
110 }
111 if got, want := b.notifyLevel(), "warn"; got != want {
112 t.Fatalf("notify level = %q, want %q", got, want)
113 }
114}
115
116func TestStopDuringStartDiscardsStaleResult(t *testing.T) {
117 s := newManagedServer("racy", ServerConfig{}, "", log.New(io.Discard, "", 0))
118
119 // Simulate the window inside start(): state is starting, gen recorded.
120 s.mu.Lock()
121 s.state = stateStarting
122 gen := s.gen
123 s.mu.Unlock()
124
125 // stop() arrives while the spawn is in flight.
126 s.stop()
127
128 // The in-flight attempt now tries to commit with a stale generation.
129 err := s.finishStart(gen, nil, []mcp.Tool{{Name: "late"}}, nil)
130 if err == nil {
131 t.Fatal("expected error committing a stale start")
132 }
133
134 s.mu.Lock()
135 defer s.mu.Unlock()
136 if s.state != stateStopped {
137 t.Fatalf("state = %s, want stopped", s.state)
138 }
139 if s.client != nil {
140 t.Fatal("stale client must not be installed")
141 }
142}
143
144func TestCallToolNilClientReturnsErrorNotPanic(t *testing.T) {
145 s := newManagedServer("bogus", ServerConfig{Transport: "no-such-transport", RequestTimeout: 1, ConnectTimeout: 1}, "", log.New(io.Discard, "", 0))
146 _, err := s.callTool(context.Background(), "anything", nil)
147 if err == nil {
148 t.Fatal("expected error from unknown transport")
149 }
150}
151
152func TestPipeStderrKeepsLinesIntactAcrossChunks(t *testing.T) {
153 var buf bytes.Buffer
154 s := newManagedServer("srv", ServerConfig{}, "", log.New(&buf, "", 0))
155
156 // iotest.OneByteReader forces 1-byte reads: the old 4096-byte
157 // chunking code would log every byte as its own "line".
158 long := strings.Repeat("x", 100)
159 r := iotest.OneByteReader(strings.NewReader("first " + long + "\nsecond line\n"))
160 s.pipeStderr(r)
161
162 out := buf.String()
163 if !strings.Contains(out, "[srv:stderr] first "+long+"\n") {
164 t.Fatalf("first line was split or lost:\n%s", out)
165 }
166 if !strings.Contains(out, "[srv:stderr] second line\n") {
167 t.Fatalf("second line missing:\n%s", out)
168 }
169 if got := strings.Count(out, "[srv:stderr]"); got != 2 {
170 t.Fatalf("logged %d lines, want 2:\n%s", got, out)
171 }
172}
173
174func TestCompactErrTruncatesOnRuneBoundary(t *testing.T) {
175 msg := strings.Repeat("ü", 60) // 120 bytes, 60 runes
176 got := compactErr(msg)
177 if !utf8.ValidString(got) {
178 t.Fatalf("truncation split a rune: %q", got)
179 }
180 if !strings.HasSuffix(got, "…") {
181 t.Fatalf("expected ellipsis suffix: %q", got)
182 }
183}
184
185func TestCompactErrClassifications(t *testing.T) {
186 cases := []struct{ in, want string }{
187 {"context deadline exceeded", "timeout"},
188 {"401 Unauthorized", "auth failed"},
189 {"exec: \"nope\": executable file not found in $PATH", "command not found"},
190 {"initialize: something odd", "initialize failed"},
191 {"list tools: something odd", "tool discovery failed"},
192 {"", "error"},
193 }
194 for _, c := range cases {
195 if got := compactErr(c.in); got != c.want {
196 t.Errorf("compactErr(%q) = %q, want %q", c.in, got, c.want)
197 }
198 }
199}
200
201func TestMCPResultToZot(t *testing.T) {
202 if got := mcpResultToZot(nil); len(got.Content) == 0 || got.IsError {
203 t.Fatalf("nil result: %+v", got)
204 }
205
206 res := &mcp.CallToolResult{
207 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: "hello"}},
208 IsError: true,
209 }
210 got := mcpResultToZot(res)
211 if !got.IsError {
212 t.Fatal("IsError not propagated")
213 }
214 if len(got.Content) != 1 {
215 t.Fatalf("content count = %d, want 1", len(got.Content))
216 }
217
218 empty := mcpResultToZot(&mcp.CallToolResult{})
219 if len(empty.Content) == 0 {
220 t.Fatal("empty MCP result must produce placeholder content")
221 }
222}