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 and the beads-aware tools of // §9.2, whole. This list is where an accidental extra — or a tool that // quietly stopped being registered — is visible. // // §9.2's list_memories and ready_work are deliberately absent: the memory // projection and the cross-database aggregation arrive with their own // commits (§11, phases 5 and 7). assert.ElementsMatch(t, []string{ "list_databases", "list_branches", "list_tables", "read_rows", "get_commit_log", "get_commit_diff", "list_issues", "get_issue", "list_milestones", }, 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") }