1package mcp
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "strings"
10 "testing"
11 "time"
12)
13
14func httpServerConfig(srv *httptest.Server) ServerConfig {
15 return ServerConfig{Type: "http", URL: srv.URL + "/mcp"}
16}
17
18func startHTTP(t *testing.T, fake *httpFake, tweak func(*ServerConfig)) (*Manager, []error) {
19 t.Helper()
20 srv := fake.start(t)
21 cfg := httpServerConfig(srv)
22 if tweak != nil {
23 tweak(&cfg)
24 }
25 m, failures, _ := startManager(t, map[string]ServerConfig{"web": cfg}, "")
26 return m, failures
27}
28
29func TestManagerHTTPEndToEnd(t *testing.T) {
30 for _, mode := range []struct {
31 name string
32 sse bool
33 }{{"json", false}, {"sse", true}} {
34 t.Run(mode.name, func(t *testing.T) {
35 t.Setenv("MCP_T_BEARER", " s3cret-token \n")
36 t.Setenv("MCP_T_ENVHDR", "from-env")
37 fake := newHTTPFake(mode.sse)
38 fake.wantAuth = "Bearer s3cret-token"
39 m, failures := startHTTP(t, fake, func(c *ServerConfig) {
40 c.BearerTokenEnvVar = "MCP_T_BEARER"
41 c.Headers = map[string]string{"X-Static": "static"}
42 c.EnvHeaders = map[string]string{"X-Env": "MCP_T_ENVHDR"}
43 })
44 if len(failures) != 0 {
45 t.Fatalf("failures: %v", failures)
46 }
47 if st := m.Status()[0]; !st.Connected || st.Transport != "http" || st.Tools != fakeToolCount || !st.Resources {
48 t.Fatalf("status = %+v", st)
49 }
50
51 // tool calls of each flavour
52 if got := resultText(execTool(t, findTool(t, m, "mcp__web__echo"), `{"text":"hi"}`)); got != "echo: hi" {
53 t.Errorf("echo = %q", got)
54 }
55 res := execTool(t, findTool(t, m, "mcp__web__image"), `{}`)
56 if len(res.Content) != 2 {
57 t.Errorf("image content = %+v", res.Content)
58 }
59 if res := execTool(t, findTool(t, m, "mcp__web__err"), `{}`); !res.IsError {
60 t.Error("isError lost over http")
61 }
62 if got := resultText(execTool(t, findTool(t, m, "mcp__web__structured"), `{}`)); got != `{"answer":42}` {
63 t.Errorf("structured = %q", got)
64 }
65 if got := resultText(execTool(t, findTool(t, m, ReadResourceToolName), `{"server":"web","uri":"mem://a"}`)); !strings.Contains(got, "content of mem://a") {
66 t.Errorf("read resource = %q", got)
67 }
68 if got := resultText(execTool(t, findTool(t, m, ListResourcesToolName), `{}`)); !strings.Contains(got, "mem://a") || !strings.Contains(got, "template") {
69 t.Errorf("list resources = %q", got)
70 }
71
72 // request headers
73 recs := fake.snapshot()
74 if len(recs) == 0 || recs[0].RPCMethod != "initialize" {
75 t.Fatalf("first request = %+v", recs)
76 }
77 var sawInitialized, sawGet bool
78 for i, r := range recs {
79 if r.Auth != "Bearer s3cret-token" || r.Static != "static" || r.EnvHeader != "from-env" {
80 t.Errorf("record %d missing configured headers: %+v", i, r)
81 }
82 if i == 0 {
83 if r.Session != "" || r.Protocol != "" {
84 t.Errorf("initialize must carry no session/protocol: %+v", r)
85 }
86 continue
87 }
88 if r.Session != "sess-abc" {
89 t.Errorf("record %d (%s %s): session = %q", i, r.Method, r.RPCMethod, r.Session)
90 }
91 if r.Protocol != "2025-06-18" {
92 t.Errorf("record %d (%s %s): protocol = %q", i, r.Method, r.RPCMethod, r.Protocol)
93 }
94 if r.RPCMethod == "notifications/initialized" {
95 sawInitialized = true
96 }
97 if r.Method == http.MethodGet {
98 sawGet = true
99 }
100 }
101 if !sawInitialized {
102 t.Error("notifications/initialized never sent")
103 }
104 _ = sawGet // the GET stream is opened asynchronously; checked separately below.
105
106 // 405 on GET is tolerated: the optional server stream is skipped.
107 eventually(t, "GET stream probe", func() bool {
108 for _, r := range fake.snapshot() {
109 if r.Method == http.MethodGet && r.Session == "sess-abc" && r.Protocol == "2025-06-18" {
110 return true
111 }
112 }
113 return false
114 })
115
116 // Close ends the session with DELETE.
117 if err := m.Close(); err != nil {
118 t.Fatal(err)
119 }
120 select {
121 case sid := <-fake.deleted:
122 if sid != "sess-abc" {
123 t.Errorf("DELETE session = %q", sid)
124 }
125 case <-time.After(5 * time.Second):
126 t.Fatal("no DELETE on Close")
127 }
128 if err := m.Close(); err != nil {
129 t.Fatal(err)
130 }
131 select {
132 case sid := <-fake.deleted:
133 t.Errorf("second Close sent another DELETE (%q)", sid)
134 case <-time.After(50 * time.Millisecond):
135 }
136 })
137 }
138}
139
140func TestManagerHTTPServerRequestDuringSSEResponse(t *testing.T) {
141 fake := newHTTPFake(true)
142 m, failures := startHTTP(t, fake, nil)
143 if len(failures) != 0 {
144 t.Fatal(failures)
145 }
146 res := execTool(t, findTool(t, m, "mcp__web__ask_ping"), `{}`)
147 if res.IsError || resultText(res) != "ping reply: {}" {
148 t.Fatalf("got %q (error=%v)", resultText(res), res.IsError)
149 }
150}
151
152func TestManagerHTTPUnauthorized(t *testing.T) {
153 t.Run("wrong token", func(t *testing.T) {
154 t.Setenv("MCP_T_BEARER", "wrong")
155 fake := newHTTPFake(false)
156 fake.wantAuth = "Bearer right"
157 _, failures := startHTTP(t, fake, func(c *ServerConfig) { c.BearerTokenEnvVar = "MCP_T_BEARER" })
158 if len(failures) != 1 {
159 t.Fatalf("failures = %v", failures)
160 }
161 msg := failures[0].Error()
162 for _, want := range []string{"401", "Unauthorized", "bearerTokenEnvVar", `"web"`} {
163 if !strings.Contains(msg, want) {
164 t.Errorf("message missing %q: %s", want, msg)
165 }
166 }
167 if strings.Contains(msg, "Bearer wrong") {
168 t.Errorf("message leaks the token: %s", msg)
169 }
170 })
171 t.Run("no credentials", func(t *testing.T) {
172 fake := newHTTPFake(true)
173 fake.wantAuth = "Bearer right"
174 _, failures := startHTTP(t, fake, nil)
175 if len(failures) != 1 || !strings.Contains(failures[0].Error(), "401") {
176 t.Fatalf("failures = %v", failures)
177 }
178 })
179 t.Run("missing bearer variable", func(t *testing.T) {
180 os.Unsetenv("MCP_T_NOPE")
181 fake := newHTTPFake(false)
182 _, failures := startHTTP(t, fake, func(c *ServerConfig) { c.BearerTokenEnvVar = "MCP_T_NOPE" })
183 if len(failures) != 1 || !strings.Contains(failures[0].Error(), "MCP_T_NOPE is not set") {
184 t.Fatalf("failures = %v", failures)
185 }
186 if n := len(fake.snapshot()); n != 0 {
187 t.Errorf("no request should be sent without a token, got %d", n)
188 }
189 })
190}
191
192func TestHTTPStatusErrorMessage(t *testing.T) {
193 err := httpStatusError(http.StatusForbidden, []byte(" nope "))
194 got := err.Error()
195 if !strings.Contains(got, "403") || !strings.Contains(got, "bearerTokenEnvVar") || !strings.HasSuffix(got, ": nope") {
196 t.Errorf("got %q", got)
197 }
198 long := httpStatusError(http.StatusInternalServerError, []byte(strings.Repeat("x", 1000))).Error()
199 if len(long) > 400 || strings.Contains(long, "bearerTokenEnvVar") {
200 t.Errorf("long body not trimmed or hint misapplied: %d", len(long))
201 }
202}
203
204func TestManagerHTTPToolTimeoutSendsCancel(t *testing.T) {
205 fake := newHTTPFake(false)
206 m, failures := startHTTP(t, fake, func(c *ServerConfig) { c.ToolTimeoutSec = 0.3 })
207 if len(failures) != 0 {
208 t.Fatal(failures)
209 }
210 res := execTool(t, findTool(t, m, "mcp__web__slow"), `{}`)
211 if !res.IsError || !strings.Contains(resultText(res), "toolTimeoutSec") {
212 t.Fatalf("got %q", resultText(res))
213 }
214 eventually(t, "cancelled notification", func() bool {
215 for _, r := range fake.snapshot() {
216 if r.RPCMethod == "notifications/cancelled" {
217 return true
218 }
219 }
220 return false
221 })
222 if got := resultText(execTool(t, findTool(t, m, "mcp__web__echo"), `{"text":"ok"}`)); got != "echo: ok" {
223 t.Errorf("after timeout = %q", got)
224 }
225}
226
227func TestManagerHTTPStartupFailureUnreachable(t *testing.T) {
228 srv := httptest.NewServer(http.NotFoundHandler())
229 url := srv.URL
230 srv.Close() // nothing listens any more
231 m, failures, reqErr := startManager(t, map[string]ServerConfig{"gone": {Type: "http", URL: url, Required: true}}, "")
232 if len(failures) != 1 || reqErr == nil {
233 t.Fatalf("failures=%v reqErr=%v", failures, reqErr)
234 }
235 if st := m.Status()[0]; st.Connected || st.Err == "" {
236 t.Errorf("status = %+v", st)
237 }
238}
239
240func TestHTTPTransportPlainResponses(t *testing.T) {
241 // 202 for notifications and a JSON batch answer are accepted.
242 var gotNotify bool
243 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244 var msg message
245 _ = json.NewDecoder(r.Body).Decode(&msg)
246 if !msg.isRequest() {
247 gotNotify = true
248 w.WriteHeader(http.StatusAccepted)
249 return
250 }
251 note, _ := encodeNotification("notifications/message", nil)
252 resp, _ := encodeResponse(msg.ID, map[string]any{"ok": true}, nil)
253 w.Header().Set("Content-Type", "application/json; charset=utf-8")
254 _, _ = w.Write([]byte("[" + string(note) + "," + string(resp) + "]"))
255 }))
256 defer srv.Close()
257 tr, err := newHTTPTransport("x", ServerConfig{URL: srv.URL}, nil)
258 if err != nil {
259 t.Fatal(err)
260 }
261 defer tr.close()
262 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
263 defer cancel()
264 if err := tr.notify(ctx, "notifications/initialized", nil); err != nil || !gotNotify {
265 t.Fatalf("notify: %v (server saw=%v)", err, gotNotify)
266 }
267 raw, err := tr.request(ctx, 5, "anything", nil)
268 if err != nil || string(raw) != `{"ok":true}` {
269 t.Fatalf("batch response: %s %v", raw, err)
270 }
271 // After close, requests fail fast.
272 _ = tr.close()
273 if _, err := tr.request(ctx, 6, "anything", nil); err == nil {
274 t.Error("request after close should fail")
275 }
276}
277
278func TestReadSSEFrames(t *testing.T) {
279 stream := ": comment\n" +
280 "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"a\"}\n\n" +
281 "data: {\"jsonrpc\":\"2.0\",\n" +
282 "data: \"id\":3,\"result\":{}}\n\n" +
283 "data: {\"jsonrpc\":\"2.0\",\"method\":\"never\"}\n\n"
284 var methods []string
285 err := readSSEFrames(strings.NewReader(stream), func(m message) bool {
286 methods = append(methods, m.Method)
287 return m.isResponse()
288 })
289 if err != nil {
290 t.Fatal(err)
291 }
292 if len(methods) != 2 || methods[0] != "a" || methods[1] != "" {
293 t.Errorf("methods = %q (multi-line data must join and stop after the response)", methods)
294 }
295 // Trailing event without a blank line is still delivered.
296 n := 0
297 _ = readSSEFrames(strings.NewReader("data: {\"jsonrpc\":\"2.0\",\"method\":\"z\"}"), func(message) bool { n++; return false })
298 if n != 1 {
299 t.Errorf("unterminated event delivered %d times", n)
300 }
301}