package mcpsrv
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// The tools of docs/DESIGN.mcp.md ch. 9. This commit registers one —
// list_databases, the entry point every other tool's arguments are built from —
// and the rules below are written once here because they hold for all of them:
//
// - A database is addressed as {owner, name}, both without the "~". That is
// what the URL says and what an agent can copy out of a link, and it is why
// this listing answers with the two fields separately rather than with one
// pre-joined string a tool would then have to take apart.
// - Visibility is not re-implemented. The listings apply the listing rule on
// the far side of the seam (ports.go), and every tool that resolves a named
// database will reproduce the browse dance instead of inventing a second
// reading of core.Allowed.
// - Nothing derived is recomputed here: the default branch is
// browse.DefaultBranch, the beads fingerprint is beads.Applies. A second
// copy of either is how the board and this surface would start disagreeing
// about what a beads database is.
// - Every list is an array — empty rather than null — so an agent can loop
// without a nil check, and anything genuinely unknown is a null rather than
// a zero value that reads as an answer.
// databaseJSON is one hosted database as list_databases reports it.
//
// The row's id, and the on-disk path it is served from, are both deliberately
// absent. A database is addressed as {owner, name} on every surface, and
// publishing an internal key an agent has no tool to use would only invite the
// next tool to accept one; the path is the deployment's and no caller's.
type databaseJSON struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Visibility core.Visibility `json:"visibility"`
// Content is everything that had to be read out of the database itself, and
// it is one nullable object rather than four nullable fields so that "the
// store could not be read" is one statement an agent checks once.
//
// A null Content with an empty ContentError is a database that exists and
// carries no commits yet — a store created by a push that has not arrived.
// A null Content *with* a ContentError is a store this daemon could not
// read; the metadata beside it still came from Postgres and is still true.
Content *databaseContentJSON `json:"content"`
// ContentError is a fixed sentence, never the underlying failure: the real
// one names on-disk paths and dolt internals. The cause is logged instead.
ContentError string `json:"content_error,omitempty"`
}
// databaseContentJSON is what one read of a database's store answers about it
// at its default branch.
type databaseContentJSON struct {
// DefaultBranch is browse.DefaultBranch's answer — "main" when it exists,
// otherwise the first branch by name. It is the ref every tool that takes an
// optional one falls back to, so an agent that does not care about branches
// never has to name one.
DefaultBranch string `json:"default_branch"`
// Head is the hash of that branch's head commit, as the branch itself
// reports it.
Head string `json:"head"`
// HeadTime is when that commit was authored. It is null only if the branch
// head could not be read as a commit, which is a broken store rather than a
// young one.
HeadTime *time.Time `json:"head_time"`
// IsBeads reports whether the tables at the default branch carry the beads
// fingerprint (beads.Applies), i.e. whether the beads-aware tools of ch. 9.2
// will answer about this database. It is the one reading of that fingerprint
// on this instance, shared with the web board.
IsBeads bool `json:"is_beads"`
}
// listDatabasesInput carries the one argument today's queries make necessary.
//
// docs/DESIGN.mcp.md §9.1 gives this tool no arguments at all — "every database
// the caller may list" — and that is not answerable with the queries this
// service has. db/repos.go enumerates in exactly two ways: by owner
// (ListReposByOwner, which applies the listing rule including anonymity) and by
// membership (ListReposForDashboard, "owned or ACL'd", which needs a user id).
// There is no "every PUBLIC database on the instance" query, and inventing one
// is a change to db/'s file set rather than to this one. So the argument is
// optional and the two arms are exactly the two queries.
type listDatabasesInput struct {
// Owner is a SourceHut username without the "~". A leading one is tolerated
// rather than refused: it is what a link shows, so an agent copying an
// address is more likely to include it than not, and refusing it would be a
// sentence about punctuation in place of an answer.
Owner string `json:"owner,omitempty" jsonschema:"the SourceHut username whose databases to list, without the \"~\". Omit it to list your own — the databases you own or hold an ACL entry on — which requires a credential."`
}
// listDatabasesOutput wraps the array in an object rather than serving a bare
// one, so that a later addition is a new field and not a change of the
// document's type.
type listDatabasesOutput struct {
Databases []databaseJSON `json:"databases"`
}
// contentUnreadable is the ContentError sentence. It is one string for every
// cause — a missing store dir, a corrupt manifest, a ref that will not resolve —
// because the difference is the operator's business (it is in the log) and not
// 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.
//
// 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() {
// 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: 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. " +
"Omit it to list your own: everything you own or have been granted access to, which needs " +
"a bearer token.\n\n" +
"There is no way to enumerate every database on this instance: the service can only " +
"answer per owner, or about you. Without `owner` and without a credential there is " +
"nothing to list, and the call says so.\n\n" +
"What you see depends on the token you present: a user's public databases to everyone, " +
"plus any of theirs you own or hold access to. An unlisted database of somebody else " +
"never appears here and is still readable if you address it directly; a private one you " +
"have no access to is reported as not existing, which is the same answer a name nobody " +
"took gets.\n\n" +
"Address a database in the other tools as the `owner` and `name` of an entry here. " +
"`content` is null for a database with no commits yet, and carries `content_error` when " +
"its store could not be read.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, in listDatabasesInput) (*mcp.CallToolResult, listDatabasesOutput, error) {
out, err := s.listDatabases(ctx, in)
return nil, out, err
})
}
// listDatabases answers list_databases: the databases the caller may list, each
// described by one read of its store.
//
// The two arms are the two queries db/ has, and neither of them is this
// package's reading of visibility — ListReposByOwner applies the listing rule
// (PUBLIC to anyone including anonymity, plus what the viewer owns or is ACL'd
// on) and ListReposForDashboard is the caller's own membership. An UNLISTED
// database of another owner is absent from both, which is the design's rule and
// the dashboard's behaviour, not a narrowing invented here.
func (s *Server) listDatabases(ctx context.Context, in listDatabasesInput) (listDatabasesOutput, error) {
caller := callerOf(ctx)
owner := strings.TrimPrefix(strings.TrimSpace(in.Owner), "~")
var (
repos []*core.Repo
err error
)
switch {
case owner != "":
repos, err = s.repos.ListReposByOwner(ctx, owner, caller)
case caller == nil:
// A tool result and not a protocol error: the call was understood, and
// what it asked for cannot exist rather than could not be produced. The
// sentence names the way out, because there is one.
return listDatabasesOutput{}, errors.New(
"no owner was named and this call carries no credential, so there is nothing to list: " +
"pass owner to list one user's public databases, or present a bearer token to list your own")
default:
repos, err = s.repos.ListReposForDashboard(ctx, caller.UserID)
}
if err != nil {
// No database was addressed, so there is no "that one does not exist" to
// answer: whatever went wrong enumerating them is this service's.
return listDatabasesOutput{}, internalError(err, "list_databases")
}
out := make([]databaseJSON, 0, len(repos))
for _, repo := range repos {
out = append(out, s.describe(ctx, repo))
}
return listDatabasesOutput{Databases: out}, nil
}
// describe renders one repository row and adds what its store says about
// itself.
//
// A store that cannot be read costs this database its content and nothing more:
// the listing still names it, with the metadata Postgres holds, and says the
// store could not be read. Two alternatives were rejected. Failing the whole
// call would let one broken store hide every other database from every caller;
// and answering `is_beads: false` for a tracker whose store did not open would
// be a lie an agent has no way to detect, which is the failure mode the whole
// truncation rule of ch. 9.3 exists to avoid.
func (s *Server) describe(ctx context.Context, repo *core.Repo) databaseJSON {
out := databaseJSON{
Owner: repo.OwnerName,
Name: repo.Name,
Description: repo.Description,
Visibility: repo.Visibility,
}
content, err := s.readContent(ctx, repo)
if err != nil {
slog.Error("a hosted store could not be read for list_databases",
"owner", repo.OwnerName, "database", repo.Name, scribe.Err(err))
out.ContentError = contentUnreadable
return out
}
out.Content = content
return out
}
// readContent opens one bare store and reads the three things a listing entry
// carries: the default branch, its head, and whether the tables there are a
// beads tracker.
//
// It returns (nil, nil) for a database with no branches — a store that exists
// and has never been pushed to. That is an answer and not a failure, and it is
// distinguishable from a failure because a failure returns an error.
//
// The session is opened per call and closed here, which is the browse
// discipline: a fresh read of the on-disk manifest every time, so a push that
// landed a second ago is visible and nothing is cached between calls. It is
// also the cost of this tool — one store opened per database listed — and the
// reason the listing carries a head and a fingerprint rather than every tool
// having to ask for them separately.
func (s *Server) readContent(ctx context.Context, repo *core.Repo) (*databaseContentJSON, error) {
sess, err := s.opener.Open(ctx, repo.Path)
if err != nil {
return nil, fmt.Errorf("opening the store: %w", err)
}
defer sess.Close()
branches, err := sess.Branches(ctx)
if err != nil {
return nil, fmt.Errorf("listing branches: %w", err)
}
name := browse.DefaultBranch(branches)
if name == "" {
return nil, nil
}
content := &databaseContentJSON{DefaultBranch: name, Head: headOf(branches, name)}
commits, _, err := sess.Log(ctx, name, "", 1)
if err != nil {
return nil, fmt.Errorf("reading the head commit of %q: %w", name, err)
}
if len(commits) > 0 {
at := commits[0].Date
content.HeadTime = &at
}
tables, err := sess.Tables(ctx, name)
if err != nil {
return nil, fmt.Errorf("listing tables at %q: %w", name, err)
}
content.IsBeads = beads.Applies(tables)
return content, nil
}
// headOf returns the head hash the branch list carries for name, or "" if the
// list does not name it — which browse.DefaultBranch's contract makes
// impossible, since it picks out of this very list.
func headOf(branches []browse.Branch, name string) string {
for _, b := range branches {
if b.Name == name {
return b.Head
}
}
return ""
}