~bigbes/sr-ht-dolt

dcdc9790d7fa085b5238eb39f196ccc8a2c0d5da — Eugene Blikh 5 days ago fe616c8
mcpsrv: serve a stateless read-only MCP surface
M go.mod => go.mod +7 -2
@@ 9,6 9,7 @@ require (
	github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee
	github.com/go-chi/chi/v5 v5.3.1
	github.com/lib/pq v1.10.9
	github.com/modelcontextprotocol/go-sdk v1.6.1
	github.com/sirupsen/logrus v1.9.4
	github.com/stretchr/testify v1.11.1
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec


@@ 103,9 104,10 @@ require (
	github.com/go-redis/redis/v8 v8.11.5 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/gofrs/flock v0.8.1 // indirect
	github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
	github.com/golang/snappy v0.0.4 // indirect
	github.com/google/btree v1.1.2 // indirect
	github.com/google/jsonschema-go v0.4.3 // indirect
	github.com/google/s2a-go v0.1.9 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect


@@ 137,10 139,13 @@ require (
	github.com/prometheus/client_model v0.6.2 // indirect
	github.com/prometheus/common v0.66.1 // indirect
	github.com/prometheus/procfs v0.16.1 // indirect
	github.com/segmentio/asm v1.1.3 // indirect
	github.com/segmentio/encoding v0.5.4 // indirect
	github.com/sony/gobreaker v0.5.0 // indirect
	github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
	github.com/vektah/gqlparser/v2 v2.5.8 // indirect
	github.com/xtaci/smux v1.5.56 // indirect
	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
	github.com/zeebo/xxh3 v1.0.2 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect


@@ 156,7 161,7 @@ require (
	go.yaml.in/yaml/v2 v2.4.2 // indirect
	golang.org/x/crypto v0.52.0 // indirect
	golang.org/x/net v0.54.0 // indirect
	golang.org/x/oauth2 v0.34.0 // indirect
	golang.org/x/oauth2 v0.35.0 // indirect
	golang.org/x/sync v0.20.0 // indirect
	golang.org/x/sys v0.45.0 // indirect
	golang.org/x/text v0.37.0 // indirect

M go.sum => go.sum +14 -0
@@ 254,6 254,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=


@@ 282,6 284,8 @@ github.com/google/go-github/v57 v57.0.0/go.mod h1:s0omdnye0hvK/ecLvpsGfJMiRt85Pi
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=


@@ 352,6 356,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=


@@ 412,6 418,10 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=


@@ 462,6 472,8 @@ github.com/xitongsys/parquet-go-source v0.0.0-20211010230925-397910c5e371 h1:RfG
github.com/xitongsys/parquet-go-source v0.0.0-20211010230925-397910c5e371/go.mod h1:qLb2Itmdcp7KPa5KZKvhE9U1q5bYSOmgeOckF/H2rQA=
github.com/xtaci/smux v1.5.56 h1:Eyv/dUULmkGZZNucLUisnkzJ/4UQ5YZTschhugFBM0U=
github.com/xtaci/smux v1.5.56/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=


@@ 540,6 552,8 @@ golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

A mcpsrv/errors.go => mcpsrv/errors.go +81 -0
@@ 0,0 1,81 @@
package mcpsrv

import (
	"errors"
	"log/slog"

	"github.com/modelcontextprotocol/go-sdk/jsonrpc"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// The one place this surface decides what a failure *is*, which on MCP is a
// question with two answers rather than a status code.
//
//   - A tool result error (CallToolResult.IsError) is an answer to the agent:
//     the call was understood, executed, and the thing asked for is not there.
//     The SDK produces one out of any ordinary error a handler returns, and the
//     agent reads it as text and decides what to ask next.
//   - A protocol error (a *jsonrpc.Error returned by a handler, which the SDK
//     passes through as the JSON-RPC error of the response) says the call did
//     not produce an answer at all. The client's CallTool returns an error
//     rather than a result, which is exactly right for a store that is down: an
//     agent must not read "Postgres could not answer" as "that database does not
//     exist" and go rewrite its plan around a database that is perfectly real.
//
// The table is missingOrDenied plus a default, and the default is the protocol
// arm on purpose: an unmapped error is a bug in a layer below, and rendering it
// as a tool result would report that bug to the agent as a fact about the data.
//
// # The visibility rule
//
// docs/DESIGN.mcp.md §4.3 runs through both arms and is the reason this file
// exists at all. A database the caller may not read is **not found** —
// indistinguishable from one that does not exist, which is what
// core.NotFoundForPrivate decides and what the browse handlers already answer
// (web/router.go). There is no "forbidden" on this surface and no failure of its
// own shape for a masked database: a tool that reported one would rebuild
// exactly the distinction the 404 exists to erase, and an agent probing names
// would read the difference straight out of the two messages.
//
// The sentence the agent sees is written here, from the arguments the call
// carried, and never from the error's own text: db/ wraps its misses with the
// owner and name it looked up, and echoing that would eventually publish a
// difference between "no such database" and "not yours".

// internalMessage is the message of every protocol error this surface returns.
// The detail is logged, never sent: it names tables, queries and on-disk paths,
// and this endpoint is reachable by anyone holding any valid token — or by
// nobody at all.
const internalMessage = "internal server error"

// missingOrDenied is the answer to a read that resolved to nothing — because the
// database does not exist, or because the visibility rule says this caller may
// not learn that it does.
//
// missing is the sentence the agent sees, and every caller builds it from the
// arguments of the call being answered ("no database ~alice/notes"). That is
// deliberate on both counts: the sentence discloses nothing the caller did not
// already know, and it is one string for both cases by construction rather than
// by two error paths being kept in agreement by hand.
//
// where is the operator's half — the tool name — and appears only in the log
// line of the protocol arm.
func missingOrDenied(err error, where, missing string) error {
	if errors.Is(err, db.ErrNotFound) {
		// A tool result error: the SDK packs an ordinary error returned by a
		// handler into a CallToolResult with IsError set.
		return errors.New(missing)
	}
	return internalError(err, where)
}

// internalError logs the cause and returns the protocol error the client sees.
//
// The error goes through scribe.Err, which expands a culpa chain into err.msg,
// err.code and err.hint instead of flattening it with %v.
func internalError(err error, where string) error {
	slog.Error("a tool call failed", "tool", where, scribe.Err(err))
	return &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: internalMessage}
}

A mcpsrv/errors_internal_test.go => mcpsrv/errors_internal_test.go +49 -0
@@ 0,0 1,49 @@
package mcpsrv

import (
	"errors"
	"fmt"
	"testing"

	"github.com/modelcontextprotocol/go-sdk/jsonrpc"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// The mapping of errors.go, tested from inside the package because it is the
// contract every address-taking tool is written against and none of them exists
// yet: list_databases addresses no single database, so the not-found arm has no
// caller until the tools of ch. 9.1 land. Pinning it here is what keeps the two
// arms from being decided again, differently, in the first handler that needs
// them.
func TestMissingOrDeniedSeparatesTheTwoArms(t *testing.T) {
	const missing = "no database ~alice/notes"

	t.Run("a miss is an answer the agent reads", func(t *testing.T) {
		// db/ wraps its misses with what it looked up; the sentence the agent
		// gets is written from the call's own arguments and never from that.
		err := missingOrDenied(fmt.Errorf("get repo alice/notes: %w", db.ErrNotFound), "get_database", missing)

		require.Error(t, err)
		var protocol *jsonrpc.Error
		assert.False(t, errors.As(err, &protocol), "a miss is a tool result, not a protocol failure")
		assert.Equal(t, missing, err.Error())
	})

	t.Run("anything else is a protocol failure", func(t *testing.T) {
		// An agent must not read "the metadata store could not answer" as "that
		// database does not exist" and go rewrite its plan around a database that
		// is perfectly real.
		err := missingOrDenied(errors.New("dial tcp 127.0.0.1:5432: connection refused"), "get_database", missing)

		var protocol *jsonrpc.Error
		require.ErrorAs(t, err, &protocol)
		assert.EqualValues(t, jsonrpc.CodeInternalError, protocol.Code)
		assert.Equal(t, internalMessage, protocol.Message)
		assert.NotContains(t, protocol.Message, "5432", "the cause is logged, never sent")
		assert.NotContains(t, protocol.Message, missing,
			"and a store outage does not borrow the sentence a miss gets")
	})
}

A mcpsrv/http_test.go => mcpsrv/http_test.go +582 -0
@@ 0,0 1,582 @@
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")
		})
	}
}

A mcpsrv/mcpsrv.go => mcpsrv/mcpsrv.go +513 -0
@@ 0,0 1,513 @@
// Package mcpsrv is dolt.sr.ht's Model Context Protocol surface: the read-only
// tools of docs/DESIGN.mcp.md ch. 9 an agent calls to read a hosted Dolt
// database — and the beads tracker inside it — served over streamable HTTP at
// /mcp on the daemon's web listener.
//
// The donor is cov.sr.ht's mcpsrv/, the newest of the four this instance already
// runs, and the family convention is to copy the pattern rather than import it:
// the SDK server built in New, the tools registered through the generic
// mcp.AddTool so every schema is derived from a Go struct, the streamable
// handler mounted on the service's own router, and the Host allowlist that
// replaces the SDK's DNS-rebinding guard.
//
// # A surface, not a second service
//
// Nothing here re-derives what the rest of the service already decides.
// Visibility is core.Allowed over the grant the metadata store resolves, the
// beads fingerprint is beads.Applies, the default branch is
// browse.DefaultBranch. Two surfaces that each grew their own copy is how they
// start answering one question differently, quietly, months later — so the
// browse handlers' dance (web/router.go's loadRepoForBrowse) is reproduced here
// call for call rather than re-thought.
//
// # No engine, no writes
//
// The whole pure-Go build stands on one fact: this service never starts the SQL
// engine, because a bare NBS store has no working set to start it against
// (browse/open.go). This surface changes nothing about that. There is no
// query(sql) tool and there will not be one, there is no mutation of any kind,
// and neither is a rule anybody has to remember: ports.go names no seam that
// could reach either, so a handler here cannot write what it has no way to
// call.
//
// # Identity
//
// This is the only bearer surface the service has, so the credential middleware
// lives here rather than in the daemon (which is the one departure from the
// donor, whose siblings share a resolver in front of three surfaces). A request
// carrying no Authorization header is anonymous, and anonymous is a normal
// caller: it reads what anonymity may read. A bearer token that fails to resolve
// is a refusal and never a downgrade to anonymous, with authn/bearer.go's two
// error classes rendered as 401, 403 and 503 (resolveCaller).
//
// How that caller reaches a tool handler is decided by the SDK's transport and
// is why this one runs stateless; the constant below carries the argument and
// the measurement made against the version go.mod pins.
package mcpsrv

import (
	"context"
	"errors"
	"log/slog"
	"net"
	"net/http"
	"runtime/debug"
	"strings"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

const (
	// ServerName is the implementation name reported in the MCP handshake, and
	// the realm of every 401 this surface writes.
	//
	// It is the service's config-section name spelled as a literal rather than
	// read from a config section: a client listing several SourceHut MCP
	// endpoints tells them apart by this string, so it is part of this surface's
	// contract and does not follow a key that may move for reasons of its own.
	ServerName = "dolt.sr.ht"

	// stateless puts the streamable transport in stateless mode, and that is an
	// authentication decision rather than a performance one
	// (docs/DESIGN.mcp.md §5).
	//
	// 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
	// one context. In stateful mode the creating request is the *initialize*
	// handshake, so the caller resolved for the handshake answers every later
	// tools/call on that session — and the consequence is worse than a stale
	// authority: the session id becomes a bearer credential in its own right,
	// issued by a service that issues none, and the credential presented on the
	// call is not consulted at all. Anyone holding that id — a proxy log, a crash
	// report, a shared client's state file — then reads as the caller who opened
	// the session, and a token revoked mid-session keeps working until the client
	// reconnects.
	//
	// Stateless mode connects a temporary session per POST, so a tool handler's
	// context descends from the request that carried the call, credential
	// middleware included, and identity is per call — which is what a bearer
	// surface means.
	//
	// The measurement behind this is the donor's, and a paragraph is not a
	// measurement: TestIdentityIsPerCallAndNotPerSession re-makes it here,
	// against the SDK version go.mod pins, by swapping the credential
	// mid-session. Flipping this constant to false and running it gives, for one
	// session whose handshake carried no credential and one owner with six
	// databases of which four are public:
	//
	//	stateless: handshake anonymous, call with the OWNER's token -> 6
	//	           handshake anonymous, call with NO credential     -> 4
	//	stateful:  handshake anonymous, call with the OWNER's token -> 4
	//	           i.e. the token on the call is not consulted at all
	//
	// and the mirror image — a handshake that *did* carry a token — is the same
	// fact the other way round: the session answers as its opener to a caller
	// presenting nothing.
	//
	// What it costs is the server->client half of the protocol: no standalone SSE
	// stream, so no server-initiated requests, and a GET is answered 405. Every
	// tool of ch. 9 is a read that answers in one response — none samples,
	// elicits or reports progress — so there is nothing to give up.
	stateless = true

	// privateVary is what every answer of this endpoint actually depends on, and
	// it names one header rather than the donor's two: /mcp is bearer-only
	// (docs/DESIGN.mcp.md §4.1). The unified-login cookie is the web UI's plane
	// and is not read here, so promising a cache that answers vary by it would be
	// a promise about a header this surface never looks at.
	privateVary = "Authorization"

	// refusalCacheControl is the private-cache pair every refusal carries.
	refusalCacheControl = "private, no-store"

	// answerCacheControl is that pair plus the one directive the SDK sets for its
	// own reasons. The transport writes `no-cache, no-transform` on every
	// response it produces: no-transform protects the SSE framing from an
	// intermediary that would recompress or rechunk it, and is kept; no-cache is
	// replaced, because it permits a cache to *store* the body and merely
	// revalidate — which is exactly what no-store forbids and what an answer
	// carrying a PRIVATE database's contents may not allow.
	answerCacheControl = refusalCacheControl + ", no-transform"
)

// bearerChallenge is the RFC 7235 challenge every 401 of this surface carries.
// bearer.Challenge assembles it, so the quoting RFC 9110 §11.6.1 requires of a
// realm is done once for the instance rather than by hand in six services.
var bearerChallenge = bearer.Challenge(ServerName)

// A Server is the MCP surface: the SDK server with the tools registered,
// wrapped in the HTTP chain the daemon mounts at /mcp.
//
// It holds no request state and is safe for concurrent use — the caller's
// identity travels in the request context, never on the server — which is what
// lets one instance serve every session.
type Server struct {
	repos  Repos
	opener BrowseOpener

	// validator verifies a tokens.sr.ht working token. It may be nil, and a nil
	// one is a configuration rather than a degradation: an instance whose
	// config.ini carries no [tokens.sr.ht] origin has no such daemon, so meta
	// PATs and anonymity keep working and a working token is refused rather than
	// guessed at (authn.ResolveBearer documents the contract, including that a
	// *typed* nil is not it).
	validator authn.InstanceValidator

	// mcp is the protocol server the tools are registered on, kept so that a test
	// can connect an in-memory transport to it without going through HTTP
	// (Connect).
	mcp *mcp.Server

	// http is the whole handler chain. It is built once, in New, because
	// mcp.NewStreamableHTTPHandler owns transport state and two of them would be
	// two servers.
	http http.Handler
}

// A Server is an http.Handler: the daemon mounts it with r.Handle("/mcp", s) —
// Handle and not Mount, because the streamable handler serves that exact path
// (docs/DESIGN.mcp.md §3).
var _ http.Handler = (*Server)(nil)

// New builds the MCP surface over its seams.
//
// origin is [dolt.sr.ht]origin — the instance's public base URL — and it is
// required: it is the Host allowlist this endpoint is guarded by (allowHosts).
// An origin with no host is a wiring error and is refused here rather than
// warned about and then served unguarded.
//
// validator may be nil (see Server.validator). repos and opener may not: a
// surface that answered every call "internal error" because a seam was never
// wired would be a daemon that starts and does not work, and the daemon that
// wired it is not an operator to be warned, it is a bug.
func New(repos Repos, opener BrowseOpener, validator authn.InstanceValidator, origin string) (*Server, error) {
	if repos == nil {
		return nil, culpa.New("mcpsrv: nil Repos")
	}
	if opener == nil {
		return nil, culpa.New("mcpsrv: nil BrowseOpener")
	}
	// instconf.OriginHost is the instance's one reading of "what host does this
	// origin name": the name without the port, "" for anything that does not
	// parse — never a guessed "localhost", which would make every malformed
	// origin agree with a local client on the one code path where that decides
	// an allowlist.
	host := instconf.OriginHost(origin)
	if host == "" {
		return nil, culpa.Errorf("mcpsrv: origin %q has no host to guard /mcp with", origin)
	}

	s := &Server{repos: repos, opener: opener, validator: validator}
	s.mcp = mcp.NewServer(&mcp.Implementation{Name: ServerName, Version: serverVersion()}, nil)
	s.register()

	// The SDK's DNS-rebinding guard is disabled deliberately, and disabling a
	// security default usually is not defensible, so here is why this one is.
	//
	// The guard refuses any request that arrives on a loopback address carrying a
	// non-loopback Host header. That is precisely this deployment: the daemon
	// binds localhost and Traefik/nginx forwards with the instance's public Host
	// (docs/DESIGN.mcp.md §3, §6). Every genuine request would be a 403 — and
	// only in production, because a local client sends a loopback Host and
	// passes.
	//
	// It is not that the guard has nothing to catch: a browser running on the
	// daemon's own host could reach the loopback port directly with an attacker's
	// Host. The guard simply cannot tell that request from the proxy's — both
	// arrive from loopback with a non-loopback Host — and the SDK offers no
	// allowlist to separate them. So it is disabled and *replaced*, in the same
	// constructor, by a stricter check.
	handler := mcp.NewStreamableHTTPHandler(
		func(*http.Request) *mcp.Server { return s.mcp },
		&mcp.StreamableHTTPOptions{
			DisableLocalhostProtection: true,
			Stateless:                  stateless,
		},
	)

	// The order of the wrappers is the order of the questions, outermost first,
	// and each one is where it is for a reason:
	//
	//	privateCache        every answer AND every refusal is unstorable, so it
	//	                    wraps the lot — including the Host 403, which is
	//	                    written before the SDK is reached at all.
	//	allowHosts          a request naming somebody else's host is refused
	//	                    before its credential is even parsed: there is no
	//	                    reason to spend an HMAC, or a meta lookup, on a
	//	                    request this endpoint will not answer.
	//	resolveCaller       who is calling, once per request, put in the context
	//	                    the SDK will hand every tool handler.
	//	requireReadGrant    what that credential covers, asked after it resolved
	//	                    and before a session is negotiated or a tool named.
	s.http = privateCache(allowHosts(s.resolveCaller(requireReadGrant(handler)), host))
	return s, nil
}

// ServeHTTP serves the streamable MCP transport behind the chain New built.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }

// Connect attaches the protocol server to a transport directly, for a caller
// that speaks MCP without HTTP — the in-process client the tools are tested
// with (docs/DESIGN.mcp.md §12).
//
// It exists so that a test drives the very server the daemon serves: the tools,
// their schemas and their handlers are registered once, in New, and a second
// registration path for tests would be a second surface to keep in agreement.
// The caller comes from ctx exactly as it does over HTTP — the SDK connects the
// session with the context it is given and every handler descends from it (see
// stateless).
func (s *Server) Connect(ctx context.Context, t mcp.Transport) (*mcp.ServerSession, error) {
	return s.mcp.Connect(ctx, t, nil)
}

type contextKey struct{ name string }

// bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate.
//
// The identity itself goes where the rest of the service looks for it
// (authn.WithCaller), so a tool handler reads a caller the same way a web
// handler does. What has no house-wide home is the tokens.sr.ht grant set: it
// exists only on a working token, only this surface asks about it, and putting
// it on the shared AuthContext would subject it to core-go's entirely different
// OAuth gate (authn/bearer.go says why). So it stays here, private to this
// package, read by requireReadGrant alone.
var bearerCallerKey = &contextKey{"mcpsrv.bearerCaller"}

func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context {
	return context.WithValue(ctx, bearerCallerKey, bc)
}

func bearerCallerFrom(ctx context.Context) *authn.BearerCaller {
	bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller)
	return bc
}

// callerOf is how a tool handler learns who is asking: the core.Caller the
// access matrix is written against, nil for an anonymous caller.
//
// It reads the context and nothing else. There is no field on Server holding a
// caller and there must not be: one Server answers every session, and identity
// that lived on it would be the last caller's rather than this call's.
func callerOf(ctx context.Context) *core.Caller {
	return authn.AsCoreCaller(authn.CallerFromContext(ctx))
}

// resolveCaller is this surface's credential middleware: the bearer plane of
// docs/DESIGN.mcp.md §4.1, and the only one it accepts.
//
// It lives here rather than in the daemon because /mcp is the only bearer
// surface this service has — the web UI resolves a cookie, the remotesapi
// resolves Basic and a dolt JWT, and each does it in its own place. A middleware
// mounted globally would be a fourth plane in front of three surfaces that do
// not want it.
//
// No Authorization header is anonymous, and anonymous is a normal caller: it
// falls through with nothing in the context, which CallerFromContext already
// reads as "not signed in". A header naming another scheme is likewise no
// bearer token (authn.ParseBearer), not a refusal — Basic belongs to dolt's
// remote flow and is not this plane's to reject.
//
// A presented token that does not resolve is a refusal and never a downgrade to
// anonymous, which is the rule the whole design rests on: an agent whose token
// expired must be told so, not quietly served the public half of the instance
// and left to conclude its databases were deleted. The classes are
// authn/bearer.go's, unchanged:
//
//	ErrMissingGrant   403 — the credential is good, the caller is known, and
//	                  what is missing is a permission. A 401 here would send
//	                  them round a loop that cannot end: a token does not grow
//	                  a grant by being presented twice.
//	ErrInvalidToken   401 + the challenge — forged, expired, revoked, or a
//	                  working token on an instance that configures no
//	                  tokens.sr.ht to verify it against.
//	anything else     503 — the credential could not be *checked*. "I could not
//	                  decide" is not "your token is bad", and answering 401 to a
//	                  restart of meta.sr.ht would tell every agent on the
//	                  instance to re-mint credentials that were never broken.
//
// The messages are written here, from what the caller already knows, and never
// from the error's own text: authn's errors name usernames, hosts and token
// ids. The cause is logged instead, on the arm where an operator needs it.
func (s *Server) resolveCaller(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented := authn.ParseBearer(r)
		if presented == "" {
			next.ServeHTTP(w, r)
			return
		}

		bc, err := authn.ResolveBearer(r.Context(), s.validator, presented)
		if err != nil {
			switch {
			case errors.Is(err, authn.ErrMissingGrant):
				// Asked before ErrInvalidToken: ResolveBearer joins the two, so
				// that a caller who only knows the permanent/transient split
				// still answers 401, while one that can say 403 asks for this
				// sentinel first. This surface can.
				http.Error(w, "this credential does not grant read access to "+ServerName+" databases",
					http.StatusForbidden)
			case errors.Is(err, authn.ErrInvalidToken):
				w.Header().Set("WWW-Authenticate", bearerChallenge)
				http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized)
			default:
				slog.Error("a bearer credential could not be checked", scribe.Err(err))
				http.Error(w, "the credential could not be verified, try again",
					http.StatusServiceUnavailable)
			}
			return
		}

		ctx := authn.WithCaller(r.Context(), bc.AuthContext)
		ctx = withBearerCaller(ctx, bc)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// requireReadGrant is the grant gate of docs/DESIGN.mcp.md §4.2: a tokens.sr.ht
// working token must carry core.GrantRead to reach any of this surface.
//
// It is one check at the boundary rather than one per tool because every tool
// registered here is a read, so the surface has exactly one action, and checking
// it per tool would be one chance per tool to forget the next one. A write tool
// added here must NOT rely on this: it would be admitted by a read grant, which
// is not what a read grant says. Give it its own check against a
// core.GrantWrite that does not exist yet, in its handler, where the action it
// performs is finally known.
//
// A meta PAT and an anonymous caller pass, and neither is a hole.
// BearerCaller.Authorize already encodes that: a PAT carries no tokens.sr.ht
// grants at all — the vocabularies do not overlap — and its scoping was applied
// at resolve time by the same gate the clone path applies; anonymity carries no
// credential to scope. What either may then see is core.Allowed's answer and
// not this gate's.
func requireReadGrant(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if bc := bearerCallerFrom(r.Context()); bc != nil {
			if err := bc.Authorize(core.GrantRead); err != nil {
				// 403 and not 401, for resolveCaller's reason: the credential is
				// good and the caller is known.
				//
				// No cache headers here: this runs inside privateCache, which
				// marks everything this endpoint writes.
				http.Error(w, "this token does not carry the "+core.GrantRead+" grant",
					http.StatusForbidden)
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

// privateCache marks every answer this endpoint writes as one no cache may keep,
// and states what it depends on.
//
// Setting the headers before the handler runs is not enough: the SDK's
// streamable transport sets Cache-Control itself, with Set, from inside the
// handler — so a value written on the way in is overwritten on the way out, and
// the response leaves with `no-cache, no-transform` and no Vary. They are
// therefore written at the last moment they still can be, when the status line
// is committed and every Set the handler was going to make has been made.
//
// It wraps rather than replaces what the SDK asked for: answerCacheControl keeps
// its no-transform and drops only the directive that contradicts no-store.
func privateCache(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(&cacheWriter{ResponseWriter: w}, r)
	})
}

// cacheWriter is the http.ResponseWriter privateCache hands down: it sets the
// two headers when the response is committed, whether that is an explicit
// WriteHeader or the implicit one of the first Write.
//
// Unwrap is what keeps the streamable transport working through it:
// http.NewResponseController follows it to reach the real writer's Flush, and an
// SSE stream that could not be flushed would be a response no client sees until
// the handler returns.
type cacheWriter struct {
	http.ResponseWriter
	committed bool
}

func (w *cacheWriter) WriteHeader(status int) {
	w.commit()
	w.ResponseWriter.WriteHeader(status)
}

func (w *cacheWriter) Write(b []byte) (int, error) {
	w.commit()
	return w.ResponseWriter.Write(b)
}

func (w *cacheWriter) commit() {
	if w.committed {
		return
	}
	w.committed = true
	w.Header().Set("Cache-Control", answerCacheControl)
	w.Header().Set("Vary", privateVary)
}

func (w *cacheWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

// allowHosts is this endpoint's DNS-rebinding protection in the form the
// deployment needs: Host must be the instance's own hostname, or a loopback name
// for local development (an MCP client on the same machine as a dev daemon).
//
// It is a wrapper rather than a check inside ServeHTTP so that the refusal
// happens before the SDK sees a byte of the body.
func allowHosts(next http.Handler, want string) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !hostAllowed(r.Host, want) {
			http.Error(w, "unexpected Host header", http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// hostAllowed compares a request's Host against the expected hostname, ignoring
// any port and IPv6 brackets.
func hostAllowed(reqHost, want string) bool {
	h := reqHost
	if stripped, _, err := net.SplitHostPort(h); err == nil {
		h = stripped
	}
	h = strings.TrimSuffix(strings.TrimPrefix(h, "["), "]")
	switch {
	case strings.EqualFold(h, want):
		return true
	case h == "localhost", h == "127.0.0.1", h == "::1":
		return true
	default:
		return false
	}
}

// serverVersion is the implementation version reported in the MCP handshake.
//
// It is read from the build info rather than declared as a constant, because a
// constant would be a number somebody has to remember to bump and would
// therefore be wrong: the daemon has no version string of its own, and the one
// thing that does change per build is the module version the toolchain stamps
// in.
//
// A binary built with no module information — a `go test` binary is the usual
// one — reports "(devel)", the spelling the Go toolchain itself uses for an
// unstamped build. It is a display string in a handshake and nothing branches
// on it.
func serverVersion() string {
	info, ok := debug.ReadBuildInfo()
	if !ok || info.Main.Version == "" {
		return "(devel)"
	}
	return info.Main.Version
}

A mcpsrv/mcpsrv_test.go => mcpsrv/mcpsrv_test.go +772 -0
@@ 0,0 1,772 @@
package mcpsrv_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
	"sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv"
)

// The tool half of the suite: what the surface answers, driven through a real
// in-process MCP client over fakes — no Postgres, no store on disk. The
// transport half (who the answer is computed for, and what a request refused
// before a handler looks like) is http_test.go.

// testOrigin is what [dolt.sr.ht]origin says on the instance these tests
// pretend to be, and therefore the Host the allowlist admits.
const testOrigin = "https://dolt.example.org"

// --- the fixture ------------------------------------------------------------
//
// One owner with one database of every shape that matters, because the two
// things this surface has to get right are both properties of a *set*: which
// databases a caller may list, and what a listing says about one it cannot fully
// read.

const (
	aliceID = 1 // the owner of every fixture database
	bobID   = 2 // a grantee: an ACL entry on the private one and nothing else
	carolID = 3 // a stranger: an account with no relationship to any of them
)

// storePath is the on-disk store dir of a database, as db/ stores it on the
// row. It never leaves the server (nothing in a tool result carries it), which
// is why the fakes key on it rather than on a name.
func storePath(owner, name string) string { return "/stores/~" + owner + "/" + name }

type fixture struct {
	name       string
	visibility core.Visibility
	acl        map[int]core.AccessMode

	// session is what opening this database's store yields. A nil one is a store
	// that will not open at all, which is the entry a listing has to survive.
	session *fakeSession
}

func fixtures() []fixture {
	return []fixture{
		{
			name:       "notes",
			visibility: core.VisibilityPublic,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "aaaa"}},
				commits:  []browse.CommitInfo{{Hash: "aaaa", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "notes", Columns: []browse.ColumnInfo{{Name: "id"}}}},
			},
		},
		{
			// The one a beads-aware tool will answer about: its tables carry the
			// fingerprint beads.Applies looks for.
			name:       "tracker",
			visibility: core.VisibilityPublic,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "bbbb"}},
				commits:  []browse.CommitInfo{{Hash: "bbbb", Date: headTime}},
				tables:   beadsTables(),
			},
		},
		{
			// A store created by a repository that has never been pushed to.
			name:       "empty",
			visibility: core.VisibilityPublic,
			session:    &fakeSession{},
		},
		{
			// A store this daemon cannot open. It is in the fixture rather than in
			// one test of its own because the property worth pinning is that it
			// does not take the rest of the listing with it.
			name:       "broken",
			visibility: core.VisibilityPublic,
			session:    nil,
		},
		{
			name:       "drafts",
			visibility: core.VisibilityUnlisted,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "dddd"}},
				commits:  []browse.CommitInfo{{Hash: "dddd", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "drafts"}},
			},
		},
		{
			name:       "secrets",
			visibility: core.VisibilityPrivate,
			acl:        map[int]core.AccessMode{bobID: core.AccessRO},
			session: &fakeSession{
				branches: []browse.Branch{{Name: "release", Head: "eeee"}},
				commits:  []browse.CommitInfo{{Hash: "eeee", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "secrets"}},
			},
		},
	}
}

