package mcpsrv import ( "bufio" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // The wrapper privateCache hands down, tested where it can be reached. The // endpoint-level statement — that every answer of /mcp carries these two headers // — is in cache_test.go; what is here is the three ways a handler can commit a // response, because a wrapper that catches only one of them passes every header // test while setting no header at all on the path the SDK actually takes. // TestHeadersLandOnAnExplicitWriteHeader is the ordinary path. func TestHeadersLandOnAnExplicitWriteHeader(t *testing.T) { h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) assert.Equal(t, http.StatusAccepted, rec.Code) assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control")) assert.Equal(t, cacheVary, rec.Header().Get("Vary")) } // TestHeadersLandOnAnImplicitCommit covers the handler that never calls // WriteHeader at all. net/http commits on the first Write and drops every header // set after that point, silently. func TestHeadersLandOnAnImplicitCommit(t *testing.T) { h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"jsonrpc":"2.0"}`)) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control")) assert.Equal(t, cacheVary, rec.Header().Get("Vary")) assert.Equal(t, `{"jsonrpc":"2.0"}`, rec.Body.String()) } // TestHeadersLandOnAFlush is the third commit, and the one a stream takes: an // SSE handler writes nothing before its first flush, so a wrapper that hooks // Write and WriteHeader alone leaves the response uncached-marked exactly on the // answers that stay open longest. func TestHeadersLandOnAFlush(t *testing.T) { h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { require.NoError(t, http.NewResponseController(w).Flush()) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/mcp", nil)) assert.True(t, rec.Flushed) assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control")) assert.Equal(t, cacheVary, rec.Header().Get("Vary")) } // TestTheSDKsOwnDirectivesAreOverridden is why this is a wrapper and not a // middleware that sets the headers on the way in. The streamable transport sets // Cache-Control itself, with Set, from inside the handler, so anything written // before it runs loses. The handler here does exactly what the SDK does. func TestTheSDKsOwnDirectivesAreOverridden(t *testing.T) { h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-transform") w.WriteHeader(http.StatusOK) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) assert.Equal(t, cacheControl, rec.Header().Get("Cache-Control"), "no-cache lets a cache store the body and revalidate, which is the thing no-store forbids") assert.Equal(t, cacheVary, rec.Header().Get("Vary")) } // TestOnlyTheFirstCommitWrites checks the committed flag: a handler that writes // after committing must not have its headers re-Set on every Write. func TestOnlyTheFirstCommitWrites(t *testing.T) { h := privateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("a")) _, _ = w.Write([]byte("b")) })) rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) assert.Equal(t, []string{cacheControl}, rec.Header().Values("Cache-Control")) assert.Equal(t, []string{cacheVary}, rec.Header().Values("Vary")) assert.Equal(t, "ab", rec.Body.String()) } // TestUnwrapReachesTheWriterBelow is the test the Unwrap method exists for, and // it deliberately does not measure flushing. // // The sentence usually attached to Unwrap on a writer like this — that without // it the flusher is hidden and SSE breaks — is not true of this type: Flush is a // method on cacheWriter, so a controller finds that one first and never unwraps. // (Measured, not assumed: with Unwrap deleted every other test in this file, and // every MCP session the rest of this package opens, stays green.) What stops // working is everything cacheWriter does not implement itself, and the deadlines // are the reachable half of that — so this is written against a deadline, which // is the call that actually goes ErrNotSupported. // // It runs over a real server because httptest.ResponseRecorder supports no // deadline at all, and a recorder therefore cannot tell the two cases apart. func TestUnwrapReachesTheWriterBelow(t *testing.T) { var deadlineErr error srv := httptest.NewServer(privateCache(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { deadlineErr = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(time.Minute)) }))) t.Cleanup(srv.Close) req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil) require.NoError(t, err) resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) require.NoError(t, deadlineErr, "a controller must reach the writer this one wraps") } // TestAStreamReachesTheClientBeforeTheHandlerReturns is the same property // measured rather than asserted: over a real connection, with a real client, // bytes flushed inside the handler have to arrive while the handler is still // running. A recorder cannot tell a flush that worked from one that was buffered // until the end — and a broken flush here is what would turn every MCP session // on this endpoint into a hang rather than an error. func TestAStreamReachesTheClientBeforeTheHandlerReturns(t *testing.T) { release := make(chan struct{}) srv := httptest.NewServer(privateCache(http.HandlerFunc( func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte("data: first\n\n")) if err := http.NewResponseController(w).Flush(); err != nil { t.Errorf("flush: %v", err) } <-release }))) t.Cleanup(func() { close(release) srv.Close() }) req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil) require.NoError(t, err) resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) assert.Equal(t, cacheControl, resp.Header.Get("Cache-Control")) assert.Equal(t, cacheVary, resp.Header.Get("Vary")) line, err := bufio.NewReader(resp.Body).ReadString('\n') require.NoError(t, err, "the first event must arrive while the handler is still blocked") assert.Equal(t, "data: first\n", line) }