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" // 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 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} mcp.AddTool(s.mcp, &mcp.Tool{ Name: "list_databases", Annotations: readOnly, 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 "" }