// headTime is the commit time every fixture head carries, fixed so that a test
// can assert the value rather than merely that something was rendered.
var headTime = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)

// beadsTables is the minimum beads.Applies accepts: issues + dependencies, with
// issues carrying id and status. The fingerprint is beads/'s and is not restated
// here — this is a fixture that satisfies it, not a second copy of it.
func beadsTables() []browse.TableInfo {
	return []browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}, {Name: "title"}}},
		{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "from_id"}, {Name: "to_id"}}},
	}
}

// listable is the listing rule of docs/DESIGN.mcp.md §4.3 spelled out
// independently of the implementation under test: PUBLIC to everyone including
// anonymity, plus whatever the viewer owns or holds an ACL entry on. UNLISTED
// and PRIVATE are absent for everybody else.
//
// It is written here rather than derived from the fake so that the expectation
// and the fake cannot drift into agreement with each other and away from the
// rule.
func listable(f fixture, viewer *core.Caller) bool {
	if f.visibility == core.VisibilityPublic {
		return true
	}
	if viewer == nil {
		return false
	}
	if viewer.UserID == aliceID {
		return true // alice owns every fixture
	}
	_, ok := f.acl[viewer.UserID]
	return ok
}

func listableNames(viewer *core.Caller) []string {
	var out []string
	for _, f := range fixtures() {
		if listable(f, viewer) {
			out = append(out, f.name)
		}
	}
	return out
}

