v0.51.0 a9d674e

manager_test.go RawHistoryFolder

1package mcp
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"os"
8	"path/filepath"
9	"strconv"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/zut/packages/core"
15	"github.com/bnema/zut/packages/provider"
16)
17
18// ---- helpers ----
19
20func startManager(t *testing.T, servers map[string]ServerConfig, cwd string) (*Manager, []error, error) {
21	t.Helper()
22	m := NewManager(Config{Servers: servers}, cwd, ClientInfo{Name: "zut-test", Version: "0"}, nil)
23	t.Cleanup(func() { _ = m.Close() })
24	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
25	defer cancel()
26	failures, reqErr := m.Start(ctx)
27	return m, failures, reqErr
28}
29
30func startFake(t *testing.T, extraEnv map[string]string, tweak func(*ServerConfig)) (*Manager, string) {
31	t.Helper()
32	cfg, events := fakeStdioConfig(t, extraEnv)
33	if tweak != nil {
34		tweak(&cfg)
35	}
36	m, failures, reqErr := startManager(t, map[string]ServerConfig{"fake": cfg}, "")
37	if len(failures) != 0 || reqErr != nil {
38		t.Fatalf("start failed: %v / %v", failures, reqErr)
39	}
40	return m, events
41}
42
43func findTool(t *testing.T, m *Manager, name string) core.Tool {
44	t.Helper()
45	for _, tool := range m.Tools() {
46		if tool.Name() == name {
47			return tool
48		}
49	}
50	t.Fatalf("tool %q not found; have %v", name, toolNames(m))
51	return nil
52}
53
54func toolNames(m *Manager) []string {
55	var names []string
56	for _, tool := range m.Tools() {
57		names = append(names, tool.Name())
58	}
59	return names
60}
61
62func hasTool(m *Manager, name string) bool {
63	for _, n := range toolNames(m) {
64		if n == name {
65			return true
66		}
67	}
68	return false
69}
70
71func execTool(t *testing.T, tool core.Tool, args string) core.ToolResult {
72	t.Helper()
73	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
74	defer cancel()
75	res, err := tool.Execute(ctx, json.RawMessage(args), nil)
76	if err != nil {
77		t.Fatalf("%s: Execute returned error: %v", tool.Name(), err)
78	}
79	return res
80}
81
82func resultText(res core.ToolResult) string {
83	var parts []string
84	for _, c := range res.Content {
85		if tb, ok := c.(provider.TextBlock); ok {
86			parts = append(parts, tb.Text)
87		}
88	}
89	return strings.Join(parts, "\n")
90}
91
92// ---- stdio end to end ----
93
94func TestManagerStdioToolsAndDescriptions(t *testing.T) {
95	m, _ := startFake(t, nil, nil)
96
97	if m.Empty() {
98		t.Fatal("manager should not be empty")
99	}
100	st := m.Status()
101	if len(st) != 1 || !st[0].Connected || st[0].Tools != fakeToolCount || !st[0].Resources || st[0].Transport != "stdio" {
102		t.Fatalf("status = %+v", st)
103	}
104	if st[0].Info.ProtocolVersion != "2025-06-18" || !st[0].Info.ToolsChanged || st[0].Info.Name != "fake" {
105		t.Errorf("server info = %+v", st[0].Info)
106	}
107
108	tools := m.Tools()
109	// All server tools across two pages + 2 resource helpers.
110	if len(tools) != fakeToolCount+2 {
111		t.Fatalf("got %d tools: %v", len(tools), toolNames(m))
112	}
113	seen := map[string]bool{}
114	for _, tool := range tools {
115		if seen[tool.Name()] {
116			t.Errorf("duplicate tool name %s", tool.Name())
117		}
118		seen[tool.Name()] = true
119		if !IsTool(tool) {
120			t.Errorf("IsTool(%s) = false", tool.Name())
121		}
122		var schema map[string]any
123		if err := json.Unmarshal(tool.Schema(), &schema); err != nil || schema["type"] != "object" {
124			t.Errorf("%s schema invalid: %s", tool.Name(), tool.Schema())
125		}
126	}
127	// A tool from page two proves pagination was followed.
128	for _, want := range []string{"mcp__fake__echo", "mcp__fake__structured", "mcp__fake__getenv", ListResourcesToolName, ReadResourceToolName} {
129		if !seen[want] {
130			t.Errorf("missing tool %s", want)
131		}
132	}
133
134	echo := findTool(t, m, "mcp__fake__echo")
135	if got, want := echo.Description(), "[MCP fake/echo, read-only] Echo text back"; got != want {
136		t.Errorf("echo description = %q, want %q", got, want)
137	}
138	if !IsReadOnlyTool(echo) {
139		t.Error("echo carries readOnlyHint and should be read-only")
140	}
141	img := findTool(t, m, "mcp__fake__image")
142	if strings.Contains(img.Description(), "read-only") || IsReadOnlyTool(img) {
143		t.Errorf("image tool must not be read-only: %q", img.Description())
144	}
145	// Top-level anyOf from the server schema is removed.
146	if strings.Contains(string(echo.Schema()), "anyOf") {
147		t.Errorf("schema still has anyOf: %s", echo.Schema())
148	}
149	if !IsReadOnlyTool(findTool(t, m, ListResourcesToolName)) || !IsReadOnlyTool(findTool(t, m, ReadResourceToolName)) {
150		t.Error("resource helpers are read-only")
151	}
152	if IsTool(nil) || IsReadOnlyTool(nil) {
153		t.Error("nil is not an MCP tool")
154	}
155	if IsTool(foreignTool{}) || IsReadOnlyTool(foreignTool{}) {
156		t.Error("foreign tools are not MCP tools")
157	}
158	if !IsMCPToolName("mcp__fake__echo") || !IsMCPToolName(ListResourcesToolName) || IsMCPToolName("bash") {
159		t.Error("IsMCPToolName wrong")
160	}
161
162	text := FormatStatus(m.Status())
163	if !strings.Contains(text, "fake (stdio): connected, 18 tools, resources") {
164		t.Errorf("FormatStatus = %q", text)
165	}
166	if FormatStatus(nil) != "no MCP servers configured" {
167		t.Error("empty FormatStatus")
168	}
169	add := m.SystemPromptAddendum()
170	for _, want := range []string{"[mcp_servers]", "fake: 18 tools", "mcp__fake__<tool>", "Be nice"} {
171		if !strings.Contains(add, want) {
172			t.Errorf("addendum missing %q:\n%s", want, add)
173		}
174	}
175}
176
177type foreignTool struct{}
178
179func (foreignTool) Name() string            { return "foreign" }
180func (foreignTool) Description() string     { return "" }
181func (foreignTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
182func (foreignTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
183	return core.ToolResult{}, nil
184}
185
186func TestManagerStdioExecuteConversion(t *testing.T) {
187	m, _ := startFake(t, map[string]string{"FAKE_JUNK": "1"}, nil)
188
189	t.Run("text", func(t *testing.T) {
190		res := execTool(t, findTool(t, m, "mcp__fake__echo"), `{"text":"hi"}`)
191		if res.IsError || resultText(res) != "echo: hi" {
192			t.Fatalf("got %+v", res)
193		}
194		d, ok := res.Details.(ToolDetails)
195		if !ok || d.Server != "fake" || d.Tool != "echo" {
196			t.Errorf("details = %#v", res.Details)
197		}
198	})
199	t.Run("image", func(t *testing.T) {
200		res := execTool(t, findTool(t, m, "mcp__fake__image"), `{}`)
201		if res.IsError || len(res.Content) != 2 {
202			t.Fatalf("got %+v", res)
203		}
204		if tb, ok := res.Content[0].(provider.TextBlock); !ok || tb.Text != "an image" {
205			t.Errorf("content[0] = %#v", res.Content[0])
206		}
207		ib, ok := res.Content[1].(provider.ImageBlock)
208		if !ok || ib.MimeType != "image/png" || string(ib.Data) != string(fakeImageBytes) {
209			t.Errorf("content[1] = %#v", res.Content[1])
210		}
211	})
212	t.Run("embedded resource", func(t *testing.T) {
213		res := execTool(t, findTool(t, m, "mcp__fake__resource"), `{}`)
214		if got, want := resultText(res), "[resource mem://emb]\nembedded body"; got != want {
215			t.Errorf("got %q, want %q", got, want)
216		}
217	})
218	t.Run("isError", func(t *testing.T) {
219		res := execTool(t, findTool(t, m, "mcp__fake__err"), `{}`)
220		if !res.IsError || resultText(res) != "tool failed" {
221			t.Errorf("got %+v", res)
222		}
223	})
224	t.Run("structuredContent fallback", func(t *testing.T) {
225		res := execTool(t, findTool(t, m, "mcp__fake__structured"), `{}`)
226		if res.IsError || resultText(res) != `{"answer":42}` {
227			t.Errorf("got %+v (%q)", res, resultText(res))
228		}
229	})
230	t.Run("invalid arguments", func(t *testing.T) {
231		res := execTool(t, findTool(t, m, "mcp__fake__echo"), `[1]`)
232		if !res.IsError || !strings.Contains(resultText(res), "JSON object") {
233			t.Errorf("got %q", resultText(res))
234		}
235	})
236	t.Run("empty arguments", func(t *testing.T) {
237		res := execTool(t, findTool(t, m, "mcp__fake__echo"), ``)
238		if res.IsError || resultText(res) != "echo: " {
239			t.Errorf("got %q", resultText(res))
240		}
241	})
242	t.Run("output truncated at 50KiB", func(t *testing.T) {
243		res := execTool(t, findTool(t, m, "mcp__fake__big"), `{}`)
244		text := resultText(res)
245		marker := "\n... [truncated at 51200 bytes]"
246		if !strings.Contains(text, marker) {
247			t.Fatalf("no truncation marker; len=%d", len(text))
248		}
249		if len(text) > 50*1024+len(marker)+len("\n") {
250			t.Errorf("text too long: %d", len(text))
251		}
252		if strings.Contains(text, "tail") {
253			t.Error("content after the budget must be dropped")
254		}
255		if strings.Count(text, "a") < 50*1024 {
256			t.Error("budgeted text was cut too early")
257		}
258	})
259}
260
261func TestManagerStdioServerToClientRequests(t *testing.T) {
262	cwd := t.TempDir()
263	cfg, _ := fakeStdioConfig(t, nil)
264	m, failures, reqErr := startManager(t, map[string]ServerConfig{"fake": cfg}, cwd)
265	if len(failures) != 0 || reqErr != nil {
266		t.Fatal(failures, reqErr)
267	}
268	res := execTool(t, findTool(t, m, "mcp__fake__ask_roots"), `{}`)
269	text := resultText(res)
270	if res.IsError || !strings.Contains(text, "ping={}") {
271		t.Fatalf("server->client ping not answered: %q", text)
272	}
273	if !strings.Contains(text, `"roots":[{`) || !strings.Contains(text, "file://") || !strings.Contains(text, filepath.Base(cwd)) {
274		t.Errorf("roots/list answer wrong: %q", text)
275	}
276	// The working directory is applied to the child.
277	cwdRes := execTool(t, findTool(t, m, "mcp__fake__cwd"), `{}`)
278	got, _ := filepath.EvalSymlinks(resultText(cwdRes))
279	want, _ := filepath.EvalSymlinks(cwd)
280	if got != want {
281		t.Errorf("child cwd = %q, want %q", got, want)
282	}
283}
284
285func TestManagerStdioEnvIsolation(t *testing.T) {
286	t.Setenv("SECRET_API_KEY", "host-secret")
287	t.Setenv("MCP_T_PASS", "passed-through")
288	m, _ := startFake(t, map[string]string{"MCP_T_EXPLICIT": "from-cfg"}, func(c *ServerConfig) {
289		c.EnvVars = []string{"MCP_T_PASS"}
290	})
291	getenv := findTool(t, m, "mcp__fake__getenv")
292	for name, want := range map[string]string{
293		"SECRET_API_KEY": "[]",
294		"MCP_T_PASS":     "[passed-through]",
295		"MCP_T_EXPLICIT": "[from-cfg]",
296	} {
297		args, _ := json.Marshal(map[string]string{"name": name})
298		if got := resultText(execTool(t, getenv, string(args))); got != want {
299			t.Errorf("getenv %s = %s, want %s", name, got, want)
300		}
301	}
302}
303
304func TestManagerToolFiltering(t *testing.T) {
305	t.Run("enabledTools", func(t *testing.T) {
306		m, _ := startFake(t, nil, func(c *ServerConfig) { c.EnabledTools = []string{"echo", "structured"} })
307		names := toolNames(m)
308		want := map[string]bool{"mcp__fake__echo": true, "mcp__fake__structured": true, ListResourcesToolName: true, ReadResourceToolName: true}
309		if len(names) != len(want) {
310			t.Fatalf("names = %v", names)
311		}
312		for _, n := range names {
313			if !want[n] {
314				t.Errorf("unexpected tool %s", n)
315			}
316		}
317		if got := len(m.ServerTools("fake")); got != 2 {
318			t.Errorf("ServerTools = %d", got)
319		}
320	})
321	t.Run("disabledTools", func(t *testing.T) {
322		m, _ := startFake(t, nil, func(c *ServerConfig) { c.DisabledTools = []string{"echo", "slow", "exit"} })
323		for _, n := range []string{"mcp__fake__echo", "mcp__fake__slow", "mcp__fake__exit"} {
324			if hasTool(m, n) {
325				t.Errorf("%s should be filtered", n)
326			}
327		}
328		if !hasTool(m, "mcp__fake__image") || len(m.ServerTools("fake")) != fakeToolCount-3 {
329			t.Errorf("remaining tools wrong: %v", toolNames(m))
330		}
331	})
332	t.Run("both", func(t *testing.T) {
333		m, _ := startFake(t, nil, func(c *ServerConfig) {
334			c.EnabledTools = []string{"echo", "image"}
335			c.DisabledTools = []string{"image"}
336		})
337		if got := m.ServerTools("fake"); len(got) != 1 || got[0].Name != "echo" {
338			t.Errorf("ServerTools = %+v", got)
339		}
340	})
341}
342
343func TestManagerToolTimeout(t *testing.T) {
344	m, events := startFake(t, nil, func(c *ServerConfig) { c.ToolTimeoutSec = 0.3 })
345	start := time.Now()
346	res := execTool(t, findTool(t, m, "mcp__fake__slow"), `{}`)
347	if !res.IsError {
348		t.Fatalf("expected error result, got %q", resultText(res))
349	}
350	text := resultText(res)
351	if !strings.Contains(text, "toolTimeoutSec") || !strings.Contains(text, "timed out") || !strings.Contains(text, "fake/slow") {
352		t.Errorf("message = %q", text)
353	}
354	if time.Since(start) > 5*time.Second {
355		t.Error("timeout took far too long")
356	}
357	// The client tells the server to stop working on the request.
358	waitEvent(t, events, "cancelled:")
359	// The connection stays usable afterwards.
360	if got := resultText(execTool(t, findTool(t, m, "mcp__fake__echo"), `{"text":"after"}`)); got != "echo: after" {
361		t.Errorf("after timeout: %q", got)
362	}
363}
364
365func TestManagerToolCancellation(t *testing.T) {
366	m, events := startFake(t, nil, nil)
367	tool := findTool(t, m, "mcp__fake__slow")
368	ctx, cancel := context.WithCancel(context.Background())
369	defer cancel()
370	type out struct {
371		res core.ToolResult
372		err error
373	}
374	done := make(chan out, 1)
375	go func() {
376		res, err := tool.Execute(ctx, json.RawMessage(`{}`), nil)
377		done <- out{res, err}
378	}()
379	waitEvent(t, events, "slow-started")
380	cancel()
381	select {
382	case o := <-done:
383		if o.err != nil || !o.res.IsError || !strings.Contains(resultText(o.res), "cancelled") {
384			t.Errorf("got %+v err=%v", o.res, o.err)
385		}
386	case <-time.After(5 * time.Second):
387		t.Fatal("Execute did not return after cancellation")
388	}
389	waitEvent(t, events, "cancelled:")
390}
391
392func TestManagerReconnectAfterServerDies(t *testing.T) {
393	m, events := startFake(t, nil, nil)
394	pidTool := findTool(t, m, "mcp__fake__pid")
395	pid1, err := strconv.Atoi(resultText(execTool(t, pidTool, `{}`)))
396	if err != nil {
397		t.Fatal(err)
398	}
399	res := execTool(t, findTool(t, m, "mcp__fake__exit"), `{}`)
400	if !res.IsError {
401		t.Fatalf("killing call should report an error, got %q", resultText(res))
402	}
403	if canCheckProcess {
404		eventually(t, "first process to exit", func() bool { return !processAlive(pid1) })
405	}
406	// The next call transparently reconnects with a fresh process.
407	pid2, err := strconv.Atoi(resultText(execTool(t, pidTool, `{}`)))
408	if err != nil {
409		t.Fatalf("reconnect failed: %v", err)
410	}
411	if pid2 == pid1 {
412		t.Errorf("expected a new server process, pid stayed %d", pid1)
413	}
414	if n := countEvents(events, "start"); n != 2 {
415		t.Errorf("server started %d times, want 2", n)
416	}
417	if st := m.Status(); !st[0].Connected || st[0].Err != "" {
418		t.Errorf("status after reconnect = %+v", st[0])
419	}
420	if !hasTool(m, "mcp__fake__echo") {
421		t.Error("tools lost after reconnect")
422	}
423}
424
425func TestManagerCloseIdempotentAndProcessExits(t *testing.T) {
426	m, events := startFake(t, nil, nil)
427	pid, err := strconv.Atoi(resultText(execTool(t, findTool(t, m, "mcp__fake__pid"), `{}`)))
428	if err != nil {
429		t.Fatal(err)
430	}
431	if err := m.Close(); err != nil {
432		t.Fatal(err)
433	}
434	if err := m.Close(); err != nil {
435		t.Fatalf("second Close: %v", err)
436	}
437	if canCheckProcess {
438		eventually(t, "server process to exit", func() bool { return !processAlive(pid) })
439	}
440	waitEvent(t, events, "stdin-closed")
441	if st := m.Status(); st[0].Connected {
442		t.Error("still connected after Close")
443	}
444	// Using a closed manager reports an error result, never hangs or reconnects.
445	res := execTool(t, findTool(t, m, "mcp__fake__echo"), `{"text":"x"}`)
446	if !res.IsError || !strings.Contains(resultText(res), "closed") {
447		t.Errorf("after close: %q", resultText(res))
448	}
449	if n := countEvents(events, "start"); n != 1 {
450		t.Errorf("closed manager restarted the server (%d starts)", n)
451	}
452	var nilManager *Manager
453	if err := nilManager.Close(); err != nil {
454		t.Error(err)
455	}
456}
457
458func TestManagerRequiredAndOptionalFailures(t *testing.T) {
459	good, _ := fakeStdioConfig(t, nil)
460	missing := ServerConfig{Command: filepath.Join(t.TempDir(), "no-such-binary")}
461
462	t.Run("optional", func(t *testing.T) {
463		m, failures, reqErr := startManager(t, map[string]ServerConfig{"good": good, "bad": missing}, "")
464		if reqErr != nil {
465			t.Fatalf("optional failure must not be fatal: %v", reqErr)
466		}
467		if len(failures) != 1 || !strings.Contains(failures[0].Error(), `"bad"`) {
468			t.Fatalf("failures = %v", failures)
469		}
470		if !hasTool(m, "mcp__good__echo") {
471			t.Error("healthy server should still expose tools")
472		}
473		for _, st := range m.Status() {
474			switch st.Name {
475			case "bad":
476				if st.Connected || st.Err == "" || st.Tools != 0 {
477					t.Errorf("bad status = %+v", st)
478				}
479			case "good":
480				if !st.Connected {
481					t.Errorf("good status = %+v", st)
482				}
483			}
484		}
485		if text := FormatStatus(m.Status()); !strings.Contains(text, "bad (stdio): disconnected") || !strings.Contains(text, "error:") {
486			t.Errorf("FormatStatus = %q", text)
487		}
488	})
489	t.Run("required", func(t *testing.T) {
490		req := missing
491		req.Required = true
492		_, failures, reqErr := startManager(t, map[string]ServerConfig{"good": good, "bad": req}, "")
493		if reqErr == nil || !strings.Contains(reqErr.Error(), `"bad"`) {
494			t.Fatalf("requiredErr = %v", reqErr)
495		}
496		if len(failures) != 1 {
497			t.Errorf("failures = %v", failures)
498		}
499	})
500	t.Run("empty manager", func(t *testing.T) {
501		m := NewManager(Config{}, "", ClientInfo{}, nil)
502		if !m.Empty() || m.Tools() != nil || m.SystemPromptAddendum() != "" {
503			t.Error("empty manager should expose nothing")
504		}
505		if f, r := m.Start(context.Background()); f != nil || r != nil {
506			t.Error("empty Start")
507		}
508	})
509}
510
511func TestManagerStartupTimeoutMessage(t *testing.T) {
512	_, failures, reqErr := startManager(t, map[string]ServerConfig{"slow": func() ServerConfig {
513		cfg, _ := fakeStdioConfig(t, map[string]string{"FAKE_INIT_DELAY_MS": "5000"})
514		cfg.StartupTimeoutSec = 0.3
515		cfg.Required = true
516		return cfg
517	}()}, "")
518	if len(failures) != 1 || reqErr == nil {
519		t.Fatalf("failures=%v reqErr=%v", failures, reqErr)
520	}
521	msg := failures[0].Error()
522	if !strings.Contains(msg, "startupTimeoutSec") || !strings.Contains(msg, "timed out") {
523		t.Errorf("message = %q", msg)
524	}
525}
526
527func TestManagerInitializeFailureIncludesStderr(t *testing.T) {
528	cfg, _ := fakeStdioConfig(t, map[string]string{"FAKE_INIT_ERR": "1"})
529	_, failures, _ := startManager(t, map[string]ServerConfig{"s": cfg}, "")
530	if len(failures) != 1 {
531		t.Fatalf("failures = %v", failures)
532	}
533	msg := failures[0].Error()
534	for _, want := range []string{"init refused", "server stderr:", "fatal: missing credentials"} {
535		if !strings.Contains(msg, want) {
536			t.Errorf("error missing %q: %q", want, msg)
537		}
538	}
539	var rpcErr *RPCError
540	if !errors.As(failures[0], &rpcErr) || rpcErr.Code != -32000 {
541		t.Errorf("RPCError not unwrappable: %v", failures[0])
542	}
543}
544
545func TestManagerRejectsUnsupportedProtocolAndBadPagination(t *testing.T) {
546	cfg, _ := fakeStdioConfig(t, map[string]string{"FAKE_PROTO": "1999-01-01"})
547	_, failures, _ := startManager(t, map[string]ServerConfig{"s": cfg}, "")
548	if len(failures) != 1 || !strings.Contains(failures[0].Error(), "unsupported protocol version") {
549		t.Errorf("failures = %v", failures)
550	}
551	cfg, _ = fakeStdioConfig(t, map[string]string{"FAKE_BAD_CURSOR": "1"})
552	_, failures, _ = startManager(t, map[string]ServerConfig{"s": cfg}, "")
553	if len(failures) != 1 || !strings.Contains(failures[0].Error(), "repeated pagination cursor") {
554		t.Errorf("failures = %v", failures)
555	}
556}
557
558func TestManagerToolsListChanged(t *testing.T) {
559	m, _ := startFake(t, nil, nil)
560	if hasTool(m, "mcp__fake__extra") {
561		t.Fatal("extra tool should not exist yet")
562	}
563	res := execTool(t, findTool(t, m, "mcp__fake__add_tool"), `{}`)
564	if resultText(res) != "added" {
565		t.Fatalf("add_tool = %q", resultText(res))
566	}
567	eventually(t, "re-listed tools to include extra", func() bool {
568		for _, tool := range m.ServerTools("fake") {
569			if tool.Name == "extra" {
570				return true
571			}
572		}
573		return false
574	})
575	if !hasTool(m, "mcp__fake__extra") {
576		t.Error("Tools() should surface the new tool")
577	}
578}
579
580func TestManagerResourceTools(t *testing.T) {
581	m, _ := startFake(t, nil, nil)
582	list := findTool(t, m, ListResourcesToolName)
583	text := resultText(execTool(t, list, `{}`))
584	for _, want := range []string{"## fake", "- mem://a", "A", "(text/plain)", ": first", "- template mem://item/{id}", "Item"} {
585		if !strings.Contains(text, want) {
586			t.Errorf("list output missing %q:\n%s", want, text)
587		}
588	}
589	if got := resultText(execTool(t, list, `{"server":"fake"}`)); !strings.Contains(got, "mem://a") {
590		t.Errorf("filtered list = %q", got)
591	}
592	if res := execTool(t, list, `{bad`); !res.IsError {
593		t.Error("bad args should be an error result")
594	}
595	if got := resultText(execTool(t, list, `{"server":"nope"}`)); !strings.Contains(got, "unavailable") {
596		t.Errorf("unknown server list = %q", got)
597	}
598
599	read := findTool(t, m, ReadResourceToolName)
600	res := execTool(t, read, `{"server":"fake","uri":"mem://a"}`)
601	if res.IsError || resultText(res) != "[resource mem://a]\ncontent of mem://a" {
602		t.Errorf("read = %+v %q", res, resultText(res))
603	}
604	if d, _ := res.Details.(ToolDetails); d.Server != "fake" {
605		t.Errorf("details = %#v", res.Details)
606	}
607	for name, args := range map[string]string{
608		"missing uri":    `{"server":"fake"}`,
609		"missing server": `{"uri":"mem://a"}`,
610		"unknown server": `{"server":"nope","uri":"mem://a"}`,
611		"invalid json":   `{`,
612	} {
613		if res := execTool(t, read, args); !res.IsError {
614			t.Errorf("%s: want error result, got %q", name, resultText(res))
615		}
616	}
617}
618
619func TestManagerResourceToolsOnlyWhenServerHasResources(t *testing.T) {
620	m, _ := startFake(t, map[string]string{"FAKE_NO_RESOURCES": "1"}, nil)
621	if hasTool(m, ListResourcesToolName) || hasTool(m, ReadResourceToolName) {
622		t.Errorf("resource tools should be absent: %v", toolNames(m))
623	}
624	if len(m.Tools()) != fakeToolCount {
625		t.Errorf("tools = %v", toolNames(m))
626	}
627	if strings.Contains(FormatStatus(m.Status()), "resources") {
628		t.Error("status should not mention resources")
629	}
630}
631
632func TestManagerMultipleServersNameCollision(t *testing.T) {
633	a, _ := fakeStdioConfig(t, nil)
634	b, _ := fakeStdioConfig(t, nil)
635	m, failures, _ := startManager(t, map[string]ServerConfig{"a.b": a, "a_b": b}, "")
636	if len(failures) != 0 {
637		t.Fatal(failures)
638	}
639	seen := map[string]bool{}
640	for _, tool := range m.Tools() {
641		if seen[tool.Name()] {
642			t.Fatalf("duplicate tool name %s", tool.Name())
643		}
644		seen[tool.Name()] = true
645		if len(tool.Name()) > 64 {
646			t.Errorf("name too long: %s", tool.Name())
647		}
648	}
649	// Execute routes to the right server regardless of the hashed name.
650	for _, tool := range m.Tools() {
651		if strings.HasPrefix(tool.Name(), "mcp__a_b__echo") {
652			if got := resultText(execTool(t, tool, `{"text":"z"}`)); got != "echo: z" {
653				t.Errorf("%s -> %q", tool.Name(), got)
654			}
655		}
656	}
657	if got := m.Servers(); len(got) != 2 || got[0] != "a.b" {
658		t.Errorf("Servers = %v", got)
659	}
660}
661
662func TestManagerStartAfterClose(t *testing.T) {
663	cfg, events := fakeStdioConfig(t, nil)
664	m := NewManager(Config{Servers: map[string]ServerConfig{"s": cfg}}, "", ClientInfo{}, nil)
665	_ = m.Close()
666	failures, _ := m.Start(context.Background())
667	if len(failures) != 1 || !strings.Contains(failures[0].Error(), "closed") {
668		t.Errorf("failures = %v", failures)
669	}
670	if _, err := os.Stat(events); err == nil {
671		t.Error("a closed manager must not spawn the server")
672	}
673}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close