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 {
// A garbage or unknown cursor is an ordinary miss: browse wraps it in
// ErrRefNotFound the same as any other ref it cannot resolve, and refMiss
// reads that sentinel here. A genuine failure of the store still takes the
// protocol arm below.
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)
}