// --- the fakes --------------------------------------------------------------

// fakeRepos is the metadata store: the fixture rows, plus the two listing
// queries db/repos.go actually has and the ACL lookup. It applies db/'s SQL as
// Go, and nothing else — no visibility rule beyond the one ListReposByOwner
// documents, so a surface that leaned on the store to hide something would fail
// here rather than pass by accident.
type fakeRepos struct {
	repos []*core.Repo
	acl   map[int]map[int]core.AccessMode // repoID -> userID -> mode

	// listErr, when set, is what both listings answer: a metadata store that
	// could not be read.
	listErr error
}

var _ mcpsrv.Repos = (*fakeRepos)(nil)

func newFakeRepos() *fakeRepos {
	f := &fakeRepos{acl: map[int]map[int]core.AccessMode{}}
	for i, fx := range fixtures() {
		id := i + 1
		f.repos = append(f.repos, &core.Repo{
			ID:          id,
			Name:        fx.name,
			Description: "the " + fx.name + " database",
			OwnerID:     aliceID,
			OwnerName:   "alice",
			Path:        storePath("alice", fx.name),
			Visibility:  fx.visibility,
		})
		for userID, mode := range fx.acl {
			if f.acl[id] == nil {
				f.acl[id] = map[int]core.AccessMode{}
			}
			f.acl[id][userID] = mode
		}
	}
	return f
}

