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/<name> 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")
}