A mcpsrv/browse.go => mcpsrv/browse.go +753 -0
@@ 0,0 1,753 @@
+package mcpsrv
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// The generic tools of docs/DESIGN.mcp.md §9.1: the browse layer (browse/)
+// offered as tools, for any hosted database rather than a beads tracker.
+//
+// They live beside read.go rather than in it because §9.1 and §9.2 are two
+// chapters with two file sets — read.go keeps list_databases, the entry point
+// whose answers every tool here takes its arguments from, and the beads tools of
+// §9.2 arrive in a file of their own. One file per chapter is what keeps each of
+// them readable; the registration of the whole surface still happens in one
+// place (register, read.go).
+//
+// The rules read.go states once hold here too — the {owner, name} address,
+// visibility that is applied rather than re-derived, an array for every list —
+// and these are the ones this chapter adds:
+//
+// - Every tool resolves the database exactly as the browse handlers do
+// (resolveDatabase): the row, the caller's ACL grant, core.Allowed. A
+// database the caller may not read is *not found*, the same sentence a name
+// nobody took gets, because a distinguishable refusal is what the 404 exists
+// to erase.
+// - A ref that does not resolve and a table that does not exist are the other
+// kind of miss entirely: they are ordinary answers *about a database the
+// caller can see*, and they name what was not found. Confusing the two would
+// either leak the existence of a private database or hide a typo behind a
+// sentence about a database the agent is looking straight at.
+// - A cap is applied and *stated*. A page that stops short says so and carries
+// the number it stopped short of, because a caller that cannot see the table
+// has no other way to tell a full answer from a clipped one (§9.3).
+// - One session per call, closed by the handler that opened it. That is the
+// browse discipline: a fresh read of the on-disk manifest, so a push that
+// landed a second ago is visible, and no handle outlives the call.
+
+// The caps of docs/DESIGN.mcp.md §9.3.
+//
+// A default exists so that an agent that names no limit gets a page rather than
+// a table; a maximum exists because this surface answers in one response and a
+// caller can always page. Neither is silent: the answer reports the limit that
+// was applied and whether anything was left behind.
+const (
+ defaultRowLimit = 100
+ maxRowLimit = 500
+
+ defaultCommitLimit = 25
+ maxCommitLimit = 100
+)
+
+// databaseRef is how every tool of this chapter addresses a database: the two
+// fields of the URL, both without the "~", exactly as list_databases answers
+// them.
+//
+// It is embedded in each tool's input struct, so the derived schema carries
+// owner and name as two required properties of that tool rather than as a nested
+// object — an agent copying an address out of a link fills in two strings, and
+// does not have to learn a wrapper type first.
+type databaseRef struct {
+ Owner string `json:"owner" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted)"`
+ Name string `json:"name" jsonschema:"the database name, as list_databases reports it"`
+}
+
+// owner is the username with the sigil and any stray whitespace removed. A
+// leading "~" is tolerated rather than refused for read.go's reason: it is what
+// a link shows, so an agent copying an address is more likely to include it than
+// not.
+func (r databaseRef) owner() string { return strings.TrimPrefix(strings.TrimSpace(r.Owner), "~") }
+
+func (r databaseRef) name() string { return strings.TrimSpace(r.Name) }
+
+// String renders the address the way a link does, which is how every sentence a
+// caller reads names the database it is about.
+func (r databaseRef) String() string { return "~" + r.owner() + "/" + r.name() }
+
+// --- the shapes a caller decodes -------------------------------------------
+
+// branchJSON is one branch and the head commit it points at.
+type branchJSON struct {
+ Name string `json:"name"`
+ Head string `json:"head"`
+}
+
+type listBranchesInput struct {
+ databaseRef
+}
+
+type listBranchesOutput struct {
+ Branches []branchJSON `json:"branches"`
+
+ // DefaultBranch is browse.DefaultBranch's answer over the list above, and it
+ // is reported rather than left for the agent to work out: it is the ref every
+ // tool here falls back to, so a caller that wants to name the same one
+ // explicitly should not have to reimplement the rule. It is "" for a database
+ // with no branches at all.
+ DefaultBranch string `json:"default_branch"`
+}
+
+// columnJSON is one column of a table's schema.
+type columnJSON struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ PrimaryKey bool `json:"primary_key"`
+ Nullable bool `json:"nullable"`
+}
+
+// tableJSON is one table at a ref: its schema and its exact row count.
+type tableJSON struct {
+ Name string `json:"name"`
+ Columns []columnJSON `json:"columns"`
+ RowCount uint64 `json:"row_count"`
+}
+
+type listTablesInput struct {
+ databaseRef
+ Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`
+}
+
+type listTablesOutput struct {
+ // Ref is the ref actually read, which is the default branch when the call
+ // named none. An agent that omitted it learns from the answer which branch it
+ // is looking at, and can name that one for the rest of a series of calls.
+ Ref string `json:"ref"`
+ Tables []tableJSON `json:"tables"`
+}
+
+type readRowsInput struct {
+ databaseRef
+ Table string `json:"table" jsonschema:"the table to read, as list_tables names it"`
+ Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`
+
+ // Offset and Limit are pointers so that an omitted argument and an explicit
+ // zero are two different calls. An omitted limit is "you choose" and gets the
+ // default; a limit of 0 was typed by the caller, means "no rows at all", and
+ // is refused rather than quietly turned into 100 — a page nobody asked for
+ // reads as an empty table, which is the same class of lie as a silent
+ // truncation.
+ Offset *int `json:"offset,omitempty" jsonschema:"how many rows to skip, zero or more; defaults to 0"`
+ Limit *int `json:"limit,omitempty" jsonschema:"how many rows to return, at most 500; defaults to 100"`
+}
+
+type readRowsOutput struct {
+ Ref string `json:"ref"`
+ Table string `json:"table"`
+
+ // Columns names the cells of every row, in order: for a keyed table the
+ // primary-key columns first, then the rest.
+ Columns []string `json:"columns"`
+
+ // Rows are rendered strings and not typed values: this surface reads a bare
+ // store without a SQL engine, so what it has is the stored value formatted.
+ // A NULL cell reads as "NULL" and an unprintable one as "<binary>", neither
+ // of which is distinguishable from a row that stores those very strings —
+ // which is a limit worth knowing rather than a bug to work around.
+ Rows [][]string `json:"rows"`
+
+ Offset int `json:"offset"`
+
+ // Limit is the limit that was applied, which is not always the one asked for:
+ // a request above the cap is answered at the cap, and saying so here is what
+ // keeps that from being a silent clamp.
+ Limit int `json:"limit"`
+
+ // Total is the number of rows in the whole table at this ref — the honest
+ // denominator of the page above, straight from browse.RowPage.
+ Total int `json:"total"`
+
+ // Truncated reports that rows remain after this page (docs/DESIGN.mcp.md
+ // §9.3). Raise offset to read them.
+ Truncated bool `json:"truncated"`
+}
+
+// commitJSON is one commit of a log listing.
+//
+// The author's email is deliberately absent: the browse UI publishes the name
+// and not the address (web/templates/log.html), and a surface reachable by any
+// token has no business publishing more of a person than the page does.
+type commitJSON struct {
+ Hash string `json:"hash"`
+ Author string `json:"author"`
+ Date time.Time `json:"date"`
+ Message string `json:"message"`
+ Parents []string `json:"parents"`
+}
+
+type getCommitLogInput struct {
+ databaseRef
+ Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to start from; omit it for the database's default branch"`
+ From string `json:"from,omitempty" jsonschema:"the \"next\" hash of a previous page, to continue where it stopped; it takes the place of ref"`
+ Limit *int `json:"limit,omitempty" jsonschema:"how many commits to return, at most 100; defaults to 25"`
+}
+
+type getCommitLogOutput struct {
+ // Ref is the ref this page was read at, and is empty when the call continued
+ // a cursor: a from-hash is a point in the graph and claiming it belongs to
+ // some branch would be a claim this service did not check.
+ Ref string `json:"ref"`
+ Commits []commitJSON `json:"commits"`
+
+ // Next is the hash to pass as from for the following page, or "" at the end
+ // of the history.
+ Next string `json:"next"`
+
+ // Limit is the limit applied, as in readRowsOutput.
+ Limit int `json:"limit"`
+
+ // Truncated reports that the history goes on past this page.
+ //
+ // There is no total beside it, and that is not an omission: a commit count
+ // means walking the whole graph, browse/ offers no such call, and a number
+ // that expensive would be paid for by every caller of a paging tool. Next is
+ // the honest continuation here — it is present exactly when something was
+ // left behind.
+ Truncated bool `json:"truncated"`
+}
+
+// tableDiffJSON is how one table changed in a commit.
+type tableDiffJSON struct {
+ Name string `json:"name"`
+ Added bool `json:"added"`
+ Dropped bool `json:"dropped"`
+ SchemaChanged bool `json:"schema_changed"`
+
+ // The row counts are exact, with one documented exception browse/ owns: when
+ // the primary key set changed there is no row-level correspondence to count
+ // across, so the three are 0 and SchemaChanged is true.
+ RowsAdded int64 `json:"rows_added"`
+ RowsRemoved int64 `json:"rows_removed"`
+ RowsModified int64 `json:"rows_modified"`
+}
+
+type getCommitDiffInput struct {
+ databaseRef
+ Hash string `json:"hash" jsonschema:"the commit to summarize; a branch name is accepted and means that branch's head"`
+}
+
+type getCommitDiffOutput struct {
+ // Hash is the resolved commit hash, which is worth carrying back when the
+ // call named a branch: it pins which commit the answer is about even if the
+ // branch moves a moment later.
+ Hash string `json:"hash"`
+ Tables []tableDiffJSON `json:"tables"`
+}
+
+// --- registration -----------------------------------------------------------
+
+// registerBrowseTools installs the tools of docs/DESIGN.mcp.md §9.1.
+//
+// The descriptions are the agent-facing documentation of this surface: what the
+// tool answers, how to address it, what it costs, and — for every one of them —
+// what it cannot do, since an agent that knows there is no SQL here stops
+// looking for it.
+func (s *Server) registerBrowseTools() {
+ mcp.AddTool(s.mcp, &mcp.Tool{
+ Name: "list_branches",
+ Annotations: readOnlyTool,
+ Description: "List the branches of one hosted Dolt database, each with the hash of its head commit.\n\n" +
+ "Address the database by the `owner` and `name` that list_databases reports (the two parts of " +
+ "its URL, without the \"~\").\n\n" +
+ "`default_branch` is the branch every other tool reads when you pass no `ref`: \"main\" when it " +
+ "exists, otherwise the first branch by name. A database nothing has been pushed to yet has no " +
+ "branches, and answers an empty list with an empty default.\n\n" +
+ "A database you may not read is reported as not existing, which is the same answer a name " +
+ "nobody took gets.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, listBranchesOutput, error) {
+ out, err := s.listBranches(ctx, in)
+ return nil, out, err
+ })
+
+ mcp.AddTool(s.mcp, &mcp.Tool{
+ Name: "list_tables",
+ Annotations: readOnlyTool,
+ Description: "List the tables of one hosted Dolt database at one ref, each with its columns — name, SQL " +
+ "type, whether it is part of the primary key, whether it is nullable — and its exact row count.\n\n" +
+ "`ref` is a branch name or a commit hash; omit it to read the database's default branch, which " +
+ "the answer names back to you. A ref that matches neither is reported as such, and says so " +
+ "about that database rather than pretending the database is missing.\n\n" +
+ "This is the schema of the data, not a query interface: there is no SQL on this surface and " +
+ "there will not be one. Read a table with read_rows and project or aggregate it yourself.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in listTablesInput) (*mcp.CallToolResult, listTablesOutput, error) {
+ out, err := s.listTables(ctx, in)
+ return nil, out, err
+ })
+
+ mcp.AddTool(s.mcp, &mcp.Tool{
+ Name: "read_rows",
+ Annotations: readOnlyTool,
+ Description: "Read one page of rows from one table of a hosted Dolt database.\n\n" +
+ "`columns` names the cells of every row in order (primary-key columns first for a keyed " +
+ "table). Cells are rendered strings, because this surface reads a bare store without a SQL " +
+ "engine: a NULL cell reads as `NULL` and an unprintable one as `<binary>`, neither " +
+ "distinguishable from a row that stores those strings literally.\n\n" +
+ "`limit` defaults to 100 and is capped at 500; the `limit` in the answer is the one actually " +
+ "applied, so a larger request is visibly answered at the cap. `total` is the number of rows " +
+ "in the whole table, and `truncated` says rows remain after this page — read them by raising " +
+ "`offset`, which is O(1) here regardless of how far in it points.\n\n" +
+ "Rows come back in the table's own key order and cannot be filtered or sorted server-side. " +
+ "For a beads tracker, prefer the beads tools: they answer the questions this table would " +
+ "make you assemble by hand.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in readRowsInput) (*mcp.CallToolResult, readRowsOutput, error) {
+ out, err := s.readRows(ctx, in)
+ return nil, out, err
+ })
+
+ mcp.AddTool(s.mcp, &mcp.Tool{
+ Name: "get_commit_log",
+ Annotations: readOnlyTool,
+ Description: "Read the commit history of a hosted Dolt database, newest first: hash, author, date, " +
+ "message and parent hashes.\n\n" +
+ "`ref` is a branch name or a commit hash to start from; omit it for the default branch. " +
+ "`limit` defaults to 25 and is capped at 100.\n\n" +
+ "When the history goes on past the page, `truncated` is true and `next` carries the hash of " +
+ "the commit that follows it: pass that as `from` for the next page. `from` takes the place of " +
+ "`ref`, so a continued page reports no ref. There is no commit total — counting one would " +
+ "mean walking the entire graph — so `next` is how you tell a full answer from a clipped one.\n\n" +
+ "A merge commit lists every parent; the history is walked in reverse topological order.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitLogInput) (*mcp.CallToolResult, getCommitLogOutput, error) {
+ out, err := s.getCommitLog(ctx, in)
+ return nil, out, err
+ })
+
+ mcp.AddTool(s.mcp, &mcp.Tool{
+ Name: "get_commit_diff",
+ Annotations: readOnlyTool,
+ Description: "Summarize what one commit changed, table by table, against its first parent — and for the " +
+ "initial commit against an empty database, where every table reads as added.\n\n" +
+ "`hash` is a commit hash; a branch name is accepted and means that branch's head. Each entry " +
+ "says whether the table was added or dropped, whether its schema changed, and how many rows " +
+ "were added, removed and modified.\n\n" +
+ "The row counts are exact, with one exception: when the primary key set changed there is no " +
+ "row-level correspondence to count across, so `schema_changed` is true and the three counts " +
+ "are 0.\n\n" +
+ "This is a summary and never a row-level diff — no cell values are reported. To see what the " +
+ "rows became, read the table at this commit with read_rows, passing the hash as `ref`.",
+ }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitDiffInput) (*mcp.CallToolResult, getCommitDiffOutput, error) {
+ out, err := s.getCommitDiff(ctx, in)
+ return nil, out, err
+ })
+}
+
+// --- the handlers -----------------------------------------------------------
+
+// listBranches answers list_branches: every branch of one database with its
+// head, plus the default the other tools fall back to.
+func (s *Server) listBranches(ctx context.Context, in listBranchesInput) (listBranchesOutput, error) {
+ const tool = "list_branches"
+ var out listBranchesOutput
+
+ repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
+ if err != nil {
+ return out, err
+ }
+ sess, err := s.openStore(ctx, tool, repo)
+ if err != nil {
+ return out, err
+ }
+ defer sess.Close()
+
+ branches, err := sess.Branches(ctx)
+ if err != nil {
+ // Branches resolves no ref and names no table, so there is no miss it can
+ // report: whatever went wrong here is this service's.
+ return out, internalError(err, tool)
+ }
+
+ out.Branches = make([]branchJSON, 0, len(branches))
+ for _, b := range branches {
+ out.Branches = append(out.Branches, branchJSON{Name: b.Name, Head: b.Head})
+ }
+ out.DefaultBranch = browse.DefaultBranch(branches)
+ return out, nil
+}
+
+// listTables answers list_tables: the schema of a database at one ref.
+func (s *Server) listTables(ctx context.Context, in listTablesInput) (listTablesOutput, error) {
+ const tool = "list_tables"
+ var out listTablesOutput
+
+ repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
+ if err != nil {
+ return out, err
+ }
+ sess, err := s.openStore(ctx, tool, repo)
+ if err != nil {
+ return out, err
+ }
+ defer sess.Close()
+
+ ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
+ if err != nil {
+ return out, err
+ }
+
+ tables, err := sess.Tables(ctx, ref)
+ if err != nil {
+ return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
+ }
+
+ out.Ref = ref
+ out.Tables = make([]tableJSON, 0, len(tables))
+ for _, t := range tables {
+ cols := make([]columnJSON, 0, len(t.Columns))
+ for _, c := range t.Columns {
+ cols = append(cols, columnJSON{
+ Name: c.Name,
+ Type: c.Type,
+ PrimaryKey: c.PrimaryKey,
+ Nullable: c.Nullable,
+ })
+ }
+ out.Tables = append(out.Tables, tableJSON{Name: t.Name, Columns: cols, RowCount: t.RowCount})
+ }
+ return out, nil
+}
+
+// readRows answers read_rows: one page of a table, with the denominator that
+// makes the page readable as a page.
+func (s *Server) readRows(ctx context.Context, in readRowsInput) (readRowsOutput, error) {
+ const tool = "read_rows"
+ var out readRowsOutput
+
+ table := strings.TrimSpace(in.Table)
+ if table == "" {
+ return out, errors.New("name the table to read; list_tables names the tables of a database")
+ }
+ offset, err := pageOffset(in.Offset)
+ if err != nil {
+ return out, err
+ }
+ limit, err := pageLimit(in.Limit, defaultRowLimit, maxRowLimit, "rows")
+ if err != nil {
+ return out, err
+ }
+
+ repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
+ if err != nil {
+ return out, err
+ }
+ sess, err := s.openStore(ctx, tool, repo)
+ if err != nil {
+ return out, err
+ }
+ defer sess.Close()
+
+ ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
+ if err != nil {
+ return out, err
+ }
+
+ page, err := sess.Rows(ctx, ref, table, offset, limit)
+ if err != nil {
+ // Two misses about a database the caller can see, and the table arm is
+ // asked first because it is the more specific of the two.
+ if errors.Is(err, browse.ErrTableNotFound) {
+ return out, errors.New(noSuchTable(in.databaseRef, ref, table))
+ }
+ return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
+ }
+
+ out = readRowsOutput{
+ Ref: ref,
+ Table: table,
+ Columns: page.Columns,
+ Rows: page.Rows,
+ Offset: page.Offset,
+ Limit: limit,
+ Total: page.Total,
+ // The page stopped short exactly when something is left after it. Reading
+ // it off the page's own offset and length, rather than off "did I get
+ // limit rows back", is what keeps this honest when browse returns a short
+ // page for a reason of its own.
+ Truncated: page.Offset+len(page.Rows) < page.Total,
+ }
+ if out.Columns == nil {
+ out.Columns = []string{}
+ }
+ if out.Rows == nil {
+ out.Rows = [][]string{}
+ }
+ return out, nil
+}
+
+// getCommitLog answers get_commit_log: a page of history with the cursor that
+// continues it.
+func (s *Server) getCommitLog(ctx context.Context, in getCommitLogInput) (getCommitLogOutput, error) {
+ const tool = "get_commit_log"
+ var out getCommitLogOutput
+
+ limit, err := pageLimit(in.Limit, defaultCommitLimit, maxCommitLimit, "commits")
+ if err != nil {
+ return out, err
+ }
+ from := strings.TrimSpace(in.From)
+
+ repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
+ if err != nil {
+ return out, err
+ }
+ sess, err := s.openStore(ctx, tool, repo)
+ if err != nil {
+ return out, err
+ }
+ defer sess.Close()
+
+ // A cursor is a point in the graph and browse.Log starts there, ignoring the
+ // ref entirely. Resolving a default branch anyway would cost a read and buy a
+ // ref the answer could not honestly claim the page belongs to.
+ ref := ""
+ if from == "" {
+ if ref, err = refFor(ctx, sess, tool, in.databaseRef, in.Ref); err != nil {
+ return out, err
+ }
+ }
+
+ commits, next, err := sess.Log(ctx, ref, from, limit)
+ if err != nil {
+ // browse classifies an unparseable from-hash as a fault of its own rather
+ // than as a miss (it has no sentinel for it), so a hand-written cursor
+ // takes the protocol arm here. A cursor comes from a previous page of this
+ // very tool, and teaching this package to parse a dolt hash would be a
+ // second reading of a format browse/ owns.
+ return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
+ }
+
+ out.Ref = ref
+ out.Limit = limit
+ out.Next = next
+ out.Truncated = next != ""
+ out.Commits = make([]commitJSON, 0, len(commits))
+ for _, c := range commits {
+ parents := c.ParentHashes
+ if parents == nil {
+ parents = []string{}
+ }
+ out.Commits = append(out.Commits, commitJSON{
+ Hash: c.Hash,
+ Author: c.Author,
+ Date: c.Date,
+ Message: c.Message,
+ Parents: parents,
+ })
+ }
+ return out, nil
+}
+
+// getCommitDiff answers get_commit_diff: the per-table summary browse computes
+// for one commit against its first parent.
+func (s *Server) getCommitDiff(ctx context.Context, in getCommitDiffInput) (getCommitDiffOutput, error) {
+ const tool = "get_commit_diff"
+ var out getCommitDiffOutput
+
+ hash := strings.TrimSpace(in.Hash)
+ if hash == "" {
+ return out, errors.New("name the commit to summarize; get_commit_log lists the hashes of a database")
+ }
+
+ repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
+ if err != nil {
+ return out, err
+ }
+ sess, err := s.openStore(ctx, tool, repo)
+ if err != nil {
+ return out, err
+ }
+ defer sess.Close()
+
+ // No default here: a diff is about one named commit, and defaulting to the
+ // head would answer a question the caller did not ask.
+ diff, err := sess.CommitSummary(ctx, hash)
+ if err != nil {
+ return out, refMiss(err, tool, noSuchCommit(in.databaseRef, hash))
+ }
+
+ out.Hash = diff.Hash
+ out.Tables = make([]tableDiffJSON, 0, len(diff.Tables))
+ for _, t := range diff.Tables {
+ out.Tables = append(out.Tables, tableDiffJSON{
+ Name: t.Name,
+ Added: t.Added,
+ Dropped: t.Dropped,
+ SchemaChanged: t.SchemaChanged,
+ RowsAdded: t.RowsAdded,
+ RowsRemoved: t.RowsRemoved,
+ RowsModified: t.RowsModified,
+ })
+ }
+ return out, nil
+}
+
+// --- resolving, opening, capping --------------------------------------------
+
+// resolveDatabase turns an address into a repository the caller is allowed to
+// read, or into the one refusal this surface has.
+//
+// It is the browse handlers' dance call for call (web/router.go's
+// loadRepoForBrowse, docs/DESIGN.mcp.md §4.3): the row, the caller's ACL grant,
+// core.Allowed for OpBrowse. Two differences from the web, both deliberate:
+//
+// - There is no forbidden arm. The web tells a caller who may see that a
+// database exists but may not read it apart from one who may not learn it
+// exists at all (core.NotFoundForPrivate); here both are the same sentence,
+// so the distinction cannot be read out of a pair of answers. That is
+// stricter than the web and never looser, so nothing becomes visible that
+// was not.
+// - An ACL lookup that fails is a protocol error, where the web degrades to
+// "no grant" and falls back to visibility. On a page that degradation 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 and
+// rewrites its plan. "I could not check" is not "you may not".
+func (s *Server) resolveDatabase(ctx context.Context, tool string, ref databaseRef) (*core.Repo, error) {
+ if ref.owner() == "" || ref.name() == "" {
+ return nil, errors.New("address a database by both its owner and its name, as list_databases reports them")
+ }
+
+ caller := callerOf(ctx)
+ missing := noSuchDatabase(ref)
+
+ repo, err := s.repos.GetRepoByOwnerAndName(ctx, ref.owner(), ref.name())
+ if err != nil {
+ return nil, missingOrDenied(err, tool, missing)
+ }
+
+ // An anonymous caller holds no ACL entry and there is no user id to look one
+ // up by; visibility decides alone, which is what core.Allowed does with a nil
+ // grant.
+ var mode *core.AccessMode
+ if caller != nil {
+ if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil {
+ return nil, internalError(err, tool)
+ }
+ }
+ if !core.Allowed(caller, repo, mode, core.OpBrowse) {
+ return nil, errors.New(missing)
+ }
+ return repo, nil
+}
+
+// openStore opens the bare store of a database the caller may read. The handler
+// that calls it closes the session (defer sess.Close()).
+//
+// A store that will not open is a protocol error and not a miss: the database
+// exists, the caller may read it, and this service could not. list_databases
+// answers differently — it degrades that one entry and keeps listing, because a
+// listing is about a set — but a tool whose whole subject is this database has
+// nothing to answer with, and "not found" would be a false statement about the
+// data rather than a true one about the server.
+func (s *Server) openStore(ctx context.Context, tool string, repo *core.Repo) (BrowseSession, error) {
+ sess, err := s.opener.Open(ctx, repo.Path)
+ if err != nil {
+ return nil, internalError(fmt.Errorf("opening the store of %s/%s: %w", repo.OwnerName, repo.Name, err), tool)
+ }
+ return sess, nil
+}
+
+// refFor answers which ref a call reads at: the one it named, or the
+// repository's default branch when it named none (docs/DESIGN.mcp.md §9.1), so
+// that an agent which does not care about branches never has to name one.
+//
+// A named ref is passed through unchecked, because browse resolves a branch name
+// *or* a commit hash and this package has no business deciding which of the two
+// a string is. One that resolves to neither comes back as a miss from the call
+// that used it.
+//
+// A database with no branches at all has no default to fall back to. That is an
+// answer and not a failure — nothing has been pushed to it yet, exactly the
+// database list_databases reports with a null content — so it is a sentence the
+// agent reads rather than an error the client raises.
+func refFor(ctx context.Context, sess BrowseSession, tool string, ref databaseRef, named string) (string, error) {
+ if named = strings.TrimSpace(named); named != "" {
+ return named, nil
+ }
+
+ branches, err := sess.Branches(ctx)
+ if err != nil {
+ return "", internalError(err, tool)
+ }
+ name := browse.DefaultBranch(branches)
+ if name == "" {
+ return "", errors.New(ref.String() + " has no branches: nothing has been pushed to it yet, so there is nothing to read")
+ }
+ return name, nil
+}
+
+// pageOffset validates the offset of a paging tool: absent is 0, and a negative
+// one is refused rather than clamped. A caller that computed -3 has a bug, and
+// answering its first page would hide it.
+func pageOffset(want *int) (int, error) {
+ if want == nil {
+ return 0, nil
+ }
+ if *want < 0 {
+ return 0, fmt.Errorf("offset must be zero or more, not %d", *want)
+ }
+ return *want, nil
+}
+
+// pageLimit applies a cap of docs/DESIGN.mcp.md §9.3: absent takes the default,
+// more than the maximum is answered at the maximum, and zero or less is refused.
+//
+// Clamping and refusing are not inconsistent. A caller asking for 5000 rows
+// wants as many as it can have, and gets them with the applied limit stated in
+// the answer, so nothing is hidden. A caller asking for 0 or -1 wants something
+// that does not exist, and quietly handing it a default page would make the
+// answer a statement about a request nobody made.
+func pageLimit(want *int, def, max int, noun string) (int, error) {
+ if want == nil {
+ return def, nil
+ }
+ if *want <= 0 {
+ return 0, fmt.Errorf("limit must be a positive number of %s, not %d", noun, *want)
+ }
+ if *want > max {
+ return max, nil
+ }
+ return *want, nil
+}
+
+// --- the sentences a caller reads -------------------------------------------
+//
+// Every one of them is built from the arguments of the call being answered and
+// never from an error's own text, which is errors.go's rule: browse names
+// on-disk paths and dolt internals, and db/ names what it looked up.
+
+// noSuchDatabase is the masked not-found: one sentence for a database that does
+// not exist and for one this caller may not read, so that the two cannot be told
+// apart by an agent probing names.
+func noSuchDatabase(ref databaseRef) string {
+ return "no database " + ref.String() + ": it does not exist, or the credential this call carries does not reach it"
+}
+
+// noSuchRef is an ordinary answer about a database the caller *can* see, and
+// says so by naming it — the opposite of the masked sentence above.
+func noSuchRef(ref databaseRef, named string) string {
+ return fmt.Sprintf("%s has no branch or commit %q; list_branches names its branches", ref, named)
+}
+
+func noSuchTable(ref databaseRef, at, table string) string {
+ return fmt.Sprintf("%s has no table %q at %q; list_tables names the tables there", ref, table, at)
+}
+
+func noSuchCommit(ref databaseRef, hash string) string {
+ return fmt.Sprintf("%s has no commit %q; get_commit_log lists its history", ref, hash)
+}
A mcpsrv/browse_test.go => mcpsrv/browse_test.go +759 -0
@@ 0,0 1,759 @@
+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")
+}
M mcpsrv/errors.go => mcpsrv/errors.go +22 -0
@@ 7,6 7,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"go.bigb.es/auxilia/scribe"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
@@ 71,6 72,27 @@ func missingOrDenied(err error, where, missing string) error {
return internalError(err, where)
}
+// refMiss is the *other* kind of miss, and the reason it is spelled out beside
+// missingOrDenied is that the two must never be confused.
+//
+// missingOrDenied answers about a database the caller may not learn anything
+// about, so its sentence says nothing. refMiss answers about a database the
+// caller has already been allowed to read, where a ref that resolves to neither
+// a branch nor a commit — or, for the tools that name one, a table that is not
+// there — is an ordinary fact about that database. Naming it is not a leak: the
+// caller is looking straight at it. Masking it instead would send an agent off
+// to re-resolve a database that is right there, over a typo in a branch name.
+//
+// Anything that is not browse's miss takes the protocol arm, for the reason the
+// default always does here: an unmapped failure is a bug in a layer below, and
+// reporting it as a fact about the data would teach the agent something untrue.
+func refMiss(err error, where, missing string) error {
+ if errors.Is(err, browse.ErrRefNotFound) {
+ return errors.New(missing)
+ }
+ return internalError(err, where)
+}
+
// internalError logs the cause and returns the protocol error the client sees.
//
// The error goes through scribe.Err, which expands a culpa chain into err.msg,
M mcpsrv/mcpsrv_test.go => mcpsrv/mcpsrv_test.go +272 -41
@@ 5,6 5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strconv"
"testing"
"time"
@@ 61,24 62,18 @@ type fixture struct {
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: &fakeSession{
- branches: []browse.Branch{{Name: "main", Head: "aaaa"}},
- commits: []browse.CommitInfo{{Hash: "aaaa", Date: headTime}},
- tables: []browse.TableInfo{{Name: "notes", Columns: []browse.ColumnInfo{{Name: "id"}}}},
- },
+ 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: &fakeSession{
- branches: []browse.Branch{{Name: "main", Head: "bbbb"}},
- commits: []browse.CommitInfo{{Hash: "bbbb", Date: headTime}},
- tables: beadsTables(),
- },
+ session: smallStore("main", "bbbb", beadsTables()),
},
{
// A store created by a repository that has never been pushed to.
@@ 97,21 92,13 @@ func fixtures() []fixture {
{
name: "drafts",
visibility: core.VisibilityUnlisted,
- session: &fakeSession{
- branches: []browse.Branch{{Name: "main", Head: "dddd"}},
- commits: []browse.CommitInfo{{Hash: "dddd", Date: headTime}},
- tables: []browse.TableInfo{{Name: "drafts"}},
- },
+ session: smallStore("main", "dddd", []fakeTable{newTable("drafts", 3)}),
},
{
name: "secrets",
visibility: core.VisibilityPrivate,
acl: map[int]core.AccessMode{bobID: core.AccessRO},
- session: &fakeSession{
- branches: []browse.Branch{{Name: "release", Head: "eeee"}},
- commits: []browse.CommitInfo{{Hash: "eeee", Date: headTime}},
- tables: []browse.TableInfo{{Name: "secrets"}},
- },
+ session: smallStore("release", "eeee", []fakeTable{newTable("secrets", 3), newTable("keys", 1)}),
},
}
}
@@ 120,13 107,146 @@ func fixtures() []fixture {
// 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() []browse.TableInfo {
- return []browse.TableInfo{
- {Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}, {Name: "title"}}},
- {Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "from_id"}, {Name: "to_id"}}},
+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"}},
+ },
}
}
@@ 287,17 407,24 @@ func (o *fakeOpener) Open(_ context.Context, diskPath string) (mcpsrv.BrowseSess
return sess, nil
}
-// fakeSession is one bare store as browse/ reads it. Only the three methods
-// list_databases calls answer; the rest are the seam's, waiting for the tools of
-// ch. 9.1, and a handler that reached one here would fail loudly rather than
-// read an empty result.
+// 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
- tables []browse.TableInfo
+ 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
}
@@ 306,30 433,102 @@ var _ mcpsrv.BrowseSession = (*fakeSession)(nil)
func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }
-func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
+// 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
}
- return s.commits, "", nil
+ 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, string) ([]browse.TableInfo, error) {
+func (s *fakeSession) Tables(_ context.Context, refStr string) ([]browse.TableInfo, error) {
if s.tablesErr != nil {
return nil, s.tablesErr
}
- return s.tables, nil
+ 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, string, string, int, int) (*browse.RowPage, error) {
- panic("Rows: no tool of this phase reads rows")
+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, string) (*browse.CommitDiff, error) {
- panic("CommitSummary: no tool of this phase reads a diff")
+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 {
@@ 337,6 536,29 @@ func (s *fakeSession) Close() error {
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
@@ 528,7 750,7 @@ func TestNewRequiresAnOriginToGuardWith(t *testing.T) {
// --- the tool ---------------------------------------------------------------
-func TestServerAdvertisesTheToolsOfThisPhase(t *testing.T) {
+func TestServerAdvertisesTheToolsOfTheseChapters(t *testing.T) {
session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)
require.Equal(t, mcpsrv.ServerName, session.InitializeResult().ServerInfo.Name)
@@ 544,8 766,17 @@ func TestServerAdvertisesTheToolsOfThisPhase(t *testing.T) {
assert.True(t, tool.Annotations.ReadOnlyHint, "%s", tool.Name)
require.NotNil(t, tool.InputSchema, "%s: the schema is derived from the Go struct", tool.Name)
}
- assert.Equal(t, []string{"list_databases"}, got,
- "this phase registers exactly one tool; the rest arrive with their own commits")
+ // 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) {
M mcpsrv/read.go => mcpsrv/read.go +13 -5
@@ 119,21 119,29 @@ type listDatabasesOutput struct {
// the agent's: nothing an agent can do about a broken store differs by cause.
const contentUnreadable = "this database's store could not be read on the server; its metadata below is still accurate"
+// readOnlyTool is the annotation every tool of this surface carries, and it is
+// one shared value rather than one per registration so that "every tool here is
+// a read" is a property of the package instead of a habit five call sites are
+// keeping up. It tells a client it may retry a call freely; this surface has no
+// write tool at all, and ports.go is why it cannot grow one by accident.
+var readOnlyTool = &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}
+
// register installs the tools on the protocol server.
//
-// Every one is annotated read-only and idempotent, which is true of all of them
-// and is what tells a client it may retry a call freely: this surface has no
-// write tool at all, and ports.go is why it cannot grow one by accident.
+// The whole surface is registered from this one function — the chapters live in
+// their own files, but what an agent is offered is listed in a single place, so
+// that reading it answers "what can be called here" completely.
//
// The descriptions are the agent-facing documentation of this service and are
// written for a reader who has never seen the design document — what the tool
// answers, how to address what it answers about, and what it cannot answer.
func (s *Server) register() {
- readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}
+ // The generic tools of docs/DESIGN.mcp.md §9.1, over the browse seam.
+ s.registerBrowseTools()
mcp.AddTool(s.mcp, &mcp.Tool{
Name: "list_databases",
- Annotations: readOnly,
+ Annotations: readOnlyTool,
Description: "List hosted Dolt databases with their visibility, description, default branch and " +
"head commit, and whether each one is a beads issue tracker.\n\n" +
"Pass `owner` — a SourceHut username without the \"~\" — to list that user's databases. " +