func (f *fakeRepos) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) {
	for _, r := range f.repos {
		if r.OwnerName == owner && r.Name == name {
			return r, nil
		}
	}
	return nil, db.ErrNotFound
}

func (f *fakeRepos) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerName != owner {
			continue
		}
		visible := r.Visibility == core.VisibilityPublic
		if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
			visible = true
		}
		if visible {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerID == userID || f.hasACL(r.ID, userID) {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) {
	mode, ok := f.acl[repoID][userID]
	if !ok {
		return nil, nil
	}
	return &mode, nil
}

func (f *fakeRepos) hasACL(repoID, userID int) bool {
	_, ok := f.acl[repoID][userID]
	return ok
}

// fakeOpener resolves a store path to its fixture session. A path with no
// session is a store that will not open, which is how the "broken" fixture
// behaves.
type fakeOpener struct {
	sessions map[string]*fakeSession
	opened   []string
}

var _ mcpsrv.BrowseOpener = (*fakeOpener)(nil)

func newFakeOpener() *fakeOpener {
	o := &fakeOpener{sessions: map[string]*fakeSession{}}
	for _, fx := range fixtures() {
		if fx.session != nil {
			o.sessions[storePath("alice", fx.name)] = fx.session
		}
	}
	return o
}

func (o *fakeOpener) Open(_ context.Context, diskPath string) (mcpsrv.BrowseSession, error) {
	o.opened = append(o.opened, diskPath)
	sess, ok := o.sessions[diskPath]
	if !ok {
		return nil, fmt.Errorf("no store at %s", diskPath)
	}
	return sess, nil
}

// fakeSession is one bare store as browse/ reads it. Only the three methods
// list_databases calls answer; the rest are the seam's, waiting for the tools of
// ch. 9.1, and a handler that reached one here would fail loudly rather than
// read an empty result.
type fakeSession struct {
	branches []browse.Branch
	commits  []browse.CommitInfo
	tables   []browse.TableInfo

	logErr    error
	tablesErr error

	closes int
}

var _ mcpsrv.BrowseSession = (*fakeSession)(nil)

func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }

