package mcpsrv_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"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{
{
// The database the paging tools are measured against: more rows than
// read_rows' cap and more commits than get_commit_log's.
name: "notes",
visibility: core.VisibilityPublic,
session: notesStore(),
},
{
// The one a beads-aware tool will answer about: its tables carry the
// fingerprint beads.Applies looks for.
name: "tracker",
visibility: core.VisibilityPublic,
session: smallStore("main", "bbbb", 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: smallStore("main", "dddd", []fakeTable{newTable("drafts", 3)}),
},
{
name: "secrets",
visibility: core.VisibilityPrivate,
acl: map[int]core.AccessMode{bobID: core.AccessRO},
session: smallStore("release", "eeee", []fakeTable{newTable("secrets", 3), newTable("keys", 1)}),
},
}
}
// 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)
// The size of the "notes" database, chosen so that both caps of
// docs/DESIGN.mcp.md §9.3 are exceeded: a page that stops short is the ordinary
// case here rather than a contrived one, and the totals are prime-ish numbers no
// cap or default divides evenly, so an off-by-a-page is visible.
const (
notesRows = 1200
notesCommits = 150
)
// notesStore is a database with two branches, a linear history and two tables.
//
// Its head commit is "aaaa" at headTime, because that is what the list_databases
// suite asserts about it; everything else about it exists for the tools of
// ch. 9.1.
func notesStore() *fakeSession {
tables := []fakeTable{newTable("notes", notesRows), newTable("tags", 3)}
commits := make([]browse.CommitInfo, notesCommits)
for i := range commits {
hash := fmt.Sprintf("c%03d", i)
if i == 0 {
hash = "aaaa"
}
commits[i] = browse.CommitInfo{
Hash: hash,
Author: "alice",
Date: headTime.Add(-time.Duration(i) * time.Hour),
Message: fmt.Sprintf("commit %d", i),
}
}
for i := range commits[:len(commits)-1] {
commits[i].ParentHashes = []string{commits[i+1].Hash}
}
diffs := map[string]*browse.CommitDiff{}
for i, c := range commits {
if i == len(commits)-1 {
// The oldest commit has no parent, so browse compares it against the
// empty root and every table reads as added.
diffs[c.Hash] = initialDiff(c.Hash, tables)
continue
}
diffs[c.Hash] = &browse.CommitDiff{
Hash: c.Hash,
Tables: []browse.TableDiff{{Name: "notes", RowsAdded: 2, RowsModified: 1}},
}
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: "aaaa"}, {Name: "wip", Head: "c001"}},
commits: commits,
tables: tables,
diffs: diffs,
}
}
// smallStore is a database of one branch and one commit — the initial one, so
// its diff has every table added — over the tables given.
func smallStore(branch, head string, tables []fakeTable) *fakeSession {
return &fakeSession{
branches: []browse.Branch{{Name: branch, Head: head}},
commits: []browse.CommitInfo{{
Hash: head, Author: "alice", Date: headTime, Message: "initial commit",
}},
tables: tables,
diffs: map[string]*browse.CommitDiff{head: initialDiff(head, tables)},
}
}
// initialDiff is what browse.CommitSummary answers for a commit with no parent:
// it compares against the empty root, so every table shows as added with all of
// its rows. The rule is browse/'s and is tested there; this is a fixture that
// reproduces it, not a second implementation of it.
func initialDiff(hash string, tables []fakeTable) *browse.CommitDiff {
d := &browse.CommitDiff{Hash: hash}
for _, t := range tables {
d.Tables = append(d.Tables, browse.TableDiff{
Name: t.name,
Added: true,
RowsAdded: int64(len(t.rows)),
})
}
return d
}
// fakeTable is one table of a fixture store: the schema browse would report and
// the rows behind it, kept consistent by construction — a test that compares a
// reported row count against the rows it can read cannot be satisfied by a fake
// that disagrees with itself.
type fakeTable struct {
name string
cols []browse.ColumnInfo
rows [][]string
}
func (t fakeTable) info() browse.TableInfo {
return browse.TableInfo{Name: t.name, Columns: t.cols, RowCount: uint64(len(t.rows))}
}
// columns is the display order browse.Rows produces: primary key first, then the
// rest. The fixture schemas below declare their columns in that order already.
func (t fakeTable) columns() []string {
out := make([]string, 0, len(t.cols))
for _, c := range t.cols {
out = append(out, c.Name)
}
return out
}
// newTable builds a table of n rows over a two-column keyed schema.
func newTable(name string, n int) fakeTable {
rows := make([][]string, n)
for i := range rows {
rows[i] = []string{strconv.Itoa(i), fmt.Sprintf("%s row %d", name, i)}
}
return fakeTable{
name: name,
cols: []browse.ColumnInfo{
{Name: "id", Type: "int", PrimaryKey: true},
{Name: "body", Type: "text", Nullable: true},
},
rows: rows,
}
}
// 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() []fakeTable {
return []fakeTable{
{
name: "issues",
cols: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}, {Name: "title"}},
rows: [][]string{{"bd-1", "open", "first"}, {"bd-2", "closed", "second"}},
},
{
name: "dependencies",
cols: []browse.ColumnInfo{{Name: "from_id"}, {Name: "to_id"}},
rows: [][]string{{"bd-2", "bd-1"}},
},
}
}
// 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: a branch list, a linear
// history newest-first, tables with rows, and one recorded diff per commit.
//
// It reproduces browse/'s *contract* rather than its implementation — a ref is a
// branch name or a commit hash, an unknown one is ErrRefNotFound, an unknown
// table is ErrTableNotFound, a page past the end is empty with the true total —
// because those are the behaviours the tools are written against. Anything it
// cannot answer fails loudly rather than returning an empty result that would
// read as an answer.
type fakeSession struct {
branches []browse.Branch
commits []browse.CommitInfo // newest first, as browse.Log walks them
tables []fakeTable
diffs map[string]*browse.CommitDiff
logErr error
tablesErr error
rowsErr error
closes int
}
var _ mcpsrv.BrowseSession = (*fakeSession)(nil)
func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }
// Log walks the history from ref's head, or from a cursor, and reports the hash
// of the commit after the page — which is how browse says "there is more".
func (s *fakeSession) Log(_ context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) {
if s.logErr != nil {
return nil, "", s.logErr
}
if limit <= 0 {
// browse.Log's own guard. The tools cap and default before they call, so
// reaching this is a bug in the tool rather than a caller's argument.
return nil, "", fmt.Errorf("fake: log limit must be positive, got %d", limit)
}
start := -1
if fromHash != "" {
// browse has no sentinel for a cursor that does not parse, and neither has
// this: a hand-written cursor is not a miss it classifies.
if start = s.indexOf(fromHash); start < 0 {
return nil, "", fmt.Errorf("fake: invalid from hash %q", fromHash)
}
} else {
start = s.indexOf(s.resolve(refStr))
}
if start < 0 {
return nil, "", fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
}
end, next := start+limit, ""
if end < len(s.commits) {
next = s.commits[end].Hash
} else {
end = len(s.commits)
}
return append([]browse.CommitInfo(nil), s.commits[start:end]...), next, nil
}
func (s *fakeSession) Tables(_ context.Context, refStr string) ([]browse.TableInfo, error) {
if s.tablesErr != nil {
return nil, s.tablesErr
}
if s.resolve(refStr) == "" {
return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
}
out := make([]browse.TableInfo, 0, len(s.tables))
for _, t := range s.tables {
out = append(out, t.info())
}
return out, 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, refStr, table string, offset, limit int) (*browse.RowPage, error) {
if s.rowsErr != nil {
return nil, s.rowsErr
}
if s.resolve(refStr) == "" {
return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, refStr)
}
if offset < 0 || limit <= 0 {
return nil, fmt.Errorf("fake: bad page offset=%d limit=%d", offset, limit)
}
for _, t := range s.tables {
if t.name != table {
continue
}
page := &browse.RowPage{
Columns: t.columns(),
Rows: [][]string{},
Offset: offset,
Total: len(t.rows),
}
if offset < len(t.rows) {
end := min(offset+limit, len(t.rows))
page.Rows = append(page.Rows, t.rows[offset:end]...)
}
return page, nil
}
return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}
func (s *fakeSession) CommitSummary(_ context.Context, hashStr string) (*browse.CommitDiff, error) {
hash := s.resolve(hashStr)
if hash == "" {
return nil, fmt.Errorf("%w: %s", browse.ErrRefNotFound, hashStr)
}
diff, ok := s.diffs[hash]
if !ok {
// A commit that exists always has a summary in browse; a fixture missing
// one is a gap in the fixture, and saying so beats answering "no tables
// changed".
return nil, fmt.Errorf("fake: no recorded diff for %s", hash)
}
return diff, nil
}
func (s *fakeSession) Close() error {
s.closes++
return nil
}
// resolve maps a ref — a branch name or a commit hash, which is what
// browse.resolveCommit accepts — to a commit hash, or "" when it is neither.
func (s *fakeSession) resolve(refStr string) string {
for _, b := range s.branches {
if b.Name == refStr {
return b.Head
}
}
if s.indexOf(refStr) >= 0 {
return refStr
}
return ""
}
func (s *fakeSession) indexOf(hash string) int {
for i, c := range s.commits {
if c.Hash == hash {
return i
}
}
return -1
}
// --- 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 TestServerAdvertisesTheToolsOfTheseChapters(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)
}
// The generic surface of docs/DESIGN.mcp.md §9.1, whole. The beads-aware
// tools of §9.2 arrive with their own commit, and this list is where their
// absence — or an accidental extra — is visible.
assert.ElementsMatch(t, []string{
"list_databases",
"list_branches",
"list_tables",
"read_rows",
"get_commit_log",
"get_commit_diff",
}, got)
}
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")
}