main 4509ec7

http_test.go RawHistoryFolder

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}

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