func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
	if s.logErr != nil {
		return nil, "", s.logErr
	}
	return s.commits, "", nil
}

func (s *fakeSession) Tables(context.Context, string) ([]browse.TableInfo, error) {
	if s.tablesErr != nil {
		return nil, s.tablesErr
	}
	return s.tables, nil
}

func (s *fakeSession) TableHash(context.Context, string, string) (string, bool, error) {
	panic("TableHash: no tool of this phase reads a table hash")
}

func (s *fakeSession) Rows(context.Context, string, string, int, int) (*browse.RowPage, error) {
	panic("Rows: no tool of this phase reads rows")
}

func (s *fakeSession) CommitSummary(context.Context, string) (*browse.CommitDiff, error) {
	panic("CommitSummary: no tool of this phase reads a diff")
}

func (s *fakeSession) Close() error {
	s.closes++
	return nil
}

// --- callers ----------------------------------------------------------------

// The three principals of the visibility matrix, as the *auth.AuthContext every
// plane of this service produces. Anonymous is a nil one, and it is a caller
// like any other.
func alice() *auth.AuthContext { return user(aliceID, "alice") }
func bob() *auth.AuthContext   { return user(bobID, "bob") }
func carol() *auth.AuthContext { return user(carolID, "carol") }

func user(id int, name string) *auth.AuthContext {
	return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER}
}

// authnContext is a context carrying ac as the resolved caller, exactly as the
// credential middleware leaves it — authn.WithCaller stores a nil one as-is and
// CallerFromContext reads it back as anonymous, which is why an anonymous test
// needs no special case.
func authnContext(ac *auth.AuthContext) context.Context {
	return authn.WithCaller(context.Background(), ac)
}

func coreCaller(ac *auth.AuthContext) *core.Caller {
	if ac == nil {
		return nil
	}
	return &core.Caller{UserID: ac.UserID, Username: ac.Username, UserType: core.UserType(ac.UserType)}
}

// --- plumbing ---------------------------------------------------------------

// newServer builds the surface over the fakes, failing the test on a wiring
// error rather than returning one.
func newServer(t *testing.T, repos mcpsrv.Repos, opener mcpsrv.BrowseOpener) *mcpsrv.Server {
	t.Helper()
	s, err := mcpsrv.New(repos, opener, nil, testOrigin)
	require.NoError(t, err)
	return s
}

// connect runs an in-process MCP client against the real server, with ac as the
// caller.
//
// The caller is injected by connecting the session with a context carrying it,
// which is exactly what happens in production: the SDK connects a session with
// the context of the HTTP request, and every tool handler descends from it. Over
// HTTP the credential middleware puts it there (http_test.go drives that path);
// here the test puts it there directly, and the handlers cannot tell.
func connect(t *testing.T, s *mcpsrv.Server, ac *auth.AuthContext) *mcp.ClientSession {
	t.Helper()
	ctx := authnContext(ac)

	serverTransport, clientTransport := mcp.NewInMemoryTransports()
	serverConn, err := s.Connect(ctx, serverTransport)
	require.NoError(t, err)
	t.Cleanup(func() { _ = serverConn.Close() })

	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil)
	session, err := client.Connect(context.Background(), clientTransport, nil)
	require.NoError(t, err)
	t.Cleanup(func() { _ = session.Close() })
	return session
}

// call makes a tool call, failing the test on a protocol error — which is the
// distinction errors.go draws: a missing database is a result, a store that
// could not answer is a protocol error, and a test that conflated them would
// pass for the wrong reason.
func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
	t.Helper()
	res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
	require.NoError(t, err, "protocol-level failure calling %s", name)
	return res
}

// decode reads a successful tool result into out, asserting it is not an error
// result on the way.
func decode(t *testing.T, res *mcp.CallToolResult, out any) {
	t.Helper()
	require.False(t, res.IsError, "unexpected tool error: %s", errorText(res))
	require.NotNil(t, res.StructuredContent, "no structured output")
	raw, err := json.Marshal(res.StructuredContent)
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw, out))
}

// errorText is the message of an error result, which is where this surface's
// refusals are written.
func errorText(res *mcp.CallToolResult) string {
	var s string
	for _, c := range res.Content {
		if tc, ok := c.(*mcp.TextContent); ok {
			s += tc.Text
		}
	}
	return s
}

// resultJSON is the whole result as it went over the wire — content blocks
// included, not just the structured half — for the tests that assert about
// everything a client can see.
func resultJSON(t *testing.T, res *mcp.CallToolResult) string {
	t.Helper()
	raw, err := json.Marshal(res)
	require.NoError(t, err)
	return string(raw)
}

// The shapes a client decodes into, spelled out here rather than exported from
// the package: they are this surface's contract, and a test that reused the
// production structs would pass no matter what those structs said.
type (
	listDatabasesResult struct {
		Databases []databaseResult `json:"databases"`
	}

	databaseResult struct {
		Owner        string           `json:"owner"`
		Name         string           `json:"name"`
		Description  string           `json:"description"`
		Visibility   string           `json:"visibility"`
		Content      *databaseContent `json:"content"`
		ContentError string           `json:"content_error"`
	}

	databaseContent struct {
		DefaultBranch string     `json:"default_branch"`
		Head          string     `json:"head"`
		HeadTime      *time.Time `json:"head_time"`
		IsBeads       bool       `json:"is_beads"`
	}
)

func names(res listDatabasesResult) []string {
	out := make([]string, 0, len(res.Databases))
	for _, d := range res.Databases {
		out = append(out, d.Name)
	}
	return out
}

func listDatabases(t *testing.T, session *mcp.ClientSession, args map[string]any) listDatabasesResult {
	t.Helper()
	var out listDatabasesResult
	decode(t, call(t, session, "list_databases", args), &out)
	return out
}

// --- the constructor --------------------------------------------------------

