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")
}