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}