func TestNewRejectsAMissingSeam(t *testing.T) {
	opener := newFakeOpener()
	repos := newFakeRepos()

	_, err := mcpsrv.New(nil, opener, nil, testOrigin)
	require.Error(t, err, "a surface with no metadata store would answer every call internal error")
	assert.Contains(t, err.Error(), "Repos")

	_, err = mcpsrv.New(repos, nil, nil, testOrigin)
	require.Error(t, err, "a surface that cannot open a store cannot describe one")
	assert.Contains(t, err.Error(), "BrowseOpener")
}

// A nil InstanceValidator is a configuration and not a missing seam: an instance
// with no [tokens.sr.ht] section still serves meta PATs and anonymous callers
// (authn.ResolveBearer's documented contract, docs/DESIGN.mcp.md §10).
func TestNewAcceptsNoTokensDaemon(t *testing.T) {
	s, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, testOrigin)
	require.NoError(t, err)
	require.NotNil(t, s)

	session := connect(t, s, nil)
	assert.NotEmpty(t, listDatabases(t, session, map[string]any{"owner": "alice"}).Databases,
		"an instance without a token daemon still answers an anonymous caller")
}

// The Host allowlist is derived from the origin, so an origin with no host is a
// constructor error rather than a guessed "localhost" — which would make every
// malformed origin agree with a local client on the one code path that decides
// the allowlist.
func TestNewRequiresAnOriginToGuardWith(t *testing.T) {
	for _, origin := range []string{"", "   ", "not a url", "/relative/path"} {
		t.Run(fmt.Sprintf("%q", origin), func(t *testing.T) {
			_, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, origin)
			require.Error(t, err)
			assert.Contains(t, err.Error(), "no host")
		})
	}
}

// --- the tool ---------------------------------------------------------------

func TestServerAdvertisesTheToolsOfThisPhase(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	require.Equal(t, mcpsrv.ServerName, session.InitializeResult().ServerInfo.Name)

	res, err := session.ListTools(context.Background(), nil)
	require.NoError(t, err)

	var got []string
	for _, tool := range res.Tools {
		got = append(got, tool.Name)
		assert.NotEmpty(t, tool.Description, "%s: an agent reads the description as its documentation", tool.Name)
		require.NotNil(t, tool.Annotations, "%s: every tool here is a read and must say so", tool.Name)
		assert.True(t, tool.Annotations.ReadOnlyHint, "%s", tool.Name)
		require.NotNil(t, tool.InputSchema, "%s: the schema is derived from the Go struct", tool.Name)
	}
	assert.Equal(t, []string{"list_databases"}, got,
		"this phase registers exactly one tool; the rest arrive with their own commits")
}

func TestListDatabasesDescribesADatabase(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	byName := map[string]databaseResult{}
	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		byName[d.Name] = d
	}

	notes := byName["notes"]
	assert.Equal(t, "alice", notes.Owner)
	assert.Equal(t, "the notes database", notes.Description)
	assert.Equal(t, string(core.VisibilityPublic), notes.Visibility)
	require.NotNil(t, notes.Content)
	assert.Equal(t, "main", notes.Content.DefaultBranch)
	assert.Equal(t, "aaaa", notes.Content.Head)
	require.NotNil(t, notes.Content.HeadTime)
	assert.True(t, headTime.Equal(*notes.Content.HeadTime), "got %v", notes.Content.HeadTime)
	assert.False(t, notes.Content.IsBeads, "a database whose tables are not beads' is not a tracker")

	tracker := byName["tracker"]
	require.NotNil(t, tracker.Content)
	assert.True(t, tracker.Content.IsBeads,
		"the fingerprint is beads.Applies, and this fixture satisfies it")
}

// A database that exists and carries no commits is not a failure: its content is
// null and there is no error beside it. The two nulls are distinguishable, which
// is the whole reason content is one object rather than four fields.
func TestADatabaseWithNoCommitsHasNoContentAndNoError(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		if d.Name != "empty" {
			continue
		}
		assert.Nil(t, d.Content, "nothing has been pushed, so there is nothing to describe")
		assert.Empty(t, d.ContentError, "and that is not an error")
		return
	}
	t.Fatal("the empty database was not listed")
}

// One unreadable store costs that database its content and nothing else. The
// alternatives were failing the whole call — one broken store hiding every
// database from every caller — and answering is_beads:false, which is a lie an
// agent cannot detect.
func TestAnUnreadableStoreDoesNotSinkTheListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Subset(t, names(res), []string{"notes", "tracker", "broken"},
		"the other databases are still listed")

	for _, d := range res.Databases {
		if d.Name != "broken" {
			continue
		}
		assert.Nil(t, d.Content, "no content is claimed for a store that did not open")
		assert.NotEmpty(t, d.ContentError, "and the entry says so")
		assert.Equal(t, string(core.VisibilityPublic), d.Visibility,
			"the metadata beside it came from Postgres and is still true")
		return
	}
	t.Fatal("the broken database was not listed")
}

// The failure reaches the caller as a fixed sentence: the real one names on-disk
// paths and dolt internals, and this endpoint is reachable by anyone.
func TestAContentFailureDisclosesNoDetail(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	opener.sessions[storePath("alice", "notes")].tablesErr = errors.New("read /srv/dolt/~alice/notes/manifest: input/output error")

	session := connect(t, newServer(t, repos, opener), nil)
	body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

	assert.NotContains(t, body, "/srv/dolt", "no on-disk path reaches a caller")
	assert.NotContains(t, body, "input/output error")
	assert.Contains(t, body, "could not be read")
}

// Every session a listing opens is closed by the handler that opened it, which
// is the browse discipline: a store held open across calls is a stale manifest
// and a leaked handle.
func TestEverySessionIsClosed(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	session := connect(t, newServer(t, repos, opener), alice())

	listDatabases(t, session, nil)

	for path, sess := range opener.sessions {
		assert.Equal(t, 1, sess.closes, "%s: opened once, closed once", path)
	}
}

// A metadata store that could not answer is a protocol error and not an empty
// listing: an agent must not read "Postgres is down" as "you have no
// databases".
func TestAStoreThatCouldNotAnswerIsAProtocolError(t *testing.T) {
	repos := newFakeRepos()
	repos.listErr = errors.New("dial tcp 127.0.0.1:5432: connection refused")
	session := connect(t, newServer(t, repos, newFakeOpener()), alice())

	res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "list_databases",
		Arguments: map[string]any{"owner": "alice"},
	})
	require.Error(t, err, "a broken metadata store is not an answer")
	assert.NotContains(t, err.Error(), "5432", "and the cause is logged, not sent")
	assert.Nil(t, res)
}

// --- the visibility matrix --------------------------------------------------

// The listing rule of docs/DESIGN.mcp.md §4.3 over every viewer: PUBLIC to
// everyone, an owner sees all of their own, a grantee sees what they were
// granted, and an UNLISTED database of somebody else is absent from the listing
// while remaining readable by direct address.
func TestListDatabasesAppliesTheListingRule(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
		{"a grantee", bob()},
		{"the owner", alice()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			res := listDatabases(t, connect(t, server, tc.caller), map[string]any{"owner": "alice"})
			assert.ElementsMatch(t, listableNames(coreCaller(tc.caller)), names(res))
		})
	}
}

// Nothing a caller may not list leaks through the listing — not as a name, not
// as a description, not in an error message. The private database's name is the
// canary: a stranger who can see the string at all can enumerate what exists.
func TestNothingLeaksToAViewerWhoMayNotList(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			session := connect(t, server, tc.caller)
			body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

			assert.NotContains(t, body, "secrets", "a private database is not named to a caller who may not list it")
			assert.NotContains(t, body, "drafts", "and neither is an unlisted one")
		})
	}
}

// An owner named with the sigil is the address a link shows, so it is accepted
// rather than answered with a sentence about punctuation.
func TestTheOwnerArgumentToleratesTheSigil(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	with := listDatabases(t, session, map[string]any{"owner": "~alice"})
	without := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Equal(t, names(without), names(with))
	assert.NotEmpty(t, names(with))
}

// An owner nobody has and an owner with nothing visible are one answer: an empty
// listing. That is not a limitation to be fixed — a "no such user" would let an
// agent enumerate accounts through a database listing.
func TestAnUnknownOwnerIsAnEmptyListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", map[string]any{"owner": "nobody"})
	assert.False(t, res.IsError, "not knowing an account is not an error")

	var out listDatabasesResult
	decode(t, res, &out)
	assert.Empty(t, out.Databases)
}

