package mcpsrv_test import ( "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv" ) // What a cache is told about /mcp, stated at the endpoint rather than at the // wrapper (cache_internal_test.go has the wrapper). // // Every answer here depends entirely on the credential the request carried and // says nothing about it in its URL, and some of those answers are the whole // approved corpus. A shared cache that stored one and replayed it to the next // caller would be handing one principal's read to another. const ( wantCacheControl = "private, no-store, no-transform" wantVary = "Cookie, Authorization" ) // mount builds the chain cmd/specsrht builds, minus the resolver middleware: // Gate outside, Handler inside, with a principal planted directly so the test // does not need a tokens.sr.ht plane. func mount(t *testing.T, p authn.Principal) *httptest.Server { t.Helper() r, s := newFixture() h, err := mcpsrv.Handler(mcpsrv.Backend{Docs: r, Index: s}, "test", "https://spec.srht.bigb.es") require.NoError(t, err) withPrincipal := func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { next.ServeHTTP(w, req.WithContext(authn.WithPrincipal(req.Context(), p))) }) } router := chi.NewRouter() router.Handle("/mcp", withPrincipal(mcpsrv.Gate(h))) srv := httptest.NewServer(router) t.Cleanup(srv.Close) return srv } func post(t *testing.T, srv *httptest.Server, host, accept, body string) *http.Response { t.Helper() req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, srv.URL+"/mcp", strings.NewReader(body)) require.NoError(t, err) req.Host = host req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", accept) resp, err := srv.Client().Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) return resp } const initialize = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + `"protocolVersion":"2025-06-18","capabilities":{},` + `"clientInfo":{"name":"test-client","version":"test"}}}` // The answer an agent actually gets, on the transport it actually uses: the SDK // replies to a POST with an event stream, which is the response that never calls // WriteHeader and commits through Flush instead. This is the case a wrapper // hooking WriteHeader alone would miss while passing every other test here. func TestTheHeadersLandOnAStreamedAnswer(t *testing.T) { srv := mount(t, authn.Principal{Kind: authn.KindOwner}) resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize) require.Equal(t, http.StatusOK, resp.StatusCode) require.Equal(t, "text/event-stream", strings.Split(resp.Header.Get("Content-Type"), ";")[0], "this test is worth nothing unless the answer really was a stream") assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control")) assert.Equal(t, wantVary, resp.Header.Get("Vary")) body, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Contains(t, string(body), mcpsrv.ServerName, "and it really was this server answering") } // The SDK writes `no-cache, no-transform` on its own responses. no-cache still // permits a shared cache to store the body and merely revalidate, which is the // one thing no-store forbids — so the wrapper has to win, and it can only do // that by writing at commit time rather than on the way in. func TestTheSDKsCacheControlDoesNotSurvive(t *testing.T) { srv := mount(t, authn.Principal{Kind: authn.KindOwner}) resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize) require.Equal(t, []string{wantCacheControl}, resp.Header.Values("Cache-Control"), "exactly one directive set, and it is ours") assert.NotContains(t, resp.Header.Get("Cache-Control"), "no-cache") } // A refusal is an answer too. The Host allowlist writes this one before the SDK // is reached at all, and it is inside the wrapper for that reason. func TestTheHeadersLandOnAHostRefusal(t *testing.T) { srv := mount(t, authn.Principal{Kind: authn.KindOwner}) resp := post(t, srv, "evil.example.com", "application/json, text/event-stream", initialize) require.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control")) assert.Equal(t, wantVary, resp.Header.Get("Vary")) } // Gate's 401 is written outside the wrapper — Gate runs inside the resolver // middleware and Handler runs inside Gate — so Gate sets the two headers itself. // A cache free to keep this 401 would refuse a credential this service never // saw. func TestTheHeadersLandOnAGateRefusal(t *testing.T) { srv := mount(t, authn.Anonymous()) resp := post(t, srv, "spec.srht.bigb.es", "application/json, text/event-stream", initialize) require.Equal(t, http.StatusUnauthorized, resp.StatusCode) assert.Equal(t, wantCacheControl, resp.Header.Get("Cache-Control")) assert.Equal(t, wantVary, resp.Header.Get("Vary")) } // Vary names the cookie because on this service the cookie genuinely decides the // answer, which is the one thing that separates this endpoint from dolt.sr.ht's. // // dolt varies on Authorization alone and is right to: its /mcp is bearer-only, // so naming Cookie there would promise a cache a dependency the surface never // reads. Here authn.Resolver.Resolve falls through to the unified-login cookie // whenever no bearer token is present, and an owner cookie resolves to KindOwner // — which is exactly what Gate admits. The two requests below differ in nothing // but the principal the cookie plane would have produced, and they get different // statuses; that difference is what Vary: Cookie is for. func TestTheCookiePlaneReallyDoesDecideTheAnswer(t *testing.T) { asOwner := post(t, mount(t, authn.Principal{Kind: authn.KindOwner}), "spec.srht.bigb.es", "application/json, text/event-stream", initialize) asAnonymous := post(t, mount(t, authn.Anonymous()), "spec.srht.bigb.es", "application/json, text/event-stream", initialize) require.Equal(t, http.StatusOK, asOwner.StatusCode) require.Equal(t, http.StatusUnauthorized, asAnonymous.StatusCode) require.NotEqual(t, asOwner.StatusCode, asAnonymous.StatusCode, "if these ever agree, Vary: Cookie has stopped being a statement about this endpoint") }