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