// Without an owner the tool answers about the caller: everything they own or
// hold an ACL entry on, whatever its visibility. That is the dashboard query and
// the only "everything I may see" this service can answer.
func TestListDatabasesWithNoOwnerIsTheCallersOwn(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	t.Run("the owner sees all of their own", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, alice()), nil)
		assert.ElementsMatch(t, listableNames(coreCaller(alice())), names(res))
	})

	t.Run("a grantee sees what they were granted", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, bob()), nil)
		assert.Equal(t, []string{"secrets"}, names(res))
	})

	t.Run("a stranger owns nothing and is granted nothing", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, carol()), nil)
		assert.Empty(t, res.Databases)
	})
}

// An anonymous caller with no owner named has nothing to be answered about:
// there is no "every public database on this instance" query (db/repos.go), so
// the tool says what it cannot do and names the two ways out instead of
// answering an empty listing that would read as "this instance is empty".
func TestAnonymousWithNoOwnerIsToldWhatToPass(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", nil)
	require.True(t, res.IsError, "an empty listing here would be a false statement about the instance")

	text := errorText(res)
	assert.Contains(t, text, "owner")
	assert.Contains(t, text, "token")
}

A mcpsrv/ports.go => mcpsrv/ports.go +103 -0
@@ 0,0 1,103 @@
package mcpsrv

import (
	"context"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// The seams this package calls, declared consumer-side — the house style
// web/deps.go sets — so that the tools can be driven by an in-process MCP client
// over fakes, with no Postgres and no store on disk (docs/DESIGN.mcp.md §12).
//
// They name exactly what the tools call, and what is *absent* from them is the
// design of docs/DESIGN.mcp.md §2 made structural rather than remembered:
//
//   - No StoreManager. Nothing on this surface creates, initialises, evicts or
//     deletes an on-disk store, so no handler here can. A write tool added later
//     would have to widen this file first, which is a diff a reviewer sees.
//   - No ACL mutation, no repository create/update/delete. Repos below is the
//     read half of web's RepoStore and nothing more: a resolution, two listings
//     and the caller's effective grant.
//   - No key management. Dolt keys are a credential plane, and a surface reached
//     with one credential has no business enumerating another.
//   - No UserResolver. Resolving a username to a mirrored account is a write to
//     the local user table on first sight; this surface reads.
//   - No SQL engine anywhere. BrowseSession is the bare-store reader of
//     browse/, which is the only reading of a hosted database that exists
//     without a working set (docs/DESIGN.mcp.md §2, browse/open.go).
//
// The nil contract is the one every seam here shares and is not restated per
// method: a nil error means a usable result, no method returns (nil, nil) except
// EffectiveAccess — whose (nil, nil) *is* the answer "this caller holds no ACL
// entry", exactly as db.Store.EffectiveAccess defines it.

// Repos is the metadata store as this surface reads it: the repository rows and
// the caller's ACL grant on one of them.
//
// It is spelled with db.Store's own signatures, so *db.Store satisfies it as
// written and the production wiring binds it through the same request-scoped
// adapter web uses. There is no compile-time assertion here because that adapter
// is web's and unexported; the wiring commit is where the two meet.
//
// Visibility is *not* applied by these methods and must not be assumed from
// them. GetRepoByOwnerAndName answers about any repository that exists, and the
// caller here reproduces the browse dance — EffectiveAccess, core.Allowed,
// core.NotFoundForPrivate — exactly as web/router.go's loadRepoForBrowse does
// (docs/DESIGN.mcp.md §4.3). The two listings are the exception and say so
// themselves.
type Repos interface {
	// GetRepoByOwnerAndName resolves a database by its owner's username (without
	// the "~") and name, or reports db.ErrNotFound. It applies no visibility rule
	// whatsoever: what the caller may see is decided afterwards, by core.Allowed
	// over the grant EffectiveAccess returns.
	GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error)

	// ListReposByOwner lists one owner's databases that viewer may *list*, which
	// is a narrower rule than "may read": PUBLIC to everyone including anonymity,
	// plus anything viewer owns or holds an ACL entry on. An UNLISTED database of
	// somebody else is therefore absent from the listing and still readable by
	// direct address, which is the rule the dashboard already implements.
	//
	// viewer is nil for an anonymous caller, and anonymous is a normal caller
	// here — it sees the PUBLIC ones.
	ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error)

	// ListReposForDashboard lists every database userID owns or holds an ACL
	// entry on, whatever its visibility. It is the signed-in caller's own view of
	// the instance and takes no viewer argument because the user *is* the viewer.
	ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error)

	// EffectiveAccess resolves the caller's ACL grant on a repository, or
	// (nil, nil) when there is none. Feed the result to core.Allowed; a nil grant
	// is not a denial, it is a fall-through to visibility.
	EffectiveAccess(ctx context.Context, userID, repoID int) (*core.AccessMode, error)
}

// BrowseSession is the read-only view of one bare store: the method set
// web/deps.go declares plus TableHash, so a *browse.DB satisfies both and the
// production adapter is the same one web uses.
//
// Everything here reads committed roots. There is no Write, no Commit and no
// working set to hold one — a bare NBS store has none (browse/open.go), which is
// what the whole pure-Go build stands on (docs/DESIGN.mcp.md §2).
type BrowseSession interface {
	Branches(ctx context.Context) ([]browse.Branch, error)
	Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error)
	Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error)
	TableHash(ctx context.Context, refStr, table string) (string, bool, error)
	Rows(ctx context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error)
	CommitSummary(ctx context.Context, hashStr string) (*browse.CommitDiff, error)
	Close() error
}

// BrowseOpener opens a session over the bare store at diskPath.
//
// Open is paired with Close by the handler that called it (defer sess.Close()),
// which is the browse discipline and not an ornament: a session is one fresh
// read of the on-disk manifest, so a handler sees commits the push writer landed
// since the last call and nothing is cached between calls (browse/open.go).
type BrowseOpener interface {
	Open(ctx context.Context, diskPath string) (BrowseSession, error)
}

A mcpsrv/read.go => mcpsrv/read.go +291 -0
@@ 0,0 1,291 @@
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// The tools of docs/DESIGN.mcp.md ch. 9. This commit registers one —
// list_databases, the entry point every other tool's arguments are built from —
// and the rules below are written once here because they hold for all of them:
//
//   - A database is addressed as {owner, name}, both without the "~". That is
//     what the URL says and what an agent can copy out of a link, and it is why
//     this listing answers with the two fields separately rather than with one
//     pre-joined string a tool would then have to take apart.
//   - Visibility is not re-implemented. The listings apply the listing rule on
//     the far side of the seam (ports.go), and every tool that resolves a named
//     database will reproduce the browse dance instead of inventing a second
//     reading of core.Allowed.
//   - Nothing derived is recomputed here: the default branch is
//     browse.DefaultBranch, the beads fingerprint is beads.Applies. A second
//     copy of either is how the board and this surface would start disagreeing
//     about what a beads database is.
//   - Every list is an array — empty rather than null — so an agent can loop
//     without a nil check, and anything genuinely unknown is a null rather than
//     a zero value that reads as an answer.

// databaseJSON is one hosted database as list_databases reports it.
//
// The row's id, and the on-disk path it is served from, are both deliberately
// absent. A database is addressed as {owner, name} on every surface, and
// publishing an internal key an agent has no tool to use would only invite the
// next tool to accept one; the path is the deployment's and no caller's.
type databaseJSON struct {
	Owner       string          `json:"owner"`
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Visibility  core.Visibility `json:"visibility"`

	// Content is everything that had to be read out of the database itself, and
	// it is one nullable object rather than four nullable fields so that "the
	// store could not be read" is one statement an agent checks once.
	//
	// A null Content with an empty ContentError is a database that exists and
	// carries no commits yet — a store created by a push that has not arrived.
	// A null Content *with* a ContentError is a store this daemon could not
	// read; the metadata beside it still came from Postgres and is still true.
	Content *databaseContentJSON `json:"content"`

	// ContentError is a fixed sentence, never the underlying failure: the real
	// one names on-disk paths and dolt internals. The cause is logged instead.
	ContentError string `json:"content_error,omitempty"`
}

