v0.51.0 a9d674e

hardening_test.go RawHistoryFolder

1package mcp
2
3import (
4	"context"
5	"errors"
6	"net/http"
7	"net/http/httptest"
8	"net/url"
9	"strconv"
10	"strings"
11	"sync/atomic"
12	"testing"
13	"time"
14)
15
16// Regression tests for the review findings: write path, SSE limits, session
17// expiry, diagnostics hygiene, process cleanup, refresh coalescing, and the
18// cancellation rules.
19
20func connectFake(t *testing.T, extraEnv map[string]string) (*Client, string) {
21	t.Helper()
22	cfg, events := fakeStdioConfig(t, extraEnv)
23	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
24	defer cancel()
25	c, err := Connect(ctx, "fake", cfg, "", ClientInfo{Name: "t"}, nil)
26	if err != nil {
27		t.Fatalf("Connect: %v", err)
28	}
29	t.Cleanup(func() { _ = c.Close() })
30	return c, events
31}
32
33func callText(t *testing.T, c *Client, tool, args string) string {
34	t.Helper()
35	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
36	defer cancel()
37	res, err := c.CallTool(ctx, tool, []byte(args))
38	if err != nil {
39		t.Fatalf("CallTool %s: %v", tool, err)
40	}
41	if len(res.Content) == 0 {
42		t.Fatalf("CallTool %s: no content", tool)
43	}
44	return res.Content[0].Text
45}
46
47// ---- 1, 8: cancellable writes, close behind a stuck writer ----
48
49func TestStdioWriteStuckChildDoesNotWedgeCancelOrClose(t *testing.T) {
50	c, events := connectFake(t, nil)
51
52	// The server stops reading stdin after receiving this call.
53	stallCtx, stallCancel := context.WithCancel(context.Background())
54	stallDone := make(chan error, 1)
55	go func() {
56		_, err := c.CallTool(stallCtx, "stall", []byte(`{}`))
57		stallDone <- err
58	}()
59	waitEvent(t, events, "stalled")
60
61	// A frame far larger than the pipe buffer now blocks the writer.
62	big := `{"text":"` + strings.Repeat("a", 4<<20) + `"}`
63	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
64	defer cancel()
65	start := time.Now()
66	_, err := c.CallTool(ctx, "echo", []byte(big))
67	if !errors.Is(err, context.DeadlineExceeded) {
68		t.Fatalf("err = %v, want deadline exceeded", err)
69	}
70	if d := time.Since(start); d > 3*time.Second {
71		t.Fatalf("request ignored its context for %v", d)
72	}
73
74	closed := make(chan struct{})
75	go func() { _ = c.Close(); close(closed) }()
76	select {
77	case <-closed:
78	case <-time.After(10 * time.Second):
79		t.Fatal("Close hung behind a blocked write")
80	}
81	select {
82	case <-c.Done():
83	default:
84		t.Error("Done not closed after Close")
85	}
86	stallCancel()
87	select {
88	case <-stallDone:
89	case <-time.After(5 * time.Second):
90		t.Fatal("pending call not released by Close")
91	}
92}
93
94// ---- 2: SSE aggregate limit ----
95
96func TestReadSSEFramesAggregateLimit(t *testing.T) {
97	line := "data: " + strings.Repeat("x", 9) + "\n" // 9 bytes of data per line
98	var delivered int
99	handle := func(message) bool { delivered++; return false }
100
101	// Many short lines of one event exceed the limit.
102	err := readSSEFramesLimit(strings.NewReader(strings.Repeat(line, 200)+"\n"), 1000, handle)
103	var tooLarge errSSEEventTooLarge
104	if !errors.As(err, &tooLarge) || !strings.Contains(err.Error(), "1000") {
105		t.Fatalf("err = %v, want errSSEEventTooLarge", err)
106	}
107	if delivered != 0 {
108		t.Errorf("oversized event delivered %d frames", delivered)
109	}
110
111	// The limit is per event: a blank line resets it.
112	ok := `data: {"jsonrpc":"2.0","method":"a"}` + "\n\n"
113	delivered = 0
114	if err := readSSEFramesLimit(strings.NewReader(strings.Repeat(ok, 50)), 100, handle); err != nil {
115		t.Fatal(err)
116	}
117	if delivered != 50 {
118		t.Errorf("delivered %d, want 50", delivered)
119	}
120
121	// A single line longer than the limit is the same explicit error.
122	err = readSSEFramesLimit(strings.NewReader("data: "+strings.Repeat("y", 5000)+"\n\n"), 1000, handle)
123	if !errors.As(err, &tooLarge) {
124		t.Errorf("long line err = %v", err)
125	}
126}
127
128func TestSSEEndlessEventStopsAtLimit(t *testing.T) {
129	// A server that never ends its event must not grow memory without bound.
130	err := readSSEFramesLimit(&infiniteSSE{}, 64<<10, func(message) bool { return false })
131	var tooLarge errSSEEventTooLarge
132	if !errors.As(err, &tooLarge) {
133		t.Fatalf("err = %v", err)
134	}
135}
136
137type infiniteSSE struct{}
138
139func (*infiniteSSE) Read(p []byte) (int, error) {
140	line := []byte("data: " + strings.Repeat("q", 100) + "\n")
141	n := copy(p, line)
142	return n, nil
143}
144
145// ---- 3: session expiry ----
146
147func TestHTTPSessionExpiredReconnects(t *testing.T) {
148	for _, mode := range []struct {
149		name string
150		sse  bool
151	}{{"json", false}, {"sse", true}} {
152		t.Run(mode.name, func(t *testing.T) {
153			fake := newHTTPFake(mode.sse)
154			m, failures := startHTTP(t, fake, nil)
155			if len(failures) != 0 {
156				t.Fatal(failures)
157			}
158			echo := findTool(t, m, "mcp__web__echo")
159			fake.expireNextCall.Store(true)
160			res := execTool(t, echo, `{"text":"one"}`)
161			if !res.IsError || !strings.Contains(resultText(res), "session expired") {
162				t.Fatalf("expired call = %q (error=%v)", resultText(res), res.IsError)
163			}
164			// The transport is unusable, so the status reflects it at once.
165			if st := m.Status()[0]; st.Connected {
166				t.Errorf("still marked connected after 404: %+v", st)
167			}
168			// The next call reconnects (fresh initialize) and succeeds.
169			if got := resultText(execTool(t, echo, `{"text":"two"}`)); got != "echo: two" {
170				t.Fatalf("after reconnect = %q", got)
171			}
172			inits, calls := fake.counts()
173			if inits != 2 {
174				t.Errorf("initialize count = %d, want 2", inits)
175			}
176			if calls != 2 {
177				t.Errorf("tools/call count = %d, want 2 (the failed call must not be replayed)", calls)
178			}
179			// No DELETE for the dead session.
180			select {
181			case sid := <-fake.deleted:
182				t.Errorf("DELETE sent for expired session %q", sid)
183			default:
184			}
185			// The new session carries its own id.
186			var last httpRecord
187			for _, r := range fake.snapshot() {
188				if r.RPCMethod == "tools/call" {
189					last = r
190				}
191			}
192			if last.Session != "sess-abc-2" {
193				t.Errorf("session after reconnect = %q", last.Session)
194			}
195		})
196	}
197}
198
199func TestHTTPNotFoundWithoutSessionIsOrdinaryError(t *testing.T) {
200	srv := httptest.NewServer(http.NotFoundHandler())
201	defer srv.Close()
202	tr, err := newHTTPTransport("x", ServerConfig{URL: srv.URL}, nil)
203	if err != nil {
204		t.Fatal(err)
205	}
206	defer tr.close()
207	_, err = tr.request(context.Background(), 1, "initialize", nil)
208	var he *HTTPStatusError
209	if !errors.As(err, &he) || he.Status != 404 {
210		t.Fatalf("err = %v", err)
211	}
212	select {
213	case <-tr.done():
214		t.Error("a 404 before any session must not poison the transport")
215	default:
216	}
217}
218
219// ---- 4, 13: diagnostics hygiene ----
220
221func TestScrubURLsAndErrors(t *testing.T) {
222	red := newRedactor(ServerConfig{})
223	uerr := &url.Error{Op: "Post", URL: "https://user:pw@h.example:8443/mcp/v1?token=abc&x=1#frag", Err: errors.New("connection refused")}
224	got := red.scrubError(uerr).Error()
225	if want := `Post "https://h.example:8443/mcp/v1": connection refused`; got != want {
226		t.Errorf("got %q, want %q", got, want)
227	}
228	var target *url.Error
229	if !errors.As(red.scrubError(uerr), &target) {
230		t.Error("original error must stay reachable via errors.As")
231	}
232	for in, want := range map[string]string{
233		"see http://a.example/p?k=v for details":       "see http://a.example/p for details",
234		"ws://u@h/x and https://h2/y?q=1":              "ws://h/x and https://h2/y",
235		"no urls here":                                 "no urls here",
236		"odd-scheme://x:y@z/p?secret=1,next":           "odd-scheme://z/p",
237		"bad scheme:// and http://host?only=query end": "bad scheme:// and http://host end",
238	} {
239		if got := scrubURLs(in); got != want {
240			t.Errorf("scrubURLs(%q) = %q, want %q", in, got, want)
241		}
242	}
243}
244
245func TestRedactorValues(t *testing.T) {
246	t.Setenv("MCP_T_HDR", "hdr-secret-77")
247	t.Setenv("MCP_T_BEARER2", " bearer-secret-99 ")
248	t.Setenv("MCP_T_PASS2", "passthru-secret-5")
249	red := newRedactor(ServerConfig{
250		Env:               map[string]string{"A": "env-secret-1", "SHORT": "ab"},
251		Headers:           map[string]string{"Authorization": "Bearer static-secret-2"},
252		EnvHeaders:        map[string]string{"X": "MCP_T_HDR"},
253		BearerTokenEnvVar: "MCP_T_BEARER2",
254		EnvVars:           []string{"MCP_T_PASS2"},
255		URL:               "https://u:urlpass-3@h.example/p?key=querysecret-4",
256	})
257	in := "env-secret-1 static-secret-2 Bearer static-secret-2 hdr-secret-77 bearer-secret-99 passthru-secret-5 urlpass-3 querysecret-4 ab"
258	out := red.scrub(in)
259	for _, secret := range []string{"env-secret-1", "static-secret-2", "hdr-secret-77", "bearer-secret-99", "passthru-secret-5", "urlpass-3", "querysecret-4"} {
260		if strings.Contains(out, secret) {
261			t.Errorf("%q leaked in %q", secret, out)
262		}
263	}
264	if !strings.HasSuffix(out, " ab") {
265		t.Errorf("short values must not be redacted: %q", out)
266	}
267	var nilRed *redactor
268	if got := nilRed.scrub("plain \x1b[1mtext"); got != "plain text" {
269		t.Errorf("nil redactor = %q", got)
270	}
271}
272
273func TestSanitizeText(t *testing.T) {
274	cases := []struct {
275		name, in string
276		keepNL   bool
277		want     string
278	}{
279		{"csi", "a\x1b[31mred\x1b[0mb", true, "aredb"},
280		{"osc bel", "x\x1b]0;title\x07y", true, "xy"},
281		{"osc st", "\x1b]8;;http://e\x1b\\link\x1b]8;;\x1b\\", true, "link"},
282		{"dcs", "p\x1bPq#0;2\x1b\\q", true, "pq"},
283		{"c1 csi", "\u009b31mX", true, "X"},
284		{"cr dropped", "a\rb", true, "ab"},
285		{"tab", "a\tb", true, "a b"},
286		{"newline kept", "l1\nl2", true, "l1\nl2"},
287		{"newline folded", "l1\nl2", false, "l1 l2"},
288		{"bidi", "\u202eevil\u2066", true, "evil"},
289		{"controls", "\x00a\x07b\x7fc", true, "abc"},
290		{"lone esc", "end\x1b", true, "end"},
291		{"unicode kept", "héllo ✓ 日本", true, "héllo ✓ 日本"},
292		{"unterminated osc", "a\x1b]0;never ends", true, "a"},
293		{"esc cancels osc", "a\x1b]0;t\x1b[31mred", true, "ared"},
294		{"charset", "a\x1b(Bb", true, "ab"},
295	}
296	for _, c := range cases {
297		if got := sanitizeText(c.in, c.keepNL); got != c.want {
298			t.Errorf("%s: sanitizeText(%q) = %q, want %q", c.name, c.in, got, c.want)
299		}
300	}
301}
302
303func TestFormatStatusStripsControlSequences(t *testing.T) {
304	text := FormatStatus([]ServerStatus{{
305		Name:      "ev\x1b]0;pwn\x07il",
306		Transport: "stdio",
307		Err:       "boom \x1b[31mred\x1b[0m\x1b]52;c;ZXZpbA==\x07\nsecond line",
308	}})
309	if strings.ContainsAny(text, "\x1b\x07") {
310		t.Errorf("control characters survived: %q", text)
311	}
312	if !strings.Contains(text, "evil (stdio): disconnected") || !strings.Contains(text, "error: boom red") || strings.Contains(text, "second line") {
313		t.Errorf("text = %q", text)
314	}
315}
316
317func TestStdioDiagnosticsAreScrubbed(t *testing.T) {
318	const secret = "s3cr3t-value"
319	cfg, _ := fakeStdioConfig(t, map[string]string{"FAKE_INIT_ERR": "1", "FAKE_ERR_ECHO": secret, "FAKE_ANSI": "1"})
320	_, failures, _ := startManager(t, map[string]ServerConfig{"s": cfg}, "")
321	if len(failures) != 1 {
322		t.Fatalf("failures = %v", failures)
323	}
324	msg := failures[0].Error()
325	if strings.Contains(msg, secret) {
326		t.Errorf("secret leaked: %q", msg)
327	}
328	if strings.ContainsAny(msg, "\x1b\x07") || strings.Contains(msg, "evil-title") {
329		t.Errorf("control sequences survived: %q", msg)
330	}
331	for _, want := range []string{redactedMarker, "fatal: missing credentials", "http://example.invalid/x", "red end"} {
332		if !strings.Contains(msg, want) {
333			t.Errorf("message missing %q: %q", want, msg)
334		}
335	}
336	if strings.Contains(msg, "?k=") {
337		t.Errorf("URL query survived: %q", msg)
338	}
339}
340
341func TestHTTPDiagnosticsAreScrubbed(t *testing.T) {
342	t.Run("error body", func(t *testing.T) {
343		t.Setenv("MCP_T_BEARER", "tok-abcdef123")
344		fake := newHTTPFake(false)
345		fake.errorBody = "denied tok-abcdef123 \x1b[31mred\x1b[0m at http://x.example/p?token=tok-abcdef123"
346		_, failures := startHTTP(t, fake, func(c *ServerConfig) { c.BearerTokenEnvVar = "MCP_T_BEARER" })
347		if len(failures) != 1 {
348			t.Fatalf("failures = %v", failures)
349		}
350		msg := failures[0].Error()
351		if strings.Contains(msg, "tok-abcdef123") || strings.ContainsRune(msg, 0x1b) || strings.Contains(msg, "?token") {
352			t.Errorf("unsanitized: %q", msg)
353		}
354		if !strings.Contains(msg, redactedMarker) || !strings.Contains(msg, "500") {
355			t.Errorf("message = %q", msg)
356		}
357	})
358	t.Run("url error", func(t *testing.T) {
359		srv := httptest.NewServer(http.NotFoundHandler())
360		base := srv.URL
361		srv.Close()
362		cfg := ServerConfig{Type: "http", URL: base + "/mcp?access_token=QUERYSECRET"}
363		_, failures, _ := startManager(t, map[string]ServerConfig{"gone": cfg}, "")
364		if len(failures) != 1 {
365			t.Fatalf("failures = %v", failures)
366		}
367		msg := failures[0].Error()
368		if strings.Contains(msg, "QUERYSECRET") || strings.Contains(msg, "access_token") {
369			t.Errorf("query leaked: %q", msg)
370		}
371		if !strings.Contains(msg, "127.0.0.1") {
372			t.Errorf("host should remain for debugging: %q", msg)
373		}
374	})
375}
376
377// ---- 5: process cleanup ----
378
379func TestStdioCloseKillsDescendants(t *testing.T) {
380	if !canCheckProcess {
381		t.Skip("process liveness is not observable on this platform")
382	}
383	for _, mode := range []string{"sleeper", "sleeper-stubborn"} {
384		t.Run(mode, func(t *testing.T) {
385			c, _ := connectFake(t, nil)
386			pid, err := strconv.Atoi(callText(t, c, "spawn", `{"mode":"`+mode+`"}`))
387			if err != nil {
388				t.Fatal(err)
389			}
390			if !processAlive(pid) {
391				t.Fatal("descendant is not running before Close")
392			}
393			// Make sure we never leak it even if the assertion below fails.
394			t.Cleanup(func() { killPID(pid) })
395			_ = c.Close()
396			eventually(t, "descendant to die", func() bool { return !processAlive(pid) })
397		})
398	}
399}
400
401// ---- 6, 10: refresh coalescing ----
402
403func TestToolRefresherCoalescesAndSerializes(t *testing.T) {
404	r := newToolRefresher()
405	var running, maxRunning, runs atomic.Int32
406	release := make(chan struct{})
407	started := make(chan struct{}, 16)
408
409	r.notify() // before start: remembered, not lost
410	r.start(func(ctx context.Context) {
411		n := running.Add(1)
412		for {
413			old := maxRunning.Load()
414			if n <= old || maxRunning.CompareAndSwap(old, n) {
415				break
416			}
417		}
418		runs.Add(1)
419		started <- struct{}{}
420		select {
421		case <-release:
422		case <-ctx.Done():
423		}
424		running.Add(-1)
425	})
426	select {
427	case <-started:
428	case <-time.After(5 * time.Second):
429		t.Fatal("notification before start never triggered a listing")
430	}
431	for i := 0; i < 100; i++ {
432		r.notify() // all arrive during the running listing
433	}
434	close(release)
435	eventually(t, "refresher to go idle", func() bool {
436		r.mu.Lock()
437		defer r.mu.Unlock()
438		return !r.running
439	})
440	if got := runs.Load(); got != 2 {
441		t.Errorf("runs = %d, want exactly 2 (initial + one coalesced re-run)", got)
442	}
443	if got := maxRunning.Load(); got != 1 {
444		t.Errorf("max concurrent listings = %d, want 1", got)
445	}
446}
447
448func TestToolRefresherStop(t *testing.T) {
449	r := newToolRefresher()
450	entered := make(chan struct{})
451	finished := make(chan struct{})
452	var runs atomic.Int32
453	r.start(func(ctx context.Context) {
454		runs.Add(1)
455		close(entered)
456		<-ctx.Done() // only a stop can end this listing
457		close(finished)
458	})
459	r.notify()
460	<-entered
461	r.notify() // dirty again while running
462	r.stop()
463	select {
464	case <-finished:
465	case <-time.After(5 * time.Second):
466		t.Fatal("stop did not cancel the running listing")
467	}
468	r.notify()
469	eventually(t, "refresher idle", func() bool {
470		r.mu.Lock()
471		defer r.mu.Unlock()
472		return !r.running
473	})
474	if got := runs.Load(); got != 1 {
475		t.Errorf("runs after stop = %d, want 1", got)
476	}
477}
478
479func TestManagerStopsRefresherOnClose(t *testing.T) {
480	m, _ := startFake(t, nil, nil)
481	s := m.servers["fake"]
482	s.mu.Lock()
483	r := s.refresher
484	s.mu.Unlock()
485	if r == nil {
486		t.Fatal("no refresher installed")
487	}
488	_ = m.Close()
489	r.mu.Lock()
490	stopped := r.stopped
491	r.mu.Unlock()
492	if !stopped {
493		t.Error("refresher still active after Close")
494	}
495	s.mu.Lock()
496	left := s.refresher
497	s.mu.Unlock()
498	if left != nil {
499		t.Error("refresher reference kept after Close")
500	}
501}
502
503func TestManagerListChangedDuringInitialListing(t *testing.T) {
504	// The server announces a changed tool set right after answering the last
505	// page of the first listing, before Start has published the client.
506	m, _ := startFake(t, map[string]string{"FAKE_NOTIFY_ON_LIST": "1"}, nil)
507	eventually(t, "tool added during initial listing to appear", func() bool {
508		for _, tool := range m.ServerTools("fake") {
509			if tool.Name == "extra" {
510				return true
511			}
512		}
513		return false
514	})
515}
516
517// ---- 7: stderr draining ----
518
519func TestStdioStderrFloodDoesNotBlockChild(t *testing.T) {
520	c, _ := connectFake(t, nil)
521	// 3 MiB on one stderr line, then a short one. The child blocks on the
522	// stderr pipe unless the client keeps draining it.
523	if got := callText(t, c, "stderr_flood", `{}`); got != "flooded" {
524		t.Fatalf("got %q", got)
525	}
526	st := c.t.(*stdioTransport)
527	eventually(t, "stderr tail to include the last line", func() bool {
528		return strings.Contains(st.stderrTail(), "after-flood")
529	})
530	tail := st.stderrTail()
531	if !strings.Contains(tail, "[line truncated]") {
532		t.Error("long line should be marked truncated")
533	}
534	if len(tail) > stderrTailLines*(maxStderrLine+64) {
535		t.Errorf("tail not bounded: %d bytes", len(tail))
536	}
537	// The server still works afterwards.
538	if got := callText(t, c, "echo", `{"text":"ok"}`); got != "echo: ok" {
539		t.Errorf("after flood: %q", got)
540	}
541}
542
543// ---- 8, 9: cancellation notifications ----
544
545func TestStdioCancelNotificationRules(t *testing.T) {
546	// initialize never answers within the deadline.
547	cfg, _ := fakeStdioConfig(t, map[string]string{"FAKE_INIT_DELAY_MS": "5000"})
548	st, err := startStdio("x", cfg, "")
549	if err != nil {
550		t.Fatal(err)
551	}
552	defer st.close()
553	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
554	defer cancel()
555	if _, err := st.request(ctx, 1, "initialize", map[string]any{"protocolVersion": LatestProtocolVersion}); !errors.Is(err, context.DeadlineExceeded) {
556		t.Fatalf("err = %v", err)
557	}
558	if got := st.cancelsSent.Load(); got != 0 {
559		t.Errorf("notifications/cancelled sent for initialize (%d)", got)
560	}
561}
562
563func TestStdioCancelNotificationForSentRequest(t *testing.T) {
564	c, events := connectFake(t, nil)
565	st := c.t.(*stdioTransport)
566	ctx, cancel := context.WithCancel(context.Background())
567	done := make(chan error, 1)
568	go func() {
569		_, err := c.CallTool(ctx, "slow", []byte(`{}`))
570		done <- err
571	}()
572	waitEvent(t, events, "slow-started")
573	start := time.Now()
574	cancel()
575	if err := <-done; !errors.Is(err, context.Canceled) {
576		t.Fatalf("err = %v", err)
577	}
578	if time.Since(start) > time.Second {
579		t.Error("caller delayed by the cancel notification")
580	}
581	if got := st.cancelsSent.Load(); got != 1 {
582		t.Errorf("cancel count = %d, want 1", got)
583	}
584	waitEvent(t, events, "cancelled:")
585}
586
587func TestHTTPInitializeNotCancelledOnTimeout(t *testing.T) {
588	fake := newHTTPFake(false)
589	fake.hangInit = true
590	srv := fake.start(t)
591	cfg := httpServerConfig(srv)
592	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
593	defer cancel()
594	_, err := Connect(ctx, "web", cfg, "", ClientInfo{}, nil)
595	if !errors.Is(err, context.DeadlineExceeded) {
596		t.Fatalf("err = %v, want deadline exceeded", err)
597	}
598	for _, r := range fake.snapshot() {
599		if r.RPCMethod == "notifications/cancelled" {
600			t.Fatal("notifications/cancelled sent for initialize")
601		}
602	}
603}
604
605func TestHTTPCancelNotificationIsBoundedAndOwnedByTransport(t *testing.T) {
606	fake := newHTTPFake(false)
607	fake.cancelGate = make(chan struct{})
608	t.Cleanup(func() { close(fake.cancelGate) })
609	m, failures := startHTTP(t, fake, func(c *ServerConfig) { c.ToolTimeoutSec = 0.2 })
610	if len(failures) != 0 {
611		t.Fatal(failures)
612	}
613	start := time.Now()
614	res := execTool(t, findTool(t, m, "mcp__web__slow"), `{}`)
615	if !res.IsError || !strings.Contains(resultText(res), "toolTimeoutSec") {
616		t.Fatalf("got %q", resultText(res))
617	}
618	// The cancellation POST is stuck on the server (gate closed); the caller
619	// must not wait for it.
620	if d := time.Since(start); d > 1500*time.Millisecond {
621		t.Fatalf("caller delayed by the cancel notification: %v", d)
622	}
623	eventually(t, "cancel notification to reach the server", func() bool {
624		for _, r := range fake.snapshot() {
625			if r.RPCMethod == "notifications/cancelled" {
626				return true
627			}
628		}
629		return false
630	})
631	// Closing the transport aborts the stuck notification; otherwise the
632	// httptest server would wait for it at cleanup.
633	closed := make(chan struct{})
634	go func() { _ = m.Close(); close(closed) }()
635	select {
636	case <-closed:
637	case <-time.After(10 * time.Second):
638		t.Fatal("Close hung behind the cancel notification")
639	}
640}
641
642// ---- 11: file URIs ----
643
644func TestFileURI(t *testing.T) {
645	cases := []struct {
646		name, path string
647		windows    bool
648		want       string
649	}{
650		{"unix", "/home/me/proj", false, "file:///home/me/proj"},
651		{"unix space", "/home/me/my proj", false, "file:///home/me/my%20proj"},
652		{"drive", `C:\Users\me\proj`, true, "file:///C:/Users/me/proj"},
653		{"drive space", `D:\My Docs\x`, true, "file:///D:/My%20Docs/x"},
654		{"drive root", `C:\`, true, "file:///C:/"},
655		{"drive forward slashes", `C:/x/y`, true, "file:///C:/x/y"},
656		{"unc", `\\host\share\dir`, true, "file://host/share/dir"},
657		{"unc share only", `\\host\share`, true, "file://host/share"},
658		{"extended drive", `\\?\C:\x`, true, "file:///C:/x"},
659		{"extended unc", `\\?\UNC\host\share\d`, true, "file://host/share/d"},
660	}
661	for _, c := range cases {
662		if got := fileURI(c.path, c.windows); got != c.want {
663			t.Errorf("%s: fileURI(%q) = %q, want %q", c.name, c.path, got, c.want)
664		}
665	}
666}
667
668// ---- 12: DELETE shape ----
669
670func TestHTTPDeleteCarriesProtocolVersionAndHeaders(t *testing.T) {
671	t.Setenv("MCP_T_BEARER", "tok-delete-1")
672	fake := newHTTPFake(false)
673	m, failures := startHTTP(t, fake, func(c *ServerConfig) {
674		c.BearerTokenEnvVar = "MCP_T_BEARER"
675		c.Headers = map[string]string{"X-Static": "static"}
676	})
677	if len(failures) != 0 {
678		t.Fatal(failures)
679	}
680	_ = m.Close()
681	select {
682	case <-fake.deleted:
683	case <-time.After(5 * time.Second):
684		t.Fatal("no DELETE")
685	}
686	var del *httpRecord
687	for _, r := range fake.snapshot() {
688		if r.Method == http.MethodDelete {
689			r := r
690			del = &r
691		}
692	}
693	if del == nil {
694		t.Fatal("DELETE not recorded")
695	}
696	if del.Protocol != "2025-06-18" || del.Session != "sess-abc" || del.Auth != "Bearer tok-delete-1" || del.Static != "static" {
697		t.Errorf("DELETE = %+v", *del)
698	}
699}
700
701// ---- 14: resource listing budget ----
702
703func TestListResourcesAppliesBudgetWhileFormatting(t *testing.T) {
704	m, events := startFake(t, map[string]string{"FAKE_MANY_RESOURCES": "2000"}, nil)
705	text := resultText(execTool(t, findTool(t, m, ListResourcesToolName), `{}`))
706	marker := "\n... [truncated at 51200 bytes]"
707	if !strings.Contains(text, marker) {
708		t.Fatalf("no truncation marker (len=%d)", len(text))
709	}
710	if len(text) > 50*1024+len(marker) {
711		t.Errorf("output not bounded: %d bytes", len(text))
712	}
713	if !strings.Contains(text, "mem://item/0") || strings.Contains(text, "mem://item/1999") {
714		t.Error("expected the head of the list, not the tail")
715	}
716	// Once the budget is spent no further requests are made.
717	if n := countEvents(events, "list-templates"); n != 0 {
718		t.Errorf("templates were fetched after the budget was exhausted (%d)", n)
719	}
720	if n := countEvents(events, "list-resources"); n != 1 {
721		t.Errorf("list-resources calls = %d", n)
722	}
723}
724
725func TestBudgetedText(t *testing.T) {
726	b := &budgetedText{}
727	b.write(strings.Repeat("a", maxResultTextBytes-1))
728	if b.full() {
729		t.Fatal("full too early")
730	}
731	b.write("bcdef")
732	if !b.full() {
733		t.Fatal("not full after overflow")
734	}
735	before := b.String()
736	b.write("more")
737	b.printf("%s", "more")
738	if b.String() != before {
739		t.Error("writes accepted after the budget was exhausted")
740	}
741	if !strings.HasSuffix(before, "[truncated at 51200 bytes]") {
742		t.Errorf("tail = %q", before[len(before)-40:])
743	}
744}

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 477

↑↓ moveenter openesc close