package mcpsrv_test import ( "context" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vaughan0/go-ini" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/config" "sourcecraft.dev/bigbes/sr-ht-ecore/bearer" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-ecore/grants" "sourcecraft.dev/bigbes/sr-ht-dolt/authn" "sourcecraft.dev/bigbes/sr-ht-dolt/core" "sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv" ) // The transport half: the surface as the daemon serves it — streamable HTTP, // with this package's own credential middleware in the chain — and the // properties that exist only there. What the tools *answer* is tested over the // in-memory transport (mcpsrv_test.go); what is tested here is how a caller's // identity gets from a header into a tool handler, and what a request refused // before reaching one looks like. // // The credentials are real, not stubbed principals: the tokens are sealed with // the same HMAC the instance uses, ResolveBearer decodes and routes them, and // only the two things a test process cannot have — meta.sr.ht and a // tokens.sr.ht daemon — are stood in for. So these tests exercise the // classification of authn/bearer.go rather than a test double of it. // TestMain seeds the process-global crypto state from sr-ht-ecore's fixed test // keyset, so that bearer-token HMAC (auth.BearerToken.Encode / // auth.DecodeBearerToken) works in process. No network, no Postgres. func TestMain(m *testing.M) { ecoretest.InitCrypto() os.Exit(m.Run()) } // instanceHost is what a proxy forwards in Host for testOrigin, and therefore // the one non-loopback name the allowlist admits. const instanceHost = "dolt.example.org" // --- the credentials -------------------------------------------------------- // forgeWorkingToken builds a token shaped exactly as tokens.sr.ht seals one: // the same format and the same HMAC key as a meta PAT, differing only in the // ClientID — which is the whole routing decision in ResolveBearer. func forgeWorkingToken(username string) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), ClientID: bearer.TokensClientID, Username: username, } return bt.Encode() } // forgePAT builds a meta.sr.ht personal access token with the given OAuth grant // string — the other bearer shape this surface accepts. func forgePAT(username, grantString string) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(time.Now().Add(time.Hour)), Grants: grantString, Username: username, } return bt.Encode() } // fakeValidator stands in for sr-ht-ecore's bearer.Validator: it answers per // presented token with what the test configured. It is the one part of the // credential path a test process cannot run for real — verifying a registered // working token means asking a live tokens.sr.ht whether it was revoked. type fakeValidator struct { tokens map[string]*bearer.Token errs map[string]error } var _ authn.InstanceValidator = (*fakeValidator)(nil) func (f *fakeValidator) Inspect(_ context.Context, presented string) (*bearer.Token, error) { if err, ok := f.errs[presented]; ok { return nil, err } if tok, ok := f.tokens[presented]; ok { return tok, nil } return nil, bearer.ErrInvalid } // stubMeta is an in-memory authn.MetaBackend: the profile mirror and the // revocation check, without meta.sr.ht. type stubMeta struct { users map[string]auth.AuthContext } func (s *stubMeta) LookupUser(_ context.Context, username string, out *auth.AuthContext) error { u, ok := s.users[strings.ToLower(strings.TrimPrefix(username, "~"))] if !ok { return fmt.Errorf("%w: no such user %q", authn.ErrInvalidToken, username) } *out = u return nil } func (s *stubMeta) IsRevoked(context.Context, string, [64]byte, string) (bool, error) { return false, nil } func mustGrants(t *testing.T, s string) grants.Grants { t.Helper() g, err := grants.Parse(s) require.NoError(t, err, "grants.Parse(%q)", s) return g } // --- the mount -------------------------------------------------------------- // credentials is the fixture set of tokens one mounted endpoint answers to. type credentials struct { aliceWorking string // the owner, with dolt:read bobWorking string // the grantee, with dolt:read withoutRead string // alice's, minted for another service entirely alicePAT string // a meta PAT scoped to dolt.sr.ht repositories foreignPAT string // a meta PAT scoped to another service's repositories unverifiable string // a working token the daemon could not be asked about garbage string // not a token at all validatorIsNil bool } // mountMCP assembles the endpoint the way cmd/doltsrht will (task // sr-ht-dolt-0qf.6): the surface at /mcp, outside any CSRF group, behind the // config middleware every *.sr.ht daemon installs. // // tokensDaemon=false is an instance with no [tokens.sr.ht] origin: New is handed // a nil validator, which is a configuration and not a degradation. func mountMCP(t *testing.T, tokensDaemon bool) (*httptest.Server, credentials) { t.Helper() creds := credentials{ aliceWorking: forgeWorkingToken("alice"), bobWorking: forgeWorkingToken("bob"), withoutRead: forgeWorkingToken("alice"), alicePAT: forgePAT("alice", "dolt.sr.ht/repos:RO"), foreignPAT: forgePAT("alice", "git.sr.ht/repos:RW"), unverifiable: forgeWorkingToken("alice"), garbage: "not-a-token", } // Two working tokens for one user have to differ as strings, or the map that // answers about them cannot tell them apart. The encoder derives the payload // from the expiry, so nudging it is enough and still forges a valid token. for creds.withoutRead == creds.aliceWorking || creds.unverifiable == creds.aliceWorking || creds.withoutRead == creds.unverifiable { creds.withoutRead = forgeWorkingTokenAt("alice", time.Now().Add(2*time.Hour)) creds.unverifiable = forgeWorkingTokenAt("alice", time.Now().Add(3*time.Hour)) } validator := &fakeValidator{ tokens: map[string]*bearer.Token{ creds.aliceWorking: {Username: "alice", Grants: mustGrants(t, core.GrantRead)}, creds.bobWorking: {Username: "bob", Grants: mustGrants(t, core.GrantRead)}, creds.withoutRead: {Username: "alice", Grants: mustGrants(t, "bench:read")}, }, errs: map[string]error{ // Not "your token is bad": the daemon that could say so did not // answer. authn classifies this as transient and the surface as 503. creds.unverifiable: bearer.ErrUnavailable, }, } restore := authn.SetMetaBackend(&stubMeta{users: map[string]auth.AuthContext{ "alice": *alice(), "bob": *bob(), "carol": *carol(), }}) t.Cleanup(restore) var iv authn.InstanceValidator if tokensDaemon { iv = validator } else { creds.validatorIsNil = true } server, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), iv, testOrigin) require.NoError(t, err) mux := http.NewServeMux() mux.Handle("/mcp", configMiddleware(server)) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) return srv, creds } func forgeWorkingTokenAt(username string, expires time.Time) string { bt := auth.BearerToken{ Version: auth.TokenVersion, Expires: auth.ToTimestamp(expires), ClientID: bearer.TokensClientID, Username: username, } return bt.Encode() } // configMiddleware installs the instance config the daemon's own middleware // installs: the meta-PAT arm decodes its OAuth grants against the service name // (auth.DecodeGrants), and without it that arm cannot run at all. func configMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := config.Context(r.Context(), ini.File{}, "dolt.sr.ht") next.ServeHTTP(w, r.WithContext(ctx)) }) } // --- clients ---------------------------------------------------------------- // bearerTransport presents one token on every request, which is how an MCP // client authenticates here: the SDK's streamable transport takes an // *http.Client and no header list. type bearerTransport struct{ token string } func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) { if b.token != "" { r = r.Clone(r.Context()) r.Header.Set("Authorization", "Bearer "+b.token) } return http.DefaultTransport.RoundTrip(r) } // dial connects a real MCP client to the mounted endpoint over streamable HTTP. func dial(t *testing.T, srv *httptest.Server, token string) *mcp.ClientSession { t.Helper() client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ Endpoint: srv.URL + "/mcp", HTTPClient: &http.Client{Transport: bearerTransport{token: token}}, }, nil) require.NoError(t, err) t.Cleanup(func() { _ = session.Close() }) return session } // post sends one JSON-RPC message the way an MCP client does, and returns the // response body, its status and its headers. The body of a streamable response // is an SSE stream whose data lines are the JSON-RPC messages; these tests read // it as text because what they assert about is a name appearing in it or not. func post(t *testing.T, srv *httptest.Server, token, host, message string) (string, *http.Response) { t.Helper() req, err := http.NewRequest(http.MethodPost, srv.URL+"/mcp", strings.NewReader(message)) require.NoError(t, err) if host != "" { req.Host = host } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("MCP-Protocol-Version", "2025-06-18") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := srv.Client().Do(req) require.NoError(t, err) t.Cleanup(func() { _ = resp.Body.Close() }) raw, err := io.ReadAll(resp.Body) require.NoError(t, err) return string(raw), resp } const handshake = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{` + `"protocolVersion":"2025-06-18","capabilities":{},` + `"clientInfo":{"name":"test-client","version":"test"}}}` const listAlice = `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{` + `"name":"list_databases","arguments":{"owner":"alice"}}}` // --- identity --------------------------------------------------------------- // The token decides what a call sees: one endpoint, three credentials, three // answers. A surface that resolved identity once at startup, or read it off // anything but the request, would answer all three alike. func TestTheTokenDecidesWhatTheCallSees(t *testing.T) { srv, creds := mountMCP(t, true) for _, tc := range []struct { name string token string caller *auth.AuthContext }{ {"no credential at all", "", nil}, {"a grantee's working token", creds.bobWorking, bob()}, {"the owner's working token", creds.aliceWorking, alice()}, {"the owner's meta PAT", creds.alicePAT, alice()}, } { t.Run(tc.name, func(t *testing.T) { res := listDatabases(t, dial(t, srv, tc.token), map[string]any{"owner": "alice"}) assert.ElementsMatch(t, listableNames(coreCaller(tc.caller)), names(res)) }) } } // TestIdentityIsPerCallAndNotPerSession is the measurement docs/DESIGN.mcp.md §5 // asks for, re-made here against the SDK version go.mod pins rather than cited // from the donor. It is the reason the transport runs stateless. // // The SDK connects a session with the context of the HTTP request that created // it, and every tool call that session handles then runs under that context. In // stateful mode the creating request is the *initialize* handshake, so the // caller resolved for the handshake answers every later call: a session opened // without a credential stays anonymous however good the token on the next // request, and — the half that matters — a session opened *with* one answers as // its owner to a caller presenting nothing at all, which makes the session id a // credential. // // So the test does the one thing that tells the two modes apart: it hands the // client a credential the handshake never carried, on a session that is already // open, and requires the answer to be that credential's. The last step is the // mirror image — the credential withdrawn — and it is the one that fails loudly // in stateful mode, where the answer would stay the owner's. func TestIdentityIsPerCallAndNotPerSession(t *testing.T) { srv, creds := mountMCP(t, true) credential := &switchableToken{} client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{ Endpoint: srv.URL + "/mcp", HTTPClient: &http.Client{Transport: credential}, }, nil) require.NoError(t, err) t.Cleanup(func() { _ = session.Close() }) // The handshake was anonymous, and an anonymous caller sees the public // databases of the owner it asked about. anonymousView := listDatabases(t, session, map[string]any{"owner": "alice"}) require.ElementsMatch(t, listableNames(nil), names(anonymousView)) // The same session, one call later, now presenting a stranger's token: the // grantee's, whose answer differs from both of the others. credential.set(creds.bobWorking) granteeView := listDatabases(t, session, map[string]any{"owner": "alice"}) assert.ElementsMatch(t, listableNames(coreCaller(bob())), names(granteeView), "the call was answered as the caller of *this* request, not as the caller who opened the session") // And the owner's, on the same session again. credential.set(creds.aliceWorking) ownerView := listDatabases(t, session, map[string]any{"owner": "alice"}) assert.ElementsMatch(t, listableNames(coreCaller(alice())), names(ownerView)) // And back to nothing, so that the token is doing the work rather than the // session having been upgraded once and for all. credential.set("") again := listDatabases(t, session, map[string]any{"owner": "alice"}) assert.ElementsMatch(t, listableNames(nil), names(again), "a withdrawn credential takes its authority with it") } // switchableToken is an http.Client transport whose credential can be changed // between requests, which is how a test tells "the session's identity" from // "this request's identity": the SDK's streamable client takes an *http.Client // and sends every message through it, so swapping the token mid-session is the // only way to make the two answers differ. type switchableToken struct { mu sync.Mutex token string } func (s *switchableToken) set(token string) { s.mu.Lock() defer s.mu.Unlock() s.token = token } func (s *switchableToken) RoundTrip(r *http.Request) (*http.Response, error) { s.mu.Lock() token := s.token s.mu.Unlock() if token != "" { r = r.Clone(r.Context()) r.Header.Set("Authorization", "Bearer "+token) } return http.DefaultTransport.RoundTrip(r) } // The other half of the stateless transport, and what makes the property above // cheap: every POST carries the whole of what it needs, so the credential on a // tools/call is the credential that answers it even when the handshake happened // on another connection entirely — which, behind a proxy that pools // connections, is the normal case rather than an exotic one. func TestACallNeedsNoPriorHandshakeOnThisConnection(t *testing.T) { srv, creds := mountMCP(t, true) body, resp := post(t, srv, creds.aliceWorking, "", listAlice) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.Contains(t, body, "secrets", "the owner sees their private database") body, resp = post(t, srv, "", "", listAlice) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.NotContains(t, body, "secrets") assert.Contains(t, body, "notes") } // --- refusals --------------------------------------------------------------- // A presented credential that does not resolve is a refusal, never a downgrade // to anonymous, and each class is the status authn/bearer.go's two-class // contract asks for. Getting the 401/503 split wrong is the expensive one: an // unreachable token daemon read as "your token is revoked" tells every agent on // the instance to re-mint credentials that were never broken. func TestARefusedCredentialIsNotADowngradeToAnonymous(t *testing.T) { srv, creds := mountMCP(t, true) for _, tc := range []struct { name string token string want int challenge bool }{ {"a string that is not a token", creds.garbage, http.StatusUnauthorized, true}, {"a working token the daemon could not be asked about", creds.unverifiable, http.StatusServiceUnavailable, false}, {"a meta PAT scoped to another service", creds.foreignPAT, http.StatusForbidden, false}, } { t.Run(tc.name, func(t *testing.T) { body, resp := post(t, srv, tc.token, "", listAlice) require.Equal(t, tc.want, resp.StatusCode, body) assert.NotContains(t, body, "notes", "a refused credential is refused, not served the anonymous view") if tc.challenge { assert.Equal(t, `Bearer realm="dolt.sr.ht"`, resp.Header.Get("WWW-Authenticate"), "a 401 names the scheme and the realm a client re-authenticates against") } else { assert.Empty(t, resp.Header.Get("WWW-Authenticate"), "only a 401 invites the caller to authenticate again") } }) } } // An instance with no [tokens.sr.ht] origin has no daemon to verify a working // token against, so a working token is refused — never guessed at — while meta // PATs and anonymity keep working. That is the configuration of // docs/DESIGN.mcp.md §10, not a degradation. func TestWithoutATokensDaemonWorkingTokensAreRefusedAndTheRestIsNot(t *testing.T) { srv, creds := mountMCP(t, false) require.True(t, creds.validatorIsNil) body, resp := post(t, srv, creds.aliceWorking, "", listAlice) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, body) body, resp = post(t, srv, creds.alicePAT, "", listAlice) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.Contains(t, body, "secrets", "a meta PAT still resolves to its owner") body, resp = post(t, srv, "", "", listAlice) require.Equal(t, http.StatusOK, resp.StatusCode, body) assert.Contains(t, body, "notes", "and anonymity is still a normal caller") } // The grant gate of docs/DESIGN.mcp.md §4.2: a tokens.sr.ht working token // reaches this surface only if it carries dolt:read. It is one check at the // boundary because every tool here is a read — and this is what pins that the // boundary is actually there. func TestTheReadGrantIsRequiredOfAWorkingTokenAndOfNothingElse(t *testing.T) { srv, creds := mountMCP(t, true) for _, tc := range []struct { name string token string want int }{ {"a working token carrying dolt:read is admitted", creds.aliceWorking, http.StatusOK}, {"one minted for another service is refused", creds.withoutRead, http.StatusForbidden}, {"a meta PAT carries no tokens.sr.ht grants and is not gated by them", creds.alicePAT, http.StatusOK}, {"anonymous is not turned into a grant failure", "", http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { body, resp := post(t, srv, tc.token, "", handshake) require.Equal(t, tc.want, resp.StatusCode, body) if tc.want == http.StatusForbidden { assert.Contains(t, body, core.GrantRead, "the refusal names the grant that is missing, so the holder knows what to ask for") assert.Contains(t, resp.Header.Get("Cache-Control"), "no-store", "the gate sits inside privateCache, so its refusal is unstorable too") } }) } } // --- the Host allowlist ----------------------------------------------------- // The deployment shape, pinned by a test because it is invisible otherwise: the // daemon listens on loopback and the proxy forwards the instance's public Host. // The SDK's DNS-rebinding guard rejects exactly that combination, which is why // New disables it and installs this allowlist instead. Without this test the // endpoint would 403 in production and pass every local check, since a local // client sends a loopback Host. func TestTheProxiedHostHeaderIsAccepted(t *testing.T) { srv, _ := mountMCP(t, true) body, resp := post(t, srv, "", instanceHost, handshake) assert.Equal(t, http.StatusOK, resp.StatusCode, body) } // The other half of the same trade: the SDK's guard was disabled and // *replaced*, so the replacement has to refuse the attack the guard was for — a // browser on the daemon's own host reaching the loopback port with an // attacker's name in Host. The refusal is written before the SDK is reached, and // before any credential is parsed. func TestAForeignHostIsRefusedBeforeTheSDK(t *testing.T) { srv, creds := mountMCP(t, true) for _, host := range []string{"evil.example.com", "dolt.example.org.evil.com", "dolt.example.orgx"} { t.Run(host, func(t *testing.T) { body, resp := post(t, srv, creds.aliceWorking, host, listAlice) require.Equal(t, http.StatusForbidden, resp.StatusCode, body) assert.NotContains(t, body, "notes", "no MCP message was ever parsed") assert.Contains(t, resp.Header.Get("Cache-Control"), "no-store", "a refusal by hostname is as unstorable as an answer") }) } } // --- caching ---------------------------------------------------------------- // Every answer of this endpoint depends on the credential and none of them says // so in its URL, so none may be stored by a cache that keys on the URL alone. // // The successful answer is the one that matters most and the one the SDK writes // by itself: its `no-cache, no-transform` permits *storage* — a shared cache may // keep the body and merely revalidate — and carries no Vary at all, while a 200 // here may be a PRIVATE database's contents. It is read off a real response // rather than asserted on a recorder because the SDK sets Cache-Control from // inside the handler, after any middleware could: only a response the transport // has actually written proves which Set landed last. func TestEveryAnswerIsUncacheable(t *testing.T) { srv, creds := mountMCP(t, true) for _, tc := range []struct { name string token string host string body string status int }{ {"a tool call that answers", creds.aliceWorking, "", listAlice, http.StatusOK}, {"the handshake", "", "", handshake, http.StatusOK}, {"a refused Host", "", "evil.example.com", "{}", http.StatusForbidden}, {"a refused credential", creds.garbage, "", listAlice, http.StatusUnauthorized}, } { t.Run(tc.name, func(t *testing.T) { body, resp := post(t, srv, tc.token, tc.host, tc.body) require.Equal(t, tc.status, resp.StatusCode, body) cacheControl := resp.Header.Get("Cache-Control") assert.Contains(t, cacheControl, "private", "a shared cache may not keep it") assert.Contains(t, cacheControl, "no-store", "and no cache may store it") assert.NotContains(t, cacheControl, "no-cache", "no-cache would permit storing what no-store forbids") assert.Equal(t, "Authorization", resp.Header.Get("Vary"), "and any cache that ignores the above at least keys on the credential") }) } }