package mcpsrv_test import ( "context" "errors" "strings" "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/core" "sourcecraft.dev/bigbes/sr-ht-dolt/mcpsrv" ) // The generic tools of docs/DESIGN.mcp.md §9.1, driven through the same // in-process MCP client the rest of the suite uses (mcpsrv_test.go carries the // fixtures, the fakes and the plumbing). // // Three properties are worth more than the rest here and each has a section of // its own below: a database the caller may not read answers exactly as one that // does not exist; a page that stopped short says so and says how far it had to // go; and a ref or a table that is not there is an answer *about* a database the // caller is looking straight at, not the masked refusal. // --- the shapes a client decodes -------------------------------------------- // // 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 ( branchResult struct { Name string `json:"name"` Head string `json:"head"` } listBranchesResult struct { Branches []branchResult `json:"branches"` DefaultBranch string `json:"default_branch"` } columnResult struct { Name string `json:"name"` Type string `json:"type"` PrimaryKey bool `json:"primary_key"` Nullable bool `json:"nullable"` } tableResult struct { Name string `json:"name"` Columns []columnResult `json:"columns"` RowCount uint64 `json:"row_count"` } listTablesResult struct { Ref string `json:"ref"` Tables []tableResult `json:"tables"` } readRowsResult struct { Ref string `json:"ref"` Table string `json:"table"` Columns []string `json:"columns"` Rows [][]string `json:"rows"` Offset int `json:"offset"` Limit int `json:"limit"` Total int `json:"total"` Truncated bool `json:"truncated"` } commitResult struct { Hash string `json:"hash"` Author string `json:"author"` Date time.Time `json:"date"` Message string `json:"message"` Parents []string `json:"parents"` } commitLogResult struct { Ref string `json:"ref"` Commits []commitResult `json:"commits"` Next string `json:"next"` Limit int `json:"limit"` Truncated bool `json:"truncated"` } tableDiffResult struct { Name string `json:"name"` Added bool `json:"added"` Dropped bool `json:"dropped"` SchemaChanged bool `json:"schema_changed"` RowsAdded int64 `json:"rows_added"` RowsRemoved int64 `json:"rows_removed"` RowsModified int64 `json:"rows_modified"` } commitDiffResult struct { Hash string `json:"hash"` Tables []tableDiffResult `json:"tables"` } ) // args addresses ~alice/ and adds the key/value pairs given, so that a // call in a test reads as the one argument it is actually about. func args(name string, kv ...any) map[string]any { if len(kv)%2 != 0 { panic("args: odd key/value list") } out := map[string]any{"owner": "alice", "name": name} for i := 0; i < len(kv); i += 2 { out[kv[i].(string)] = kv[i+1] } return out } func anonSession(t *testing.T) *mcp.ClientSession { t.Helper() return connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil) } func readRows(t *testing.T, s *mcp.ClientSession, a map[string]any) readRowsResult { t.Helper() var out readRowsResult decode(t, call(t, s, "read_rows", a), &out) return out } func commitLog(t *testing.T, s *mcp.ClientSession, a map[string]any) commitLogResult { t.Helper() var out commitLogResult decode(t, call(t, s, "get_commit_log", a), &out) return out } // --- what each tool answers ------------------------------------------------- func TestListBranchesNamesEveryBranchAndTheDefault(t *testing.T) { var out listBranchesResult decode(t, call(t, anonSession(t), "list_branches", args("notes")), &out) assert.ElementsMatch(t, []branchResult{ {Name: "main", Head: "aaaa"}, {Name: "wip", Head: "c001"}, }, out.Branches) assert.Equal(t, "main", out.DefaultBranch, "the default is browse.DefaultBranch's answer, reported so a caller need not reimplement the rule") } // A database nothing has been pushed to has no branches, and that is an answer: // an empty list and an empty default, not a failure and not a refusal. func TestListBranchesOfADatabaseWithNoCommits(t *testing.T) { res := call(t, anonSession(t), "list_branches", args("empty")) require.False(t, res.IsError, "%s", errorText(res)) var out listBranchesResult decode(t, res, &out) assert.Empty(t, out.Branches) assert.Empty(t, out.DefaultBranch) } func TestListTablesReportsSchemaAndRowCounts(t *testing.T) { var out listTablesResult decode(t, call(t, anonSession(t), "list_tables", args("notes")), &out) assert.Equal(t, "main", out.Ref) require.Len(t, out.Tables, 2) byName := map[string]tableResult{} for _, tbl := range out.Tables { byName[tbl.Name] = tbl } require.Contains(t, byName, "notes") assert.EqualValues(t, notesRows, byName["notes"].RowCount) assert.EqualValues(t, 3, byName["tags"].RowCount) assert.Equal(t, []columnResult{ {Name: "id", Type: "int", PrimaryKey: true, Nullable: false}, {Name: "body", Type: "text", PrimaryKey: false, Nullable: true}, }, byName["notes"].Columns) } // An omitted ref is the database's default branch, and the answer names the one // it read — so an agent that did not care about branches learns which it is // looking at and can pin it for the rest of a series of calls. func TestRefDefaultsToTheDefaultBranch(t *testing.T) { server := newServer(t, newFakeRepos(), newFakeOpener()) t.Run("omitted", func(t *testing.T) { var out listTablesResult decode(t, call(t, connect(t, server, nil), "list_tables", args("notes")), &out) assert.Equal(t, "main", out.Ref) }) t.Run("named", func(t *testing.T) { var out listTablesResult decode(t, call(t, connect(t, server, nil), "list_tables", args("notes", "ref", "wip")), &out) assert.Equal(t, "wip", out.Ref) }) // browse.DefaultBranch falls back to the first branch by name when there is // no "main", and this database has only a "release" — the fallback is the // rule, not an accident of the other fixtures all being called "main". t.Run("a database with no main", func(t *testing.T) { var out listTablesResult decode(t, call(t, connect(t, server, bob()), "list_tables", args("secrets")), &out) assert.Equal(t, "release", out.Ref) }) // read_rows and get_commit_log default the same way, and each one is asked // rather than assumed to share an implementation. t.Run("read_rows", func(t *testing.T) { got := readRows(t, connect(t, server, nil), args("notes", "table", "notes")) assert.Equal(t, "main", got.Ref) }) t.Run("get_commit_log", func(t *testing.T) { got := commitLog(t, connect(t, server, nil), args("notes")) assert.Equal(t, "main", got.Ref) }) } // A database with no branches has no default to fall back to. That is a sentence // the agent reads — nothing has been pushed yet — and not a protocol failure, and // not the masked "no such database" either: the database is right there. func TestADatabaseWithNoBranchesHasNothingToReadAt(t *testing.T) { server := newServer(t, newFakeRepos(), newFakeOpener()) for _, tc := range []struct { tool string args map[string]any }{ {"list_tables", args("empty")}, {"read_rows", args("empty", "table", "notes")}, {"get_commit_log", args("empty")}, } { t.Run(tc.tool, func(t *testing.T) { res := call(t, connect(t, server, nil), tc.tool, tc.args) require.True(t, res.IsError) text := errorText(res) assert.Contains(t, text, "~alice/empty") assert.Contains(t, text, "no branches") assert.NotContains(t, text, "no database", "the database exists and the caller may read it; only its history is missing") }) } } func TestReadRowsAnswersAPageAndTheTotal(t *testing.T) { got := readRows(t, anonSession(t), args("notes", "table", "notes")) assert.Equal(t, "main", got.Ref) assert.Equal(t, "notes", got.Table) assert.Equal(t, []string{"id", "body"}, got.Columns) assert.Equal(t, 0, got.Offset) assert.Equal(t, 100, got.Limit, "the default of docs/DESIGN.mcp.md §9.3") assert.Len(t, got.Rows, 100) assert.Equal(t, []string{"0", "notes row 0"}, got.Rows[0]) assert.Equal(t, notesRows, got.Total, "the denominator is the whole table, not the page") assert.True(t, got.Truncated, "1100 rows are left behind and the caller cannot see the table") } func TestReadRowsPagesByOffset(t *testing.T) { session := anonSession(t) t.Run("a page in the middle", func(t *testing.T) { got := readRows(t, session, args("notes", "table", "notes", "offset", 500, "limit", 10)) assert.Equal(t, 500, got.Offset) require.Len(t, got.Rows, 10) assert.Equal(t, []string{"500", "notes row 500"}, got.Rows[0]) assert.True(t, got.Truncated) }) t.Run("the last page is not truncated", func(t *testing.T) { got := readRows(t, session, args("notes", "table", "notes", "offset", notesRows-10, "limit", 100)) assert.Len(t, got.Rows, 10) assert.Equal(t, notesRows, got.Total) assert.False(t, got.Truncated, "nothing follows this page, so nothing was left behind") }) // Past the end is an empty page with the true total, which is how a caller // that paged one step too far learns it did rather than concluding the table // emptied out. t.Run("past the end", func(t *testing.T) { got := readRows(t, session, args("notes", "table", "notes", "offset", notesRows+50)) assert.Empty(t, got.Rows) assert.Equal(t, notesRows, got.Total) assert.False(t, got.Truncated) }) } // The cap of docs/DESIGN.mcp.md §9.3 is applied and *stated*: the answer carries // the limit that was really used and the total it stopped short of, so a caller // asking for 5000 rows can tell what it got. func TestReadRowsCapsTheLimitAndSaysSo(t *testing.T) { got := readRows(t, anonSession(t), args("notes", "table", "notes", "limit", 5000)) assert.Equal(t, 500, got.Limit, "the cap, reported rather than silently applied") assert.Len(t, got.Rows, 500) assert.Equal(t, notesRows, got.Total) assert.True(t, got.Truncated) } // A limit of zero or less is refused rather than defaulted: the caller typed a // number, and answering a default page would make the result a statement about a // request nobody made. An empty page would read as an empty table. func TestReadRowsRefusesANonPositivePage(t *testing.T) { session := anonSession(t) for _, limit := range []int{0, -1} { res := call(t, session, "read_rows", args("notes", "table", "notes", "limit", limit)) require.True(t, res.IsError, "limit %d", limit) assert.Contains(t, errorText(res), "limit must be a positive number of rows") } res := call(t, session, "read_rows", args("notes", "table", "notes", "offset", -3)) require.True(t, res.IsError) assert.Contains(t, errorText(res), "offset must be zero or more") } func TestReadRowsNeedsATable(t *testing.T) { res := call(t, anonSession(t), "read_rows", args("notes", "table", "")) require.True(t, res.IsError) assert.Contains(t, errorText(res), "table") } func TestGetCommitLogAnswersNewestFirst(t *testing.T) { got := commitLog(t, anonSession(t), args("notes")) assert.Equal(t, "main", got.Ref) assert.Equal(t, 25, got.Limit, "the default of docs/DESIGN.mcp.md §9.3") require.Len(t, got.Commits, 25) head := got.Commits[0] assert.Equal(t, "aaaa", head.Hash) assert.Equal(t, "alice", head.Author) assert.Equal(t, "commit 0", head.Message) assert.True(t, headTime.Equal(head.Date), "got %v", head.Date) assert.Equal(t, []string{"c001"}, head.Parents) } // The commit log has no total — counting one means walking the whole graph — so // the cursor is what tells a full answer from a clipped one, and it has to // actually continue the page. func TestGetCommitLogPagesWithACursor(t *testing.T) { session := anonSession(t) first := commitLog(t, session, args("notes")) require.True(t, first.Truncated) require.NotEmpty(t, first.Next) second := commitLog(t, session, args("notes", "from", first.Next)) require.NotEmpty(t, second.Commits) assert.Equal(t, first.Next, second.Commits[0].Hash, "the cursor is where the next page starts") assert.Empty(t, second.Ref, "a from-hash is a point in the graph, and claiming it belongs to a branch would be unchecked") last := commitLog(t, session, args("notes", "from", "c149")) assert.False(t, last.Truncated, "the initial commit is the end of the history") assert.Empty(t, last.Next) require.Len(t, last.Commits, 1) assert.Equal(t, []string{}, last.Commits[0].Parents, "an empty array rather than null, so an agent can loop without a nil check") } func TestGetCommitLogCapsTheLimitAndSaysSo(t *testing.T) { got := commitLog(t, anonSession(t), args("notes", "limit", 500)) assert.Equal(t, 100, got.Limit, "the cap of docs/DESIGN.mcp.md §9.3") assert.Len(t, got.Commits, 100) assert.True(t, got.Truncated) assert.NotEmpty(t, got.Next) } func TestGetCommitLogRefusesANonPositiveLimit(t *testing.T) { session := anonSession(t) for _, limit := range []int{0, -5} { res := call(t, session, "get_commit_log", args("notes", "limit", limit)) require.True(t, res.IsError, "limit %d", limit) assert.Contains(t, errorText(res), "limit must be a positive number of commits") } } func TestGetCommitLogReadsTheRefItIsGiven(t *testing.T) { got := commitLog(t, anonSession(t), args("notes", "ref", "wip", "limit", 3)) assert.Equal(t, "wip", got.Ref) require.NotEmpty(t, got.Commits) assert.Equal(t, "c001", got.Commits[0].Hash, "the head of that branch, not of the default one") } // The initial commit is compared against an empty database, so every table reads // as added — the one case where a diff describes the whole database rather than a // change to it. func TestGetCommitDiffOnTheInitialCommit(t *testing.T) { var out commitDiffResult decode(t, call(t, anonSession(t), "get_commit_diff", args("notes", "hash", "c149")), &out) assert.Equal(t, "c149", out.Hash) require.Len(t, out.Tables, 2) for _, tbl := range out.Tables { assert.True(t, tbl.Added, "%s reads as added against the empty root", tbl.Name) assert.False(t, tbl.Dropped, "%s", tbl.Name) assert.Zero(t, tbl.RowsRemoved, "%s", tbl.Name) } byName := map[string]tableDiffResult{} for _, tbl := range out.Tables { byName[tbl.Name] = tbl } assert.EqualValues(t, notesRows, byName["notes"].RowsAdded) assert.EqualValues(t, 3, byName["tags"].RowsAdded) } // A branch name is accepted and means its head, and the answer carries the // resolved hash — which pins what was summarized even if the branch moves a // moment later. func TestGetCommitDiffResolvesABranchName(t *testing.T) { var out commitDiffResult decode(t, call(t, anonSession(t), "get_commit_diff", args("notes", "hash", "main")), &out) assert.Equal(t, "aaaa", out.Hash) require.Len(t, out.Tables, 1) assert.Equal(t, "notes", out.Tables[0].Name) assert.EqualValues(t, 2, out.Tables[0].RowsAdded) assert.EqualValues(t, 1, out.Tables[0].RowsModified) } func TestGetCommitDiffNeedsACommit(t *testing.T) { res := call(t, anonSession(t), "get_commit_diff", args("notes", "hash", "")) require.True(t, res.IsError) assert.Contains(t, errorText(res), "commit") } // --- the two kinds of miss -------------------------------------------------- // A ref that resolves to neither a branch nor a commit is an ordinary answer // about a database the caller is looking straight at: it names the ref, names // the database, and is not the masked refusal. Masking it instead would send an // agent off to re-resolve a database that is right there, over a typo. func TestAnUnresolvableRefIsAnAnswerAboutTheDatabase(t *testing.T) { server := newServer(t, newFakeRepos(), newFakeOpener()) for _, tc := range []struct { tool string args map[string]any }{ {"list_tables", args("notes", "ref", "nope")}, {"read_rows", args("notes", "table", "notes", "ref", "nope")}, {"get_commit_log", args("notes", "ref", "nope")}, } { t.Run(tc.tool, func(t *testing.T) { res := call(t, connect(t, server, nil), tc.tool, tc.args) require.True(t, res.IsError) text := errorText(res) assert.Contains(t, text, "nope") assert.Contains(t, text, "~alice/notes") assert.NotContains(t, text, "no database", "this is not the masked not-found") }) } } func TestAMissingTableIsAnAnswerAboutTheDatabase(t *testing.T) { res := call(t, anonSession(t), "read_rows", args("notes", "table", "ghosts")) require.True(t, res.IsError) text := errorText(res) assert.Contains(t, text, "ghosts") assert.Contains(t, text, "~alice/notes") assert.Contains(t, text, "main", "and says where it looked") assert.NotContains(t, text, "no database") } func TestAnUnknownCommitIsAnAnswerAboutTheDatabase(t *testing.T) { res := call(t, anonSession(t), "get_commit_diff", args("notes", "hash", "deadbeef")) require.True(t, res.IsError) text := errorText(res) assert.Contains(t, text, "deadbeef") assert.Contains(t, text, "~alice/notes") assert.NotContains(t, text, "no database") } // --- the visibility matrix -------------------------------------------------- // browseTarget is one fixture database with arguments valid for it, so that the // matrix below exercises every tool against every visibility rather than only // the ones that need no table or commit. type browseTarget struct { db string table string commit string } func browseTargets() []browseTarget { return []browseTarget{ {db: "notes", table: "notes", commit: "aaaa"}, // PUBLIC {db: "drafts", table: "drafts", commit: "dddd"}, // UNLISTED {db: "secrets", table: "secrets", commit: "eeee"}, // PRIVATE, bob is granted RO } } // browseCall is one tool and the arguments it needs for a given database, so // that a property worth holding for the whole chapter can be written once and // asserted for every tool of it. type browseCall struct { name string args func(browseTarget) map[string]any } // browseTools is every tool of docs/DESIGN.mcp.md §9.1 that addresses a single // database — which is all of them; list_databases addresses none and is the // other suite's. func browseTools() []browseCall { return []browseCall{ {"list_branches", func(tg browseTarget) map[string]any { return args(tg.db) }}, {"list_tables", func(tg browseTarget) map[string]any { return args(tg.db) }}, {"read_rows", func(tg browseTarget) map[string]any { return args(tg.db, "table", tg.table) }}, {"get_commit_log", func(tg browseTarget) map[string]any { return args(tg.db) }}, {"get_commit_diff", func(tg browseTarget) map[string]any { return args(tg.db, "hash", tg.commit) }}, } } // readable is the access matrix of core.Allowed for OpBrowse, spelled out // independently of the implementation under test: PUBLIC and UNLISTED are // readable by everyone including anonymity — unlisted hides from a *listing*, not // from a direct address — and PRIVATE only by its owner and its grantees. func readable(f fixture, viewer *core.Caller) bool { if f.visibility != core.VisibilityPrivate { return true } if viewer == nil { return false } if viewer.UserID == aliceID { return true // alice owns every fixture } _, granted := f.acl[viewer.UserID] return granted } func fixtureNamed(t *testing.T, name string) fixture { t.Helper() for _, f := range fixtures() { if f.name == name { return f } } t.Fatalf("no fixture named %q", name) return fixture{} } // Every tool, every visibility, every principal: either the tool answers, or the // database is reported as not existing. There is no third outcome and in // particular no "forbidden" — a refusal of its own shape is exactly the // distinction the masked not-found exists to erase (docs/DESIGN.mcp.md §4.3). // // The masked answer is compared against the answer for a database that genuinely // does not exist, with the name substituted: the two must differ by nothing but // the name the caller itself supplied. func TestTheVisibilityMatrix(t *testing.T) { server := newServer(t, newFakeRepos(), newFakeOpener()) callers := []struct { name string ac *auth.AuthContext }{ {"anonymous", nil}, {"a stranger", carol()}, {"a grantee", bob()}, {"the owner", alice()}, } for _, tool := range browseTools() { for _, tg := range browseTargets() { for _, who := range callers { t.Run(tool.name+"/"+tg.db+"/"+who.name, func(t *testing.T) { session := connect(t, server, who.ac) res := call(t, session, tool.name, tool.args(tg)) if readable(fixtureNamed(t, tg.db), coreCaller(who.ac)) { assert.False(t, res.IsError, "%s", errorText(res)) return } require.True(t, res.IsError, "a database this caller may not read must not answer") absent := tg absent.db = "nosuch" missing := call(t, session, tool.name, tool.args(absent)) require.True(t, missing.IsError) assert.Equal(t, strings.Replace(errorText(missing), "nosuch", tg.db, 1), errorText(res), "a masked database answers exactly as one that does not exist") }) } } } } // Nothing about a database the caller may not read leaks through the refusal — // not a branch head, not another table's name, not a row. The database's own // name is in the sentence because the caller put it there: that is the whole // point of building the sentence from the call's arguments (errors.go). func TestNothingAboutAMaskedDatabaseLeaks(t *testing.T) { server := newServer(t, newFakeRepos(), newFakeOpener()) for _, tool := range browseTools() { tg := browseTarget{db: "secrets", table: "secrets", commit: "eeee"} t.Run(tool.name, func(t *testing.T) { session := connect(t, server, carol()) body := resultJSON(t, call(t, session, tool.name, tool.args(tg))) assert.NotContains(t, body, "release", "not the branch it has") assert.NotContains(t, body, "keys", "not the other table it has") assert.NotContains(t, body, "secrets row", "and certainly not a row") }) } } // A grantee reads the private database whole: the matrix above proves the tools // answer, and this proves they answer with its actual contents rather than an // empty shell. func TestAGranteeReadsAPrivateDatabase(t *testing.T) { session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), bob()) got := readRows(t, session, args("secrets", "table", "secrets")) assert.Equal(t, "release", got.Ref) assert.Equal(t, 3, got.Total) require.Len(t, got.Rows, 3) assert.Equal(t, []string{"0", "secrets row 0"}, got.Rows[0]) } // --- addressing, sessions and failures -------------------------------------- // The sigil is what a link shows, so an agent copying an address is more likely // to include it than not. func TestABrowseToolToleratesTheSigil(t *testing.T) { var out listBranchesResult decode(t, call(t, anonSession(t), "list_branches", map[string]any{"owner": "~alice", "name": "notes"}), &out) assert.NotEmpty(t, out.Branches) } func TestAddressingNeedsBothParts(t *testing.T) { session := anonSession(t) for _, a := range []map[string]any{ {"owner": "", "name": "notes"}, {"owner": "alice", "name": ""}, } { res := call(t, session, "list_branches", a) require.True(t, res.IsError, "%v", a) assert.Contains(t, errorText(res), "owner") assert.Contains(t, errorText(res), "name") } } // One session per call, closed by the handler that opened it. A store held open // across calls is a stale manifest and a leaked handle, and a tool that opened a // database it was not asked about would be reading somebody else's. func TestEveryToolClosesTheSessionItOpened(t *testing.T) { for _, tool := range browseTools() { t.Run(tool.name, func(t *testing.T) { opener := newFakeOpener() session := connect(t, newServer(t, newFakeRepos(), opener), nil) call(t, session, tool.name, tool.args(browseTarget{db: "notes", table: "notes", commit: "aaaa"})) assert.Equal(t, []string{storePath("alice", "notes")}, opener.opened, "exactly the one database the call named") assert.Equal(t, 1, opener.sessions[storePath("alice", "notes")].closes) }) } } // A store that will not open is a protocol error, not a miss: the database // exists and the caller may read it, and answering "not found" would be a false // statement about the data instead of a true one about the server. func TestAStoreThatWillNotOpenIsAProtocolError(t *testing.T) { session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil) res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: "list_branches", Arguments: args("broken"), }) require.Error(t, err, "a store this daemon cannot read is not an answer about the data") assert.Nil(t, res) assert.NotContains(t, err.Error(), "/stores", "and the cause is logged, not sent") } // No raw browse error text ever reaches a caller: it carries dolt internals and // on-disk paths, and this endpoint is reachable by anyone holding any valid // token — or by nobody at all. func TestABrowseFailureDisclosesNoDetail(t *testing.T) { const detail = "read /srv/dolt/~alice/notes/manifest: input/output error" for _, tc := range []struct { tool string args map[string]any // fail breaks the one call this tool makes into the store. fail func(*fakeSession) }{ {"list_tables", args("notes"), func(s *fakeSession) { s.tablesErr = errors.New(detail) }}, {"read_rows", args("notes", "table", "notes"), func(s *fakeSession) { s.rowsErr = errors.New(detail) }}, {"get_commit_log", args("notes"), func(s *fakeSession) { s.logErr = errors.New(detail) }}, } { t.Run(tc.tool, func(t *testing.T) { opener := newFakeOpener() tc.fail(opener.sessions[storePath("alice", "notes")]) session := connect(t, newServer(t, newFakeRepos(), opener), nil) res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: tc.tool, Arguments: tc.args, }) require.Error(t, err, "a store that could not answer is not an answer") assert.Nil(t, res) assert.NotContains(t, err.Error(), "/srv/dolt") assert.NotContains(t, err.Error(), "input/output error") }) } } // An ACL lookup that fails is a protocol error and not a masked not-found. The // web degrades a failed lookup to "no grant" and falls back to visibility, which // costs a signed-in user a rendering; here it would tell an agent that a database // it was granted access to does not exist, and an agent believes that. func TestAnACLLookupFailureIsNotAMissingDatabase(t *testing.T) { repos := &failingACLRepos{fakeRepos: newFakeRepos()} session := connect(t, newServer(t, repos, newFakeOpener()), bob()) res, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: "list_branches", Arguments: args("secrets"), }) require.Error(t, err, `"I could not check" is not "you may not"`) assert.Nil(t, res) assert.NotContains(t, err.Error(), "5432", "the cause is logged, not sent") } // failingACLRepos is the metadata store with a working repository lookup and a // broken ACL one, which is the shape of a partial database outage. type failingACLRepos struct { *fakeRepos } var _ mcpsrv.Repos = (*failingACLRepos)(nil) func (f *failingACLRepos) EffectiveAccess(context.Context, int, int) (*core.AccessMode, error) { return nil, errors.New("dial tcp 127.0.0.1:5432: connection refused") }