1package mcp
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "sync/atomic"
10 "testing"
11 "time"
12)
13
14// Regression tests for the third review round (platform-independent parts).
15
16// ---- GO-004: HTTP error bodies ----
17
18func TestHTTPErrorBodySecretStraddlingDisplayCut(t *testing.T) {
19 // End to end: secrets straddling the display cut and split by escapes.
20 const secret = "HTTPSECRET-0123456789abcdef"
21 t.Setenv("MCP_T_BEARER", secret)
22 // The secret straddles the 300-byte display cut, and a second copy is
23 // split by escape sequences so it only matches after stripping.
24 body := strings.Repeat("x", maxStatusBodyDisplay-len(secret)/2) + secret + strings.Repeat("y", 2000) +
25 "HTTPSE\x1b[0mCRET-0123456789abcdef"
26 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 http.Error(w, body, http.StatusInternalServerError)
28 }))
29 defer srv.Close()
30 cfg := ServerConfig{Type: "http", URL: srv.URL, BearerTokenEnvVar: "MCP_T_BEARER"}
31 tr, err := newHTTPTransport("x", cfg, nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 defer tr.close()
36 if got := tr.red.overlap(); got < len(secret) {
37 t.Fatalf("overlap %d does not cover the secret length %d", got, len(secret))
38 }
39 _, err = tr.request(context.Background(), 1, "tools/list", nil)
40 if err == nil {
41 t.Fatal("want an error")
42 }
43 for _, frag := range []string{secret, "HTTPSECR", "HTTPS", "SECRET"} {
44 if strings.Contains(err.Error(), frag) {
45 t.Errorf("secret fragment %q leaked: %q", frag, err)
46 }
47 }
48}
49
50func TestStatusErrorBodyTrimsPartialSecrets(t *testing.T) {
51 red := newRedactor(ServerConfig{Env: map[string]string{"K": "TOPSECRET-99"}})
52 // Body cut by the reader in the middle of the secret.
53 err := red.statusErrorBody(500, []byte("failed with token TOPSECR"), true)
54 if strings.Contains(err.Error(), "TOP") {
55 t.Errorf("reader-cut prefix survived: %q", err)
56 }
57 // The same text, complete: a fragment that is not the secret stays.
58 err = red.statusErrorBody(500, []byte("failed with token TOPSECR"), false)
59 if !strings.Contains(err.Error(), "TOPSECR") {
60 t.Errorf("complete bodies must not be trimmed: %q", err)
61 }
62 // A secret hidden by control sequences is matched after stripping, so the
63 // 300-byte display cut never lands inside it. (The trim after that cut is
64 // defense in depth: redaction runs first, so a whole secret is already the
65 // marker when the cut happens and no behavioural input reaches the trim.)
66 long := strings.Repeat("a", 296) + "TOPS" + "\x1b[0mECRET-99" + strings.Repeat("b", 50)
67 err = red.statusErrorBody(500, []byte(long), false)
68 if strings.Contains(err.Error(), "TOPS") || strings.Contains(err.Error(), "SECRET") {
69 t.Errorf("display cut exposed a secret prefix: %q", err)
70 }
71 if !strings.Contains(err.Error(), "…") {
72 t.Errorf("long body not marked cut: %q", err)
73 }
74 if n := len(err.(*HTTPStatusError).Body); n > maxStatusBodyDisplay+len("…") {
75 t.Errorf("body not bounded: %d", n)
76 }
77}
78
79// ---- GO-021: writer-side withdrawal ----
80
81// lateCtx reports an error from Err before Done closes, which lets a test
82// order the writer's and the caller's observations of the cancellation.
83type lateCtx struct {
84 context.Context
85 done chan struct{}
86 err atomic.Bool
87}
88
89func newLateCtx() *lateCtx { return &lateCtx{Context: context.Background(), done: make(chan struct{})} }
90
91func (c *lateCtx) Done() <-chan struct{} { return c.done }
92func (c *lateCtx) Err() error {
93 if c.err.Load() {
94 return context.Canceled
95 }
96 return nil
97}
98
99func TestWriteFrameWriterSideSkipDoesNotFailTransport(t *testing.T) {
100 c, _ := connectFake(t, nil)
101 st := c.t.(*stdioTransport)
102 ctx := newLateCtx()
103 ctx.err.Store(true) // the writer will see an ended context at dequeue...
104
105 skipped := make(chan struct{})
106 release := make(chan struct{})
107 st.hookSkipped = func() {
108 close(ctx.done) // ...and only now does the caller learn of it
109 close(skipped)
110 <-release // keep the writer from reporting, so the caller takes the Done branch
111 }
112 type result struct {
113 sent bool
114 err error
115 }
116 res := make(chan result, 1)
117 go func() {
118 sent, err := st.writeFrame(ctx, []byte(`{"jsonrpc":"2.0","method":"notifications/x"}`))
119 res <- result{sent, err}
120 }()
121 select {
122 case <-skipped:
123 case <-time.After(5 * time.Second):
124 t.Fatal("writer never skipped the frame")
125 }
126 var r result
127 select {
128 case r = <-res:
129 case <-time.After(5 * time.Second):
130 t.Fatal("caller did not return")
131 }
132 close(release)
133 if r.sent || !errors.Is(r.err, context.Canceled) {
134 t.Errorf("sent=%v err=%v, want withdrawn", r.sent, r.err)
135 }
136 select {
137 case <-st.done():
138 t.Fatalf("a frame withdrawn by the writer failed the transport: %v", st.failure())
139 default:
140 }
141 // The transport still works.
142 if got := callText(t, c, "echo", `{"text":"ok"}`); got != "echo: ok" {
143 t.Errorf("after skip: %q", got)
144 }
145}
146
147// ---- GO-022: budget marker after rune rounding ----
148
149func TestBudgetedFieldMarksTruncationAfterRuneRounding(t *testing.T) {
150 b := &budgetedText{}
151 b.write(strings.Repeat("a", maxResultTextBytes-4)) // remaining = 4
152 // remaining+1 = 5 bytes lands inside the second 3-byte rune; rounding
153 // down gives 3 bytes, which is within budget, yet text was dropped.
154 got := b.field("日日日日")
155 if got != "日" {
156 t.Fatalf("field = %q", got)
157 }
158 if !b.clipped {
159 t.Fatal("clipping not recorded")
160 }
161 b.write("- " + got)
162 if !b.full() {
163 t.Error("truncation flag not set although text was clipped")
164 }
165 if !strings.HasSuffix(b.String(), "[truncated at 51200 bytes]") {
166 t.Errorf("marker missing: %q", b.String()[len(b.String())-40:])
167 }
168 // Later writes are ignored.
169 before := b.String()
170 b.write("more")
171 if b.String() != before {
172 t.Error("write accepted after truncation")
173 }
174 // A field that fits is neither clipped nor marked.
175 c := &budgetedText{}
176 if got := c.field("日本"); got != "日本" || c.clipped {
177 t.Errorf("field = %q clipped=%v", got, c.clipped)
178 }
179 c.write("- 日本\n")
180 if c.full() || strings.Contains(c.String(), "truncated") {
181 t.Error("unclipped field must not be marked")
182 }
183}