coverage~bigbes/sr-ht-doltede3b0bbmcpsrv/read.go

Coverage
94.3% 50/53 statements
Δ
Blob
afd2e8c
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 }