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