| 1 |
|
package mcpsrv |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"log/slog" |
| 8 |
|
"strings" |
| 9 |
|
"time" |
| 10 |
|
|
| 11 |
|
"github.com/modelcontextprotocol/go-sdk/mcp" |
| 12 |
|
"go.bigb.es/auxilia/scribe" |
| 13 |
|
|
| 14 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/beads" |
| 15 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/browse" |
| 16 |
|
"sourcecraft.dev/bigbes/sr-ht-dolt/core" |
| 17 |
|
) |
| 18 |
|
|
| 19 |
|
// The tools of docs/DESIGN.mcp.md ch. 9. This commit registers one — |
| 20 |
|
// list_databases, the entry point every other tool's arguments are built from — |
| 21 |
|
// and the rules below are written once here because they hold for all of them: |
| 22 |
|
// |
| 23 |
|
// - A database is addressed as {owner, name}, both without the "~". That is |
| 24 |
|
// what the URL says and what an agent can copy out of a link, and it is why |
| 25 |
|
// this listing answers with the two fields separately rather than with one |
| 26 |
|
// pre-joined string a tool would then have to take apart. |
| 27 |
|
// - Visibility is not re-implemented. The listings apply the listing rule on |
| 28 |
|
// the far side of the seam (ports.go), and every tool that resolves a named |
| 29 |
|
// database will reproduce the browse dance instead of inventing a second |
| 30 |
|
// reading of core.Allowed. |
| 31 |
|
// - Nothing derived is recomputed here: the default branch is |
| 32 |
|
// browse.DefaultBranch, the beads fingerprint is beads.Applies. A second |
| 33 |
|
// copy of either is how the board and this surface would start disagreeing |
| 34 |
|
// about what a beads database is. |
| 35 |
|
// - Every list is an array — empty rather than null — so an agent can loop |
| 36 |
|
// without a nil check, and anything genuinely unknown is a null rather than |
| 37 |
|
// a zero value that reads as an answer. |
| 38 |
|
|
| 39 |
|
// databaseJSON is one hosted database as list_databases reports it. |
| 40 |
|
// |
| 41 |
|
// The row's id, and the on-disk path it is served from, are both deliberately |
| 42 |
|
// absent. A database is addressed as {owner, name} on every surface, and |
| 43 |
|
// publishing an internal key an agent has no tool to use would only invite the |
| 44 |
|
// next tool to accept one; the path is the deployment's and no caller's. |
| 45 |
|
type databaseJSON struct { |
| 46 |
|
Owner string `json:"owner"` |
| 47 |
|
Name string `json:"name"` |
| 48 |
|
Description string `json:"description"` |
| 49 |
|
Visibility core.Visibility `json:"visibility"` |
| 50 |
|
|
| 51 |
|
// Content is everything that had to be read out of the database itself, and |
| 52 |
|
// it is one nullable object rather than four nullable fields so that "the |
| 53 |
|
// store could not be read" is one statement an agent checks once. |
| 54 |
|
// |
| 55 |
|
// A null Content with an empty ContentError is a database that exists and |
| 56 |
|
// carries no commits yet — a store created by a push that has not arrived. |
| 57 |
|
// A null Content *with* a ContentError is a store this daemon could not |
| 58 |
|
// read; the metadata beside it still came from Postgres and is still true. |
| 59 |
|
Content *databaseContentJSON `json:"content"` |
| 60 |
|
|
| 61 |
|
// ContentError is a fixed sentence, never the underlying failure: the real |
| 62 |
|
// one names on-disk paths and dolt internals. The cause is logged instead. |
| 63 |
|
ContentError string `json:"content_error,omitempty"` |
| 64 |
|
} |
| 65 |
|
|
| 66 |
|
// databaseContentJSON is what one read of a database's store answers about it |
| 67 |
|
// at its default branch. |
| 68 |
|
type databaseContentJSON struct { |
| 69 |
|
// DefaultBranch is browse.DefaultBranch's answer — "main" when it exists, |
| 70 |
|
// otherwise the first branch by name. It is the ref every tool that takes an |
| 71 |
|
// optional one falls back to, so an agent that does not care about branches |
| 72 |
|
// never has to name one. |
| 73 |
|
DefaultBranch string `json:"default_branch"` |
| 74 |
|
|
| 75 |
|
// Head is the hash of that branch's head commit, as the branch itself |
| 76 |
|
// reports it. |
| 77 |
|
Head string `json:"head"` |
| 78 |
|
|
| 79 |
|
// HeadTime is when that commit was authored. It is null only if the branch |
| 80 |
|
// head could not be read as a commit, which is a broken store rather than a |
| 81 |
|
// young one. |
| 82 |
|
HeadTime *time.Time `json:"head_time"` |
| 83 |
|
|
| 84 |
|
// IsBeads reports whether the tables at the default branch carry the beads |
| 85 |
|
// fingerprint (beads.Applies), i.e. whether the beads-aware tools of ch. 9.2 |
| 86 |
|
// will answer about this database. It is the one reading of that fingerprint |
| 87 |
|
// on this instance, shared with the web board. |
| 88 |
|
IsBeads bool `json:"is_beads"` |
| 89 |
|
} |
| 90 |
|
|
| 91 |
|
// listDatabasesInput carries the one argument today's queries make necessary. |
| 92 |
|
// |
| 93 |
|
// docs/DESIGN.mcp.md §9.1 gives this tool no arguments at all — "every database |
| 94 |
|
// the caller may list" — and that is not answerable with the queries this |
| 95 |
|
// service has. db/repos.go enumerates in exactly two ways: by owner |
| 96 |
|
// (ListReposByOwner, which applies the listing rule including anonymity) and by |
| 97 |
|
// membership (ListReposForDashboard, "owned or ACL'd", which needs a user id). |
| 98 |
|
// There is no "every PUBLIC database on the instance" query, and inventing one |
| 99 |
|
// is a change to db/'s file set rather than to this one. So the argument is |
| 100 |
|
// optional and the two arms are exactly the two queries. |
| 101 |
|
type listDatabasesInput struct { |
| 102 |
|
// Owner is a SourceHut username without the "~". A leading one is tolerated |
| 103 |
|
// rather than refused: it is what a link shows, so an agent copying an |
| 104 |
|
// address is more likely to include it than not, and refusing it would be a |
| 105 |
|
// sentence about punctuation in place of an answer. |
| 106 |
|
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."` |
| 107 |
|
} |
| 108 |
|
|
| 109 |
|
// listDatabasesOutput wraps the array in an object rather than serving a bare |
| 110 |
|
// one, so that a later addition is a new field and not a change of the |
| 111 |
|
// document's type. |
| 112 |
|
type listDatabasesOutput struct { |
| 113 |
|
Databases []databaseJSON `json:"databases"` |
| 114 |
|
} |
| 115 |
|
|
| 116 |
|
// contentUnreadable is the ContentError sentence. It is one string for every |
| 117 |
|
// cause — a missing store dir, a corrupt manifest, a ref that will not resolve — |
| 118 |
|
// because the difference is the operator's business (it is in the log) and not |
| 119 |
|
// the agent's: nothing an agent can do about a broken store differs by cause. |
| 120 |
|
const contentUnreadable = "this database's store could not be read on the server; its metadata below is still accurate" |
| 121 |
|
|
| 122 |
|
// readOnlyTool is the annotation every tool of this surface carries, and it is |
| 123 |
|
// one shared value rather than one per registration so that "every tool here is |
| 124 |
|
// a read" is a property of the package instead of a habit five call sites are |
| 125 |
|
// keeping up. It tells a client it may retry a call freely; this surface has no |
| 126 |
|
// write tool at all, and ports.go is why it cannot grow one by accident. |
| 127 |
|
var readOnlyTool = &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true} |
| 128 |
|
|
| 129 |
|
// register installs the tools on the protocol server. |
| 130 |
|
// |
| 131 |
|
// The whole surface is registered from this one function — the chapters live in |
| 132 |
|
// their own files, but what an agent is offered is listed in a single place, so |
| 133 |
|
// that reading it answers "what can be called here" completely. |
| 134 |
|
// |
| 135 |
|
// The descriptions are the agent-facing documentation of this service and are |
| 136 |
|
// written for a reader who has never seen the design document — what the tool |
| 137 |
|
// answers, how to address what it answers about, and what it cannot answer. |
| 138 |
126 |
func (s *Server) register() { |
| 139 |
126 |
// The generic tools of docs/DESIGN.mcp.md §9.1, over the browse seam. |
| 140 |
126 |
s.registerBrowseTools() |
| 141 |
126 |
|
| 142 |
126 |
// The beads-aware tools of docs/DESIGN.mcp.md §9.2, over the beads |
| 143 |
126 |
// projection. They are advertised for every database on this instance — MCP's |
| 144 |
126 |
// tool list is static per server — and refuse per database, which is why |
| 145 |
126 |
// registering them is unconditional here. |
| 146 |
126 |
s.registerBeadsTools() |
| 147 |
126 |
|
| 148 |
126 |
// The last of §9.2, in a file of its own because it is the one tool here that |
| 149 |
126 |
// answers about a *set* of databases: ready_work with no database named is |
| 150 |
126 |
// the cross-database aggregation of docs/DESIGN.views.md ch. 4, the same |
| 151 |
126 |
// function the /ready page renders. |
| 152 |
126 |
s.registerReadyWork() |
| 153 |
126 |
|
| 154 |
126 |
mcp.AddTool(s.mcp, &mcp.Tool{ |
| 155 |
126 |
Name: "list_databases", |
| 156 |
126 |
Annotations: readOnlyTool, |
| 157 |
126 |
Description: "List hosted Dolt databases with their visibility, description, default branch and " + |
| 158 |
126 |
"head commit, and whether each one is a beads issue tracker.\n\n" + |
| 159 |
126 |
"Pass `owner` — a SourceHut username without the \"~\" — to list that user's databases. " + |
| 160 |
126 |
"Omit it to list your own: everything you own or have been granted access to, which needs " + |
| 161 |
126 |
"a bearer token.\n\n" + |
| 162 |
126 |
"There is no way to enumerate every database on this instance: the service can only " + |
| 163 |
126 |
"answer per owner, or about you. Without `owner` and without a credential there is " + |
| 164 |
126 |
"nothing to list, and the call says so.\n\n" + |
| 165 |
126 |
"What you see depends on the token you present: a user's public databases to everyone, " + |
| 166 |
126 |
"plus any of theirs you own or hold access to. An unlisted database of somebody else " + |
| 167 |
126 |
"never appears here and is still readable if you address it directly; a private one you " + |
| 168 |
126 |
"have no access to is reported as not existing, which is the same answer a name nobody " + |
| 169 |
126 |
"took gets.\n\n" + |
| 170 |
126 |
"Address a database in the other tools as the `owner` and `name` of an entry here. " + |
| 171 |
126 |
"`content` is null for a database with no commits yet, and carries `content_error` when " + |
| 172 |
126 |
"its store could not be read.", |
| 173 |
126 |
}, func(ctx context.Context, _ *mcp.CallToolRequest, in listDatabasesInput) (*mcp.CallToolResult, listDatabasesOutput, error) { |
| 174 |
33 |
out, err := s.listDatabases(ctx, in) |
| 175 |
33 |
return nil, out, err |
| 176 |
33 |
}) |
| 177 |
|
} |
| 178 |
|
|
| 179 |
|
// listDatabases answers list_databases: the databases the caller may list, each |
| 180 |
|
// described by one read of its store. |
| 181 |
|
// |
| 182 |
|
// The two arms are the two queries db/ has, and neither of them is this |
| 183 |
|
// package's reading of visibility — ListReposByOwner applies the listing rule |
| 184 |
|
// (PUBLIC to anyone including anonymity, plus what the viewer owns or is ACL'd |
| 185 |
|
// on) and ListReposForDashboard is the caller's own membership. An UNLISTED |
| 186 |
|
// database of another owner is absent from both, which is the design's rule and |
| 187 |
|
// the dashboard's behaviour, not a narrowing invented here. |
| 188 |
33 |
func (s *Server) listDatabases(ctx context.Context, in listDatabasesInput) (listDatabasesOutput, error) { |
| 189 |
33 |
caller := callerOf(ctx) |
| 190 |
33 |
owner := strings.TrimPrefix(strings.TrimSpace(in.Owner), "~") |
| 191 |
33 |
|
| 192 |
33 |
var ( |
| 193 |
33 |
repos []*core.Repo |
| 194 |
33 |
err error |
| 195 |
33 |
) |
| 196 |
33 |
switch { |
| 197 |
28 |
case owner != "": |
| 198 |
28 |
repos, err = s.repos.ListReposByOwner(ctx, owner, caller) |
| 199 |
1 |
case caller == nil: |
| 200 |
1 |
// A tool result and not a protocol error: the call was understood, and |
| 201 |
1 |
// what it asked for cannot exist rather than could not be produced. The |
| 202 |
1 |
// sentence names the way out, because there is one. |
| 203 |
1 |
return listDatabasesOutput{}, errors.New( |
| 204 |
1 |
"no owner was named and this call carries no credential, so there is nothing to list: " + |
| 205 |
1 |
"pass owner to list one user's public databases, or present a bearer token to list your own") |
| 206 |
4 |
default: |
| 207 |
4 |
repos, err = s.repos.ListReposForDashboard(ctx, caller.UserID) |
| 208 |
|
} |
| 209 |
32 |
if err != nil { |
| 210 |
1 |
// No database was addressed, so there is no "that one does not exist" to |
| 211 |
1 |
// answer: whatever went wrong enumerating them is this service's. |
| 212 |
1 |
return listDatabasesOutput{}, internalError(err, "list_databases") |
| 213 |
1 |
} |
| 214 |
|
|
| 215 |
31 |
out := make([]databaseJSON, 0, len(repos)) |
| 216 |
134 |
for _, repo := range repos { |
| 217 |
134 |
out = append(out, s.describe(ctx, repo)) |
| 218 |
134 |
} |
| 219 |
31 |
return listDatabasesOutput{Databases: out}, nil |
| 220 |
|
} |
| 221 |
|
|
| 222 |
|
// describe renders one repository row and adds what its store says about |
| 223 |
|
// itself. |
| 224 |
|
// |
| 225 |
|
// A store that cannot be read costs this database its content and nothing more: |
| 226 |
|
// the listing still names it, with the metadata Postgres holds, and says the |
| 227 |
|
// store could not be read. Two alternatives were rejected. Failing the whole |
| 228 |
|
// call would let one broken store hide every other database from every caller; |
| 229 |
|
// and answering `is_beads: false` for a tracker whose store did not open would |
| 230 |
|
// be a lie an agent has no way to detect, which is the failure mode the whole |
| 231 |
|
// truncation rule of ch. 9.3 exists to avoid. |
| 232 |
134 |
func (s *Server) describe(ctx context.Context, repo *core.Repo) databaseJSON { |
| 233 |
134 |
out := databaseJSON{ |
| 234 |
134 |
Owner: repo.OwnerName, |
| 235 |
134 |
Name: repo.Name, |
| 236 |
134 |
Description: repo.Description, |
| 237 |
134 |
Visibility: repo.Visibility, |
| 238 |
134 |
} |
| 239 |
134 |
|
| 240 |
134 |
content, err := s.readContent(ctx, repo) |
| 241 |
134 |
if err != nil { |
| 242 |
29 |
slog.Error("a hosted store could not be read for list_databases", |
| 243 |
29 |
"owner", repo.OwnerName, "database", repo.Name, scribe.Err(err)) |
| 244 |
29 |
out.ContentError = contentUnreadable |
| 245 |
29 |
return out |
| 246 |
29 |
} |
| 247 |
105 |
out.Content = content |
| 248 |
105 |
return out |
| 249 |
|
} |
| 250 |
|
|
| 251 |
|
// readContent opens one bare store and reads the three things a listing entry |
| 252 |
|
// carries: the default branch, its head, and whether the tables there are a |
| 253 |
|
// beads tracker. |
| 254 |
|
// |
| 255 |
|
// It returns (nil, nil) for a database with no branches — a store that exists |
| 256 |
|
// and has never been pushed to. That is an answer and not a failure, and it is |
| 257 |
|
// distinguishable from a failure because a failure returns an error. |
| 258 |
|
// |
| 259 |
|
// The session is opened per call and closed here, which is the browse |
| 260 |
|
// discipline: a fresh read of the on-disk manifest every time, so a push that |
| 261 |
|
// landed a second ago is visible and nothing is cached between calls. It is |
| 262 |
|
// also the cost of this tool — one store opened per database listed — and the |
| 263 |
|
// reason the listing carries a head and a fingerprint rather than every tool |
| 264 |
|
// having to ask for them separately. |
| 265 |
134 |
func (s *Server) readContent(ctx context.Context, repo *core.Repo) (*databaseContentJSON, error) { |
| 266 |
134 |
sess, err := s.opener.Open(ctx, repo.Path) |
| 267 |
134 |
if err != nil { |
| 268 |
28 |
return nil, fmt.Errorf("opening the store: %w", err) |
| 269 |
28 |
} |
| 270 |
106 |
defer sess.Close() |
| 271 |
106 |
|
| 272 |
106 |
branches, err := sess.Branches(ctx) |
| 273 |
106 |
if err != nil { |
| 274 |
0 |
return nil, fmt.Errorf("listing branches: %w", err) |
| 275 |
0 |
} |
| 276 |
106 |
name := browse.DefaultBranch(branches) |
| 277 |
106 |
if name == "" { |
| 278 |
28 |
return nil, nil |
| 279 |
28 |
} |
| 280 |
|
|
| 281 |
78 |
content := &databaseContentJSON{DefaultBranch: name, Head: headOf(branches, name)} |
| 282 |
78 |
|
| 283 |
78 |
commits, _, err := sess.Log(ctx, name, "", 1) |
| 284 |
78 |
if err != nil { |
| 285 |
0 |
return nil, fmt.Errorf("reading the head commit of %q: %w", name, err) |
| 286 |
0 |
} |
| 287 |
78 |
if len(commits) > 0 { |
| 288 |
78 |
at := commits[0].Date |
| 289 |
78 |
content.HeadTime = &at |
| 290 |
78 |
} |
| 291 |
|
|
| 292 |
78 |
tables, err := sess.Tables(ctx, name) |
| 293 |
78 |
if err != nil { |
| 294 |
1 |
return nil, fmt.Errorf("listing tables at %q: %w", name, err) |
| 295 |
1 |
} |
| 296 |
77 |
content.IsBeads = beads.Applies(tables) |
| 297 |
77 |
|
| 298 |
77 |
return content, nil |
| 299 |
|
} |
| 300 |
|
|
| 301 |
|
// headOf returns the head hash the branch list carries for name, or "" if the |
| 302 |
|
// list does not name it — which browse.DefaultBranch's contract makes |
| 303 |
|
// impossible, since it picks out of this very list. |
| 304 |
78 |
func headOf(branches []browse.Branch, name string) string { |
| 305 |
78 |
for _, b := range branches { |
| 306 |
78 |
if b.Name == name { |
| 307 |
78 |
return b.Head |
| 308 |
78 |
} |
| 309 |
|
} |
| 310 |
0 |
return "" |
| 311 |
|
} |