// databaseContentJSON is what one read of a database's store answers about it
// at its default branch.
type databaseContentJSON struct {
	// DefaultBranch is browse.DefaultBranch's answer — "main" when it exists,
	// otherwise the first branch by name. It is the ref every tool that takes an
	// optional one falls back to, so an agent that does not care about branches
	// never has to name one.
	DefaultBranch string `json:"default_branch"`

	// Head is the hash of that branch's head commit, as the branch itself
	// reports it.
	Head string `json:"head"`

	// HeadTime is when that commit was authored. It is null only if the branch
	// head could not be read as a commit, which is a broken store rather than a
	// young one.
	HeadTime *time.Time `json:"head_time"`

	// IsBeads reports whether the tables at the default branch carry the beads
	// fingerprint (beads.Applies), i.e. whether the beads-aware tools of ch. 9.2
	// will answer about this database. It is the one reading of that fingerprint
	// on this instance, shared with the web board.
	IsBeads bool `json:"is_beads"`
}

// listDatabasesInput carries the one argument today's queries make necessary.
//
// docs/DESIGN.mcp.md §9.1 gives this tool no arguments at all — "every database
// the caller may list" — and that is not answerable with the queries this
// service has. db/repos.go enumerates in exactly two ways: by owner
// (ListReposByOwner, which applies the listing rule including anonymity) and by
// membership (ListReposForDashboard, "owned or ACL'd", which needs a user id).
// There is no "every PUBLIC database on the instance" query, and inventing one
// is a change to db/'s file set rather than to this one. So the argument is
// optional and the two arms are exactly the two queries.
type listDatabasesInput struct {
	// Owner is a SourceHut username without the "~". A leading one is tolerated
	// rather than refused: it is what a link shows, so an agent copying an
	// address is more likely to include it than not, and refusing it would be a
	// sentence about punctuation in place of an answer.
	Owner string `json:"owner,omitempty" jsonschema:"the SourceHut username whose databases to list, without the \"~\". Omit it to list your own — the databases you own or hold an ACL entry on — which requires a credential."`
}

// listDatabasesOutput wraps the array in an object rather than serving a bare
// one, so that a later addition is a new field and not a change of the
// document's type.
type listDatabasesOutput struct {
	Databases []databaseJSON `json:"databases"`
}

// contentUnreadable is the ContentError sentence. It is one string for every
// cause — a missing store dir, a corrupt manifest, a ref that will not resolve —
// because the difference is the operator's business (it is in the log) and not
// the agent's: nothing an agent can do about a broken store differs by cause.
const contentUnreadable = "this database's store could not be read on the server; its metadata below is still accurate"

// register installs the tools on the protocol server.
//
// Every one is annotated read-only and idempotent, which is true of all of them
// and is what tells a client it may retry a call freely: this surface has no
// write tool at all, and ports.go is why it cannot grow one by accident.
//
// The descriptions are the agent-facing documentation of this service and are
// written for a reader who has never seen the design document — what the tool
// answers, how to address what it answers about, and what it cannot answer.
func (s *Server) register() {
	readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "list_databases",
		Annotations: readOnly,
		Description: "List hosted Dolt databases with their visibility, description, default branch and " +
			"head commit, and whether each one is a beads issue tracker.\n\n" +
			"Pass `owner` — a SourceHut username without the \"~\" — to list that user's databases. " +
			"Omit it to list your own: everything you own or have been granted access to, which needs " +
			"a bearer token.\n\n" +
			"There is no way to enumerate every database on this instance: the service can only " +
			"answer per owner, or about you. Without `owner` and without a credential there is " +
			"nothing to list, and the call says so.\n\n" +
			"What you see depends on the token you present: a user's public databases to everyone, " +
			"plus any of theirs you own or hold access to. An unlisted database of somebody else " +
			"never appears here and is still readable if you address it directly; a private one you " +
			"have no access to is reported as not existing, which is the same answer a name nobody " +
			"took gets.\n\n" +
			"Address a database in the other tools as the `owner` and `name` of an entry here. " +
			"`content` is null for a database with no commits yet, and carries `content_error` when " +
			"its store could not be read.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listDatabasesInput) (*mcp.CallToolResult, listDatabasesOutput, error) {
		out, err := s.listDatabases(ctx, in)
		return nil, out, err
	})
}

// listDatabases answers list_databases: the databases the caller may list, each
// described by one read of its store.
//
// The two arms are the two queries db/ has, and neither of them is this
// package's reading of visibility — ListReposByOwner applies the listing rule
// (PUBLIC to anyone including anonymity, plus what the viewer owns or is ACL'd
// on) and ListReposForDashboard is the caller's own membership. An UNLISTED
// database of another owner is absent from both, which is the design's rule and
// the dashboard's behaviour, not a narrowing invented here.
func (s *Server) listDatabases(ctx context.Context, in listDatabasesInput) (listDatabasesOutput, error) {
	caller := callerOf(ctx)
	owner := strings.TrimPrefix(strings.TrimSpace(in.Owner), "~")

	var (
		repos []*core.Repo
		err   error
	)
	switch {
	case owner != "":
		repos, err = s.repos.ListReposByOwner(ctx, owner, caller)
	case caller == nil:
		// A tool result and not a protocol error: the call was understood, and
		// what it asked for cannot exist rather than could not be produced. The
		// sentence names the way out, because there is one.
		return listDatabasesOutput{}, errors.New(
			"no owner was named and this call carries no credential, so there is nothing to list: " +
				"pass owner to list one user's public databases, or present a bearer token to list your own")
	default:
		repos, err = s.repos.ListReposForDashboard(ctx, caller.UserID)
	}
	if err != nil {
		// No database was addressed, so there is no "that one does not exist" to
		// answer: whatever went wrong enumerating them is this service's.
		return listDatabasesOutput{}, internalError(err, "list_databases")
	}

	out := make([]databaseJSON, 0, len(repos))
	for _, repo := range repos {
		out = append(out, s.describe(ctx, repo))
	}
	return listDatabasesOutput{Databases: out}, nil
}

// describe renders one repository row and adds what its store says about
// itself.
//
// A store that cannot be read costs this database its content and nothing more:
// the listing still names it, with the metadata Postgres holds, and says the
// store could not be read. Two alternatives were rejected. Failing the whole
// call would let one broken store hide every other database from every caller;
// and answering `is_beads: false` for a tracker whose store did not open would
// be a lie an agent has no way to detect, which is the failure mode the whole
// truncation rule of ch. 9.3 exists to avoid.
func (s *Server) describe(ctx context.Context, repo *core.Repo) databaseJSON {
	out := databaseJSON{
		Owner:       repo.OwnerName,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.Visibility,
	}

	content, err := s.readContent(ctx, repo)
	if err != nil {
		slog.Error("a hosted store could not be read for list_databases",
			"owner", repo.OwnerName, "database", repo.Name, scribe.Err(err))
		out.ContentError = contentUnreadable
		return out
	}
	out.Content = content
	return out
}

// readContent opens one bare store and reads the three things a listing entry
// carries: the default branch, its head, and whether the tables there are a
// beads tracker.
//
// It returns (nil, nil) for a database with no branches — a store that exists
// and has never been pushed to. That is an answer and not a failure, and it is
// distinguishable from a failure because a failure returns an error.
//
// The session is opened per call and closed here, which is the browse
// discipline: a fresh read of the on-disk manifest every time, so a push that
// landed a second ago is visible and nothing is cached between calls. It is
// also the cost of this tool — one store opened per database listed — and the
// reason the listing carries a head and a fingerprint rather than every tool
// having to ask for them separately.
func (s *Server) readContent(ctx context.Context, repo *core.Repo) (*databaseContentJSON, error) {
	sess, err := s.opener.Open(ctx, repo.Path)
	if err != nil {
		return nil, fmt.Errorf("opening the store: %w", err)
	}
	defer sess.Close()

	branches, err := sess.Branches(ctx)
	if err != nil {
		return nil, fmt.Errorf("listing branches: %w", err)
	}
	name := browse.DefaultBranch(branches)
	if name == "" {
		return nil, nil
	}

	content := &databaseContentJSON{DefaultBranch: name, Head: headOf(branches, name)}

	commits, _, err := sess.Log(ctx, name, "", 1)
	if err != nil {
		return nil, fmt.Errorf("reading the head commit of %q: %w", name, err)
	}
	if len(commits) > 0 {
		at := commits[0].Date
		content.HeadTime = &at
	}

	tables, err := sess.Tables(ctx, name)
	if err != nil {
		return nil, fmt.Errorf("listing tables at %q: %w", name, err)
	}
	content.IsBeads = beads.Applies(tables)

	return content, nil
}

// headOf returns the head hash the branch list carries for name, or "" if the
// list does not name it — which browse.DefaultBranch's contract makes
// impossible, since it picks out of this very list.
func headOf(branches []browse.Branch, name string) string {
	for _, b := range branches {
		if b.Name == name {
			return b.Head
		}
	}
	return ""
}