From dcdc9790d7fa085b5238eb39f196ccc8a2c0d5da Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:23:45 +0300 Subject: [PATCH] mcpsrv: serve a stateless read-only MCP surface --- go.mod | 9 +- go.sum | 14 + mcpsrv/errors.go | 81 ++++ mcpsrv/errors_internal_test.go | 49 +++ mcpsrv/http_test.go | 582 +++++++++++++++++++++++++ mcpsrv/mcpsrv.go | 513 ++++++++++++++++++++++ mcpsrv/mcpsrv_test.go | 772 +++++++++++++++++++++++++++++++++ mcpsrv/ports.go | 103 +++++ mcpsrv/read.go | 291 +++++++++++++ 9 files changed, 2412 insertions(+), 2 deletions(-) create mode 100644 mcpsrv/errors.go create mode 100644 mcpsrv/errors_internal_test.go create mode 100644 mcpsrv/http_test.go create mode 100644 mcpsrv/mcpsrv.go create mode 100644 mcpsrv/mcpsrv_test.go create mode 100644 mcpsrv/ports.go create mode 100644 mcpsrv/read.go diff --git a/go.mod b/go.mod index e6332ed17dd44cab892dcd41d1f16d1041a5b293..881d0a5fa673d5594a7dd607e17512c0c14db2bf 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d0c58d72284944d0e6df87af7c4d8f9a008b454f..fa288fcb6000476da070de1590d13ae7ce0de944 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/mcpsrv/errors.go b/mcpsrv/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..23664f0b39567a10ead8b7e43a7fe235c5c58c2c --- /dev/null +++ b/mcpsrv/errors.go @@ -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} +} diff --git a/mcpsrv/errors_internal_test.go b/mcpsrv/errors_internal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6928a7b83fad75e90268db7a56f74a80c89bb045 --- /dev/null +++ b/mcpsrv/errors_internal_test.go @@ -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") + }) +} diff --git a/mcpsrv/http_test.go b/mcpsrv/http_test.go new file mode 100644 index 0000000000000000000000000000000000000000..08a1d7bcf91bfdef12f18c6d0dcc2544d07edd96 --- /dev/null +++ b/mcpsrv/http_test.go @@ -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") + }) + } +} diff --git a/mcpsrv/mcpsrv.go b/mcpsrv/mcpsrv.go new file mode 100644 index 0000000000000000000000000000000000000000..2a5c6a8af4091a7a314560e626eaafdfc6924ef9 --- /dev/null +++ b/mcpsrv/mcpsrv.go @@ -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 +} diff --git a/mcpsrv/mcpsrv_test.go b/mcpsrv/mcpsrv_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e36884c40f7bc701351648dd4549f5c74f7c9da2 --- /dev/null +++ b/mcpsrv/mcpsrv_test.go @@ -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") +} diff --git a/mcpsrv/ports.go b/mcpsrv/ports.go new file mode 100644 index 0000000000000000000000000000000000000000..100dcbba34db6875360cf717219dced5ad32c37b --- /dev/null +++ b/mcpsrv/ports.go @@ -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) +} diff --git a/mcpsrv/read.go b/mcpsrv/read.go new file mode 100644 index 0000000000000000000000000000000000000000..53c3fc6cb24e4ebaa35ce5ed9e7d583ec23ca37a --- /dev/null +++ b/mcpsrv/read.go @@ -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 "" +}