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

Coverage
96.5% 191/198 statements
Δ
Blob
ee1e9d3
1 package mcpsrv
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 "github.com/modelcontextprotocol/go-sdk/mcp"
11
12 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
13 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
14 )
15
16 // The generic tools of docs/DESIGN.mcp.md §9.1: the browse layer (browse/)
17 // offered as tools, for any hosted database rather than a beads tracker.
18 //
19 // They live beside read.go rather than in it because §9.1 and §9.2 are two
20 // chapters with two file sets — read.go keeps list_databases, the entry point
21 // whose answers every tool here takes its arguments from, and the beads tools of
22 // §9.2 arrive in a file of their own. One file per chapter is what keeps each of
23 // them readable; the registration of the whole surface still happens in one
24 // place (register, read.go).
25 //
26 // The rules read.go states once hold here too — the {owner, name} address,
27 // visibility that is applied rather than re-derived, an array for every list —
28 // and these are the ones this chapter adds:
29 //
30 // - Every tool resolves the database exactly as the browse handlers do
31 // (resolveDatabase): the row, the caller's ACL grant, core.Allowed. A
32 // database the caller may not read is *not found*, the same sentence a name
33 // nobody took gets, because a distinguishable refusal is what the 404 exists
34 // to erase.
35 // - A ref that does not resolve and a table that does not exist are the other
36 // kind of miss entirely: they are ordinary answers *about a database the
37 // caller can see*, and they name what was not found. Confusing the two would
38 // either leak the existence of a private database or hide a typo behind a
39 // sentence about a database the agent is looking straight at.
40 // - A cap is applied and *stated*. A page that stops short says so and carries
41 // the number it stopped short of, because a caller that cannot see the table
42 // has no other way to tell a full answer from a clipped one (§9.3).
43 // - One session per call, closed by the handler that opened it. That is the
44 // browse discipline: a fresh read of the on-disk manifest, so a push that
45 // landed a second ago is visible, and no handle outlives the call.
46
47 // The caps of docs/DESIGN.mcp.md §9.3.
48 //
49 // A default exists so that an agent that names no limit gets a page rather than
50 // a table; a maximum exists because this surface answers in one response and a
51 // caller can always page. Neither is silent: the answer reports the limit that
52 // was applied and whether anything was left behind.
53 const (
54 defaultRowLimit = 100
55 maxRowLimit = 500
56
57 defaultCommitLimit = 25
58 maxCommitLimit = 100
59 )
60
61 // databaseRef is how every tool of this chapter addresses a database: the two
62 // fields of the URL, both without the "~", exactly as list_databases answers
63 // them.
64 //
65 // It is embedded in each tool's input struct, so the derived schema carries
66 // owner and name as two required properties of that tool rather than as a nested
67 // object — an agent copying an address out of a link fills in two strings, and
68 // does not have to learn a wrapper type first.
69 type databaseRef struct {
70 Owner string `json:"owner" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted)"`
71 Name string `json:"name" jsonschema:"the database name, as list_databases reports it"`
72 }
73
74 // owner is the username with the sigil and any stray whitespace removed. A
75 // leading "~" is tolerated rather than refused for read.go's reason: it is what
76 // a link shows, so an agent copying an address is more likely to include it than
77 // not.
78 891 func (r databaseRef) owner() string { return strings.TrimPrefix(strings.TrimSpace(r.Owner), "~") }
79
80 880 func (r databaseRef) name() string { return strings.TrimSpace(r.Name) }
81
82 // String renders the address the way a link does, which is how every sentence a
83 // caller reads names the database it is about.
84 300 func (r databaseRef) String() string { return "~" + r.owner() + "/" + r.name() }
85
86 // --- the shapes a caller decodes -------------------------------------------
87
88 // branchJSON is one branch and the head commit it points at.
89 type branchJSON struct {
90 Name string `json:"name"`
91 Head string `json:"head"`
92 }
93
94 type listBranchesInput struct {
95 databaseRef
96 }
97
98 type listBranchesOutput struct {
99 Branches []branchJSON `json:"branches"`
100
101 // DefaultBranch is browse.DefaultBranch's answer over the list above, and it
102 // is reported rather than left for the agent to work out: it is the ref every
103 // tool here falls back to, so a caller that wants to name the same one
104 // explicitly should not have to reimplement the rule. It is "" for a database
105 // with no branches at all.
106 DefaultBranch string `json:"default_branch"`
107 }
108
109 // columnJSON is one column of a table's schema.
110 type columnJSON struct {
111 Name string `json:"name"`
112 Type string `json:"type"`
113 PrimaryKey bool `json:"primary_key"`
114 Nullable bool `json:"nullable"`
115 }
116
117 // tableJSON is one table at a ref: its schema and its exact row count.
118 type tableJSON struct {
119 Name string `json:"name"`
120 Columns []columnJSON `json:"columns"`
121 RowCount uint64 `json:"row_count"`
122 }
123
124 type listTablesInput struct {
125 databaseRef
126 Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`
127 }
128
129 type listTablesOutput struct {
130 // Ref is the ref actually read, which is the default branch when the call
131 // named none. An agent that omitted it learns from the answer which branch it
132 // is looking at, and can name that one for the rest of a series of calls.
133 Ref string `json:"ref"`
134 Tables []tableJSON `json:"tables"`
135 }
136
137 type readRowsInput struct {
138 databaseRef
139 Table string `json:"table" jsonschema:"the table to read, as list_tables names it"`
140 Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`
141
142 // Offset and Limit are pointers so that an omitted argument and an explicit
143 // zero are two different calls. An omitted limit is "you choose" and gets the
144 // default; a limit of 0 was typed by the caller, means "no rows at all", and
145 // is refused rather than quietly turned into 100 — a page nobody asked for
146 // reads as an empty table, which is the same class of lie as a silent
147 // truncation.
148 Offset *int `json:"offset,omitempty" jsonschema:"how many rows to skip, zero or more; defaults to 0"`
149 Limit *int `json:"limit,omitempty" jsonschema:"how many rows to return, at most 500; defaults to 100"`
150 }
151
152 type readRowsOutput struct {
153 Ref string `json:"ref"`
154 Table string `json:"table"`
155
156 // Columns names the cells of every row, in order: for a keyed table the
157 // primary-key columns first, then the rest.
158 Columns []string `json:"columns"`
159
160 // Rows are rendered strings and not typed values: this surface reads a bare
161 // store without a SQL engine, so what it has is the stored value formatted.
162 //
163 // A cell that holds no value is null rather than a string. browse renders a
164 // real NULL as the text "NULL", which is exactly what a row storing those
165 // four characters renders as — harmless on a page, where a reader sees the
166 // same thing either way, and not harmless here: this tool hands rows to a
167 // machine with no schema beside them, and "is there a value in this cell?"
168 // is not a question an agent can answer from the string afterwards. The mask
169 // browse carries beside its rows is what makes the two distinguishable, and
170 // spending it on a JSON null is the shape that needs no explaining.
171 //
172 // An unprintable value still reads as "<binary>" and is still not
173 // distinguishable from a row that stores that text. That one stays: it
174 // answers what a value *is*, and the caller can see it is there.
175 Rows [][]*string `json:"rows"`
176
177 Offset int `json:"offset"`
178
179 // Limit is the limit that was applied, which is not always the one asked for:
180 // a request above the cap is answered at the cap, and saying so here is what
181 // keeps that from being a silent clamp.
182 Limit int `json:"limit"`
183
184 // Total is the number of rows in the whole table at this ref — the honest
185 // denominator of the page above, straight from browse.RowPage.
186 Total int `json:"total"`
187
188 // Truncated reports that rows remain after this page (docs/DESIGN.mcp.md
189 // §9.3). Raise offset to read them.
190 Truncated bool `json:"truncated"`
191 }
192
193 // commitJSON is one commit of a log listing.
194 //
195 // The author's email is deliberately absent: the browse UI publishes the name
196 // and not the address (web/templates/log.html), and a surface reachable by any
197 // token has no business publishing more of a person than the page does.
198 type commitJSON struct {
199 Hash string `json:"hash"`
200 Author string `json:"author"`
201 Date time.Time `json:"date"`
202 Message string `json:"message"`
203 Parents []string `json:"parents"`
204 }
205
206 type getCommitLogInput struct {
207 databaseRef
208 Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to start from; omit it for the database's default branch"`
209 From string `json:"from,omitempty" jsonschema:"the \"next\" hash of a previous page, to continue where it stopped; it takes the place of ref"`
210 Limit *int `json:"limit,omitempty" jsonschema:"how many commits to return, at most 100; defaults to 25"`
211 }
212
213 type getCommitLogOutput struct {
214 // Ref is the ref this page was read at, and is empty when the call continued
215 // a cursor: a from-hash is a point in the graph and claiming it belongs to
216 // some branch would be a claim this service did not check.
217 Ref string `json:"ref"`
218 Commits []commitJSON `json:"commits"`
219
220 // Next is the hash to pass as from for the following page, or "" at the end
221 // of the history.
222 Next string `json:"next"`
223
224 // Limit is the limit applied, as in readRowsOutput.
225 Limit int `json:"limit"`
226
227 // Truncated reports that the history goes on past this page.
228 //
229 // There is no total beside it, and that is not an omission: a commit count
230 // means walking the whole graph, browse/ offers no such call, and a number
231 // that expensive would be paid for by every caller of a paging tool. Next is
232 // the honest continuation here — it is present exactly when something was
233 // left behind.
234 Truncated bool `json:"truncated"`
235 }
236
237 // tableDiffJSON is how one table changed in a commit.
238 type tableDiffJSON struct {
239 Name string `json:"name"`
240 Added bool `json:"added"`
241 Dropped bool `json:"dropped"`
242 SchemaChanged bool `json:"schema_changed"`
243
244 // The row counts are exact, with one documented exception browse/ owns: when
245 // the primary key set changed there is no row-level correspondence to count
246 // across, so the three are 0 and SchemaChanged is true.
247 RowsAdded int64 `json:"rows_added"`
248 RowsRemoved int64 `json:"rows_removed"`
249 RowsModified int64 `json:"rows_modified"`
250 }
251
252 type getCommitDiffInput struct {
253 databaseRef
254 Hash string `json:"hash" jsonschema:"the commit to summarize; a branch name is accepted and means that branch's head"`
255 }
256
257 type getCommitDiffOutput struct {
258 // Hash is the resolved commit hash, which is worth carrying back when the
259 // call named a branch: it pins which commit the answer is about even if the
260 // branch moves a moment later.
261 Hash string `json:"hash"`
262 Tables []tableDiffJSON `json:"tables"`
263 }
264
265 // --- registration -----------------------------------------------------------
266
267 // registerBrowseTools installs the tools of docs/DESIGN.mcp.md §9.1.
268 //
269 // The descriptions are the agent-facing documentation of this surface: what the
270 // tool answers, how to address it, what it costs, and — for every one of them —
271 // what it cannot do, since an agent that knows there is no SQL here stops
272 // looking for it.
273 126 func (s *Server) registerBrowseTools() {
274 126 mcp.AddTool(s.mcp, &mcp.Tool{
275 126 Name: "list_branches",
276 126 Annotations: readOnlyTool,
277 126 Description: "List the branches of one hosted Dolt database, each with the hash of its head commit.\n\n" +
278 126 "Address the database by the `owner` and `name` that list_databases reports (the two parts of " +
279 126 "its URL, without the \"~\").\n\n" +
280 126 "`default_branch` is the branch every other tool reads when you pass no `ref`: \"main\" when it " +
281 126 "exists, otherwise the first branch by name. A database nothing has been pushed to yet has no " +
282 126 "branches, and answers an empty list with an empty default.\n\n" +
283 126 "A database you may not read is reported as not existing, which is the same answer a name " +
284 126 "nobody took gets.",
285 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, listBranchesOutput, error) {
286 23 out, err := s.listBranches(ctx, in)
287 23 return nil, out, err
288 23 })
289
290 126 mcp.AddTool(s.mcp, &mcp.Tool{
291 126 Name: "list_tables",
292 126 Annotations: readOnlyTool,
293 126 Description: "List the tables of one hosted Dolt database at one ref, each with its columns — name, SQL " +
294 126 "type, whether it is part of the primary key, whether it is nullable — and its exact row count.\n\n" +
295 126 "`ref` is a branch name or a commit hash; omit it to read the database's default branch, which " +
296 126 "the answer names back to you. A ref that matches neither is reported as such, and says so " +
297 126 "about that database rather than pretending the database is missing.\n\n" +
298 126 "This is the schema of the data, not a query interface: there is no SQL on this surface and " +
299 126 "there will not be one. Read a table with read_rows and project or aggregate it yourself.",
300 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in listTablesInput) (*mcp.CallToolResult, listTablesOutput, error) {
301 23 out, err := s.listTables(ctx, in)
302 23 return nil, out, err
303 23 })
304
305 126 mcp.AddTool(s.mcp, &mcp.Tool{
306 126 Name: "read_rows",
307 126 Annotations: readOnlyTool,
308 126 Description: "Read one page of rows from one table of a hosted Dolt database.\n\n" +
309 126 "`columns` names the cells of every row in order (primary-key columns first for a keyed " +
310 126 "table). Cells are rendered strings, because this surface reads a bare store without a SQL " +
311 126 "engine: a cell that holds no value is `null`, and an unprintable one is the string " +
312 126 "`<binary>`.\n\n" +
313 126 "`limit` defaults to 100 and is capped at 500; the `limit` in the answer is the one actually " +
314 126 "applied, so a larger request is visibly answered at the cap. `total` is the number of rows " +
315 126 "in the whole table, and `truncated` says rows remain after this page — read them by raising " +
316 126 "`offset`, which is O(1) here regardless of how far in it points.\n\n" +
317 126 "Rows come back in the table's own key order and cannot be filtered or sorted server-side. " +
318 126 "For a beads tracker, prefer the beads tools: they answer the questions this table would " +
319 126 "make you assemble by hand.",
320 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in readRowsInput) (*mcp.CallToolResult, readRowsOutput, error) {
321 33 out, err := s.readRows(ctx, in)
322 33 return nil, out, err
323 33 })
324
325 126 mcp.AddTool(s.mcp, &mcp.Tool{
326 126 Name: "get_commit_log",
327 126 Annotations: readOnlyTool,
328 126 Description: "Read the commit history of a hosted Dolt database, newest first: hash, author, date, " +
329 126 "message and parent hashes.\n\n" +
330 126 "`ref` is a branch name or a commit hash to start from; omit it for the default branch. " +
331 126 "`limit` defaults to 25 and is capped at 100.\n\n" +
332 126 "When the history goes on past the page, `truncated` is true and `next` carries the hash of " +
333 126 "the commit that follows it: pass that as `from` for the next page. `from` takes the place of " +
334 126 "`ref`, so a continued page reports no ref. There is no commit total — counting one would " +
335 126 "mean walking the entire graph — so `next` is how you tell a full answer from a clipped one.\n\n" +
336 126 "A merge commit lists every parent; the history is walked in reverse topological order.",
337 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitLogInput) (*mcp.CallToolResult, getCommitLogOutput, error) {
338 30 out, err := s.getCommitLog(ctx, in)
339 30 return nil, out, err
340 30 })
341
342 126 mcp.AddTool(s.mcp, &mcp.Tool{
343 126 Name: "get_commit_diff",
344 126 Annotations: readOnlyTool,
345 126 Description: "Summarize what one commit changed, table by table, against its first parent — and for the " +
346 126 "initial commit against an empty database, where every table reads as added.\n\n" +
347 126 "`hash` is a commit hash; a branch name is accepted and means that branch's head. Each entry " +
348 126 "says whether the table was added or dropped, whether its schema changed, and how many rows " +
349 126 "were added, removed and modified.\n\n" +
350 126 "The row counts are exact, with one exception: when the primary key set changed there is no " +
351 126 "row-level correspondence to count across, so `schema_changed` is true and the three counts " +
352 126 "are 0.\n\n" +
353 126 "This is a summary and never a row-level diff — no cell values are reported. To see what the " +
354 126 "rows became, read the table at this commit with read_rows, passing the hash as `ref`.",
355 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitDiffInput) (*mcp.CallToolResult, getCommitDiffOutput, error) {
356 20 out, err := s.getCommitDiff(ctx, in)
357 20 return nil, out, err
358 20 })
359 }
360
361 // --- the handlers -----------------------------------------------------------
362
363 // listBranches answers list_branches: every branch of one database with its
364 // head, plus the default the other tools fall back to.
365 23 func (s *Server) listBranches(ctx context.Context, in listBranchesInput) (listBranchesOutput, error) {
366 23 const tool = "list_branches"
367 23 var out listBranchesOutput
368 23
369 23 repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
370 23 if err != nil {
371 8 return out, err
372 8 }
373 15 sess, err := s.openStore(ctx, tool, repo)
374 15 if err != nil {
375 1 return out, err
376 1 }
377 14 defer sess.Close()
378 14
379 14 branches, err := sess.Branches(ctx)
380 14 if err != nil {
381 0 // Branches resolves no ref and names no table, so there is no miss it can
382 0 // report: whatever went wrong here is this service's.
383 0 return out, internalError(err, tool)
384 0 }
385
386 14 out.Branches = make([]branchJSON, 0, len(branches))
387 20 for _, b := range branches {
388 20 out.Branches = append(out.Branches, branchJSON{Name: b.Name, Head: b.Head})
389 20 }
390 14 out.DefaultBranch = browse.DefaultBranch(branches)
391 14 return out, nil
392 }
393
394 // listTables answers list_tables: the schema of a database at one ref.
395 23 func (s *Server) listTables(ctx context.Context, in listTablesInput) (listTablesOutput, error) {
396 23 const tool = "list_tables"
397 23 var out listTablesOutput
398 23
399 23 repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
400 23 if err != nil {
401 5 return out, err
402 5 }
403 18 sess, err := s.openStore(ctx, tool, repo)
404 18 if err != nil {
405 0 return out, err
406 0 }
407 18 defer sess.Close()
408 18
409 18 ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
410 18 if err != nil {
411 1 return out, err
412 1 }
413
414 17 tables, err := sess.Tables(ctx, ref)
415 17 if err != nil {
416 2 return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
417 2 }
418
419 15 out.Ref = ref
420 15 out.Tables = make([]tableJSON, 0, len(tables))
421 26 for _, t := range tables {
422 26 cols := make([]columnJSON, 0, len(t.Columns))
423 52 for _, c := range t.Columns {
424 52 cols = append(cols, columnJSON{
425 52 Name: c.Name,
426 52 Type: c.Type,
427 52 PrimaryKey: c.PrimaryKey,
428 52 Nullable: c.Nullable,
429 52 })
430 52 }
431 26 out.Tables = append(out.Tables, tableJSON{Name: t.Name, Columns: cols, RowCount: t.RowCount})
432 }
433 15 return out, nil
434 }
435
436 // readRows answers read_rows: one page of a table, with the denominator that
437 // makes the page readable as a page.
438 33 func (s *Server) readRows(ctx context.Context, in readRowsInput) (readRowsOutput, error) {
439 33 const tool = "read_rows"
440 33 var out readRowsOutput
441 33
442 33 table := strings.TrimSpace(in.Table)
443 33 if table == "" {
444 1 return out, errors.New("name the table to read; list_tables names the tables of a database")
445 1 }
446 32 offset, err := pageOffset(in.Offset)
447 32 if err != nil {
448 1 return out, err
449 1 }
450 31 limit, err := pageLimit(in.Limit, defaultRowLimit, maxRowLimit, "rows")
451 31 if err != nil {
452 2 return out, err
453 2 }
454
455 29 repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
456 29 if err != nil {
457 5 return out, err
458 5 }
459 24 sess, err := s.openStore(ctx, tool, repo)
460 24 if err != nil {
461 0 return out, err
462 0 }
463 24 defer sess.Close()
464 24
465 24 ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
466 24 if err != nil {
467 1 return out, err
468 1 }
469
470 23 page, err := sess.Rows(ctx, ref, table, offset, limit)
471 23 if err != nil {
472 3 // Two misses about a database the caller can see, and the table arm is
473 3 // asked first because it is the more specific of the two.
474 3 if errors.Is(err, browse.ErrTableNotFound) {
475 1 return out, errors.New(noSuchTable(in.databaseRef, ref, table))
476 1 }
477 2 return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
478 }
479
480 20 out = readRowsOutput{
481 20 Ref: ref,
482 20 Table: table,
483 20 Columns: page.Columns,
484 20 Rows: nullableCells(page),
485 20 Offset: page.Offset,
486 20 Limit: limit,
487 20 Total: page.Total,
488 20 // The page stopped short exactly when something is left after it. Reading
489 20 // it off the page's own offset and length, rather than off "did I get
490 20 // limit rows back", is what keeps this honest when browse returns a short
491 20 // page for a reason of its own.
492 20 Truncated: page.Offset+len(page.Rows) < page.Total,
493 20 }
494 20 if out.Columns == nil {
495 0 out.Columns = []string{}
496 0 }
497 20 return out, nil
498 }
499
500 // nullableCells reads a page's rows through the NULL mask beside them: a cell
501 // the mask reports as holding no value becomes a nil pointer, which is the JSON
502 // null a caller decodes, and every other cell is the string browse rendered.
503 //
504 // It never returns nil, so an empty page answers an empty array rather than a
505 // null one — the rule read.go states for every list on this surface.
506 //
507 // A page whose mask is missing, or shorter than its rows, reports values and
508 // never nulls. browse builds a mask parallel to the rows for every page it
509 // returns, so that arm is about a page this package did not get from browse;
510 // claiming an absence for a cell nothing was observed about would be inventing
511 // the very answer the mask exists to carry.
512 20 func nullableCells(page *browse.RowPage) [][]*string {
513 20 out := make([][]*string, 0, len(page.Rows))
514 1247 for i, row := range page.Rows {
515 1247 var mask []bool
516 1247 if i < len(page.Nulls) {
517 1247 mask = page.Nulls[i]
518 1247 }
519 1247 cells := make([]*string, len(row))
520 2494 for j, v := range row {
521 2494 if j < len(mask) && mask[j] {
522 2 continue // nil: this cell holds no value at all
523 }
524 2492 cells[j] = &v
525 }
526 1247 out = append(out, cells)
527 }
528 20 return out
529 }
530
531 // getCommitLog answers get_commit_log: a page of history with the cursor that
532 // continues it.
533 30 func (s *Server) getCommitLog(ctx context.Context, in getCommitLogInput) (getCommitLogOutput, error) {
534 30 const tool = "get_commit_log"
535 30 var out getCommitLogOutput
536 30
537 30 limit, err := pageLimit(in.Limit, defaultCommitLimit, maxCommitLimit, "commits")
538 30 if err != nil {
539 2 return out, err
540 2 }
541 28 from := strings.TrimSpace(in.From)
542 28
543 28 repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
544 28 if err != nil {
545 5 return out, err
546 5 }
547 23 sess, err := s.openStore(ctx, tool, repo)
548 23 if err != nil {
549 0 return out, err
550 0 }
551 23 defer sess.Close()
552 23
553 23 // A cursor is a point in the graph and browse.Log starts there, ignoring the
554 23 // ref entirely. Resolving a default branch anyway would cost a read and buy a
555 23 // ref the answer could not honestly claim the page belongs to.
556 23 ref := ""
557 23 if from == "" {
558 19 if ref, err = refFor(ctx, sess, tool, in.databaseRef, in.Ref); err != nil {
559 1 return out, err
560 1 }
561 }
562
563 22 commits, next, err := sess.Log(ctx, ref, from, limit)
564 22 if err != nil {
565 4 // A garbage or unknown cursor is an ordinary miss: browse wraps it in
566 4 // ErrRefNotFound the same as any other ref it cannot resolve, and refMiss
567 4 // reads that sentinel here. A genuine failure of the store still takes the
568 4 // protocol arm below.
569 4 //
570 4 // The sentence names whichever of ref and from the call actually supplied:
571 4 // with a cursor set, ref is "" (it was never resolved, see above), and a
572 4 // refusal that named it anyway would send the caller looking for a typo in
573 4 // a branch name it never typed.
574 4 missing := noSuchRef(in.databaseRef, ref)
575 4 if from != "" {
576 2 missing = noSuchCursor(in.databaseRef, from)
577 2 }
578 4 return out, refMiss(err, tool, missing)
579 }
580
581 18 out.Ref = ref
582 18 out.Limit = limit
583 18 out.Next = next
584 18 out.Truncated = next != ""
585 18 out.Commits = make([]commitJSON, 0, len(commits))
586 335 for _, c := range commits {
587 335 parents := c.ParentHashes
588 335 if parents == nil {
589 7 parents = []string{}
590 7 }
591 335 out.Commits = append(out.Commits, commitJSON{
592 335 Hash: c.Hash,
593 335 Author: c.Author,
594 335 Date: c.Date,
595 335 Message: c.Message,
596 335 Parents: parents,
597 335 })
598 }
599 18 return out, nil
600 }
601
602 // getCommitDiff answers get_commit_diff: the per-table summary browse computes
603 // for one commit against its first parent.
604 20 func (s *Server) getCommitDiff(ctx context.Context, in getCommitDiffInput) (getCommitDiffOutput, error) {
605 20 const tool = "get_commit_diff"
606 20 var out getCommitDiffOutput
607 20
608 20 hash := strings.TrimSpace(in.Hash)
609 20 if hash == "" {
610 1 return out, errors.New("name the commit to summarize; get_commit_log lists the hashes of a database")
611 1 }
612
613 19 repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
614 19 if err != nil {
615 5 return out, err
616 5 }
617 14 sess, err := s.openStore(ctx, tool, repo)
618 14 if err != nil {
619 0 return out, err
620 0 }
621 14 defer sess.Close()
622 14
623 14 // No default here: a diff is about one named commit, and defaulting to the
624 14 // head would answer a question the caller did not ask.
625 14 diff, err := sess.CommitSummary(ctx, hash)
626 14 if err != nil {
627 1 return out, refMiss(err, tool, noSuchCommit(in.databaseRef, hash))
628 1 }
629
630 13 out.Hash = diff.Hash
631 13 out.Tables = make([]tableDiffJSON, 0, len(diff.Tables))
632 16 for _, t := range diff.Tables {
633 16 out.Tables = append(out.Tables, tableDiffJSON{
634 16 Name: t.Name,
635 16 Added: t.Added,
636 16 Dropped: t.Dropped,
637 16 SchemaChanged: t.SchemaChanged,
638 16 RowsAdded: t.RowsAdded,
639 16 RowsRemoved: t.RowsRemoved,
640 16 RowsModified: t.RowsModified,
641 16 })
642 16 }
643 13 return out, nil
644 }
645
646 // --- resolving, opening, capping --------------------------------------------
647
648 // resolveDatabase turns an address into a repository the caller is allowed to
649 // read, or into the one refusal this surface has.
650 //
651 // It is the browse handlers' dance call for call (web/router.go's
652 // loadRepoForBrowse, docs/DESIGN.mcp.md §4.3): the row, the caller's ACL grant,
653 // core.Allowed for OpBrowse. Two differences from the web, both deliberate:
654 //
655 // - There is no forbidden arm. The web tells a caller who may see that a
656 // database exists but may not read it apart from one who may not learn it
657 // exists at all (core.NotFoundForPrivate); here both are the same sentence,
658 // so the distinction cannot be read out of a pair of answers. That is
659 // stricter than the web and never looser, so nothing becomes visible that
660 // was not.
661 // - An ACL lookup that fails is a protocol error, where the web degrades to
662 // "no grant" and falls back to visibility. On a page that degradation costs
663 // a signed-in user a rendering; here it would tell an agent that a database
664 // it was granted access to does not exist, and an agent believes that and
665 // rewrites its plan. "I could not check" is not "you may not".
666 270 func (s *Server) resolveDatabase(ctx context.Context, tool string, ref databaseRef) (*core.Repo, error) {
667 270 if ref.owner() == "" || ref.name() == "" {
668 2 return nil, errors.New("address a database by both its owner and its name, as list_databases reports them")
669 2 }
670
671 268 caller := callerOf(ctx)
672 268 missing := noSuchDatabase(ref)
673 268
674 268 repo, err := s.repos.GetRepoByOwnerAndName(ctx, ref.owner(), ref.name())
675 268 if err != nil {
676 19 return nil, missingOrDenied(err, tool, missing)
677 19 }
678
679 // An anonymous caller holds no ACL entry and there is no user id to look one
680 // up by; visibility decides alone, which is what core.Allowed does with a nil
681 // grant.
682 249 var mode *core.AccessMode
683 249 if caller != nil {
684 102 if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil {
685 1 return nil, internalError(err, tool)
686 1 }
687 }
688 248 if !core.Allowed(caller, repo, mode, core.OpBrowse) {
689 32 return nil, errors.New(missing)
690 32 }
691 216 return repo, nil
692 }
693
694 // openStore opens the bare store of a database the caller may read. The handler
695 // that calls it closes the session (defer sess.Close()).
696 //
697 // A store that will not open is a protocol error and not a miss: the database
698 // exists, the caller may read it, and this service could not. list_databases
699 // answers differently — it degrades that one entry and keeps listing, because a
700 // listing is about a set — but a tool whose whole subject is this database has
701 // nothing to answer with, and "not found" would be a false statement about the
702 // data rather than a true one about the server.
703 216 func (s *Server) openStore(ctx context.Context, tool string, repo *core.Repo) (BrowseSession, error) {
704 216 sess, err := s.opener.Open(ctx, repo.Path)
705 216 if err != nil {
706 1 return nil, internalError(fmt.Errorf("opening the store of %s/%s: %w", repo.OwnerName, repo.Name, err), tool)
707 1 }
708 215 return sess, nil
709 }
710
711 // refFor answers which ref a call reads at: the one it named, or the
712 // repository's default branch when it named none (docs/DESIGN.mcp.md §9.1), so
713 // that an agent which does not care about branches never has to name one.
714 //
715 // A named ref is passed through unchecked, because browse resolves a branch name
716 // *or* a commit hash and this package has no business deciding which of the two
717 // a string is. One that resolves to neither comes back as a miss from the call
718 // that used it.
719 //
720 // A database with no branches at all has no default to fall back to. That is an
721 // answer and not a failure — nothing has been pushed to it yet, exactly the
722 // database list_databases reports with a null content — so it is a sentence the
723 // agent reads rather than an error the client raises.
724 183 func refFor(ctx context.Context, sess BrowseSession, tool string, ref databaseRef, named string) (string, error) {
725 183 if named = strings.TrimSpace(named); named != "" {
726 12 return named, nil
727 12 }
728
729 171 branches, err := sess.Branches(ctx)
730 171 if err != nil {
731 0 return "", internalError(err, tool)
732 0 }
733 171 name := browse.DefaultBranch(branches)
734 171 if name == "" {
735 3 return "", errors.New(ref.String() + " has no branches: nothing has been pushed to it yet, so there is nothing to read")
736 3 }
737 168 return name, nil
738 }
739
740 // pageOffset validates the offset of a paging tool: absent is 0, and a negative
741 // one is refused rather than clamped. A caller that computed -3 has a bug, and
742 // answering its first page would hide it.
743 32 func pageOffset(want *int) (int, error) {
744 32 if want == nil {
745 28 return 0, nil
746 28 }
747 4 if *want < 0 {
748 1 return 0, fmt.Errorf("offset must be zero or more, not %d", *want)
749 1 }
750 3 return *want, nil
751 }
752
753 // pageLimit applies a cap of docs/DESIGN.mcp.md §9.3: absent takes the default,
754 // more than the maximum is answered at the maximum, and zero or less is refused.
755 //
756 // Clamping and refusing are not inconsistent. A caller asking for 5000 rows
757 // wants as many as it can have, and gets them with the applied limit stated in
758 // the answer, so nothing is hidden. A caller asking for 0 or -1 wants something
759 // that does not exist, and quietly handing it a default page would make the
760 // answer a statement about a request nobody made.
761 157 func pageLimit(want *int, def, max int, noun string) (int, error) {
762 157 if want == nil {
763 137 return def, nil
764 137 }
765 20 if *want <= 0 {
766 8 return 0, fmt.Errorf("limit must be a positive number of %s, not %d", noun, *want)
767 8 }
768 12 if *want > max {
769 4 return max, nil
770 4 }
771 8 return *want, nil
772 }
773
774 // --- the sentences a caller reads -------------------------------------------
775 //
776 // Every one of them is built from the arguments of the call being answered and
777 // never from an error's own text, which is errors.go's rule: browse names
778 // on-disk paths and dolt internals, and db/ names what it looked up.
779
780 // noSuchDatabase is the masked not-found: one sentence for a database that does
781 // not exist and for one this caller may not read, so that the two cannot be told
782 // apart by an agent probing names.
783 268 func noSuchDatabase(ref databaseRef) string {
784 268 return "no database " + ref.String() + ": it does not exist, or the credential this call carries does not reach it"
785 268 }
786
787 // noSuchRef is an ordinary answer about a database the caller *can* see, and
788 // says so by naming it — the opposite of the masked sentence above.
789 12 func noSuchRef(ref databaseRef, named string) string {
790 12 return fmt.Sprintf("%s has no branch or commit %q; list_branches names its branches", ref, named)
791 12 }
792
793 // noSuchCursor is get_commit_log's miss for a from-cursor that names no commit:
794 // the counterpart of noSuchRef for the one call where a page can be continued by
795 // hash instead of by ref, so the refusal points at "from" rather than sending the
796 // caller to list_branches over a value that was never a branch name.
797 2 func noSuchCursor(ref databaseRef, from string) string {
798 2 return fmt.Sprintf("%s has no commit %q to continue from; from takes the \"next\" hash a previous page of get_commit_log returned", ref, from)
799 2 }
800
801 1 func noSuchTable(ref databaseRef, at, table string) string {
802 1 return fmt.Sprintf("%s has no table %q at %q; list_tables names the tables there", ref, table, at)
803 1 }
804
805 1 func noSuchCommit(ref databaseRef, hash string) string {
806 1 return fmt.Sprintf("%s has no commit %q; get_commit_log lists its history", ref, hash)
807 1 }