~bigbes/sr-ht-dolt

ref: a1fe404f916f0a66242954c9843115ef7146584c sr-ht-dolt/mcpsrv/browse.go -rw-r--r-- 32.4 KiB
a1fe404f — Eugene Blikh docs: spell out the clipped-read answers 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// The generic tools of docs/DESIGN.mcp.md §9.1: the browse layer (browse/)
// offered as tools, for any hosted database rather than a beads tracker.
//
// They live beside read.go rather than in it because §9.1 and §9.2 are two
// chapters with two file sets — read.go keeps list_databases, the entry point
// whose answers every tool here takes its arguments from, and the beads tools of
// §9.2 arrive in a file of their own. One file per chapter is what keeps each of
// them readable; the registration of the whole surface still happens in one
// place (register, read.go).
//
// The rules read.go states once hold here too — the {owner, name} address,
// visibility that is applied rather than re-derived, an array for every list —
// and these are the ones this chapter adds:
//
//   - Every tool resolves the database exactly as the browse handlers do
//     (resolveDatabase): the row, the caller's ACL grant, core.Allowed. A
//     database the caller may not read is *not found*, the same sentence a name
//     nobody took gets, because a distinguishable refusal is what the 404 exists
//     to erase.
//   - A ref that does not resolve and a table that does not exist are the other
//     kind of miss entirely: they are ordinary answers *about a database the
//     caller can see*, and they name what was not found. Confusing the two would
//     either leak the existence of a private database or hide a typo behind a
//     sentence about a database the agent is looking straight at.
//   - A cap is applied and *stated*. A page that stops short says so and carries
//     the number it stopped short of, because a caller that cannot see the table
//     has no other way to tell a full answer from a clipped one (§9.3).
//   - One session per call, closed by the handler that opened it. That is the
//     browse discipline: a fresh read of the on-disk manifest, so a push that
//     landed a second ago is visible, and no handle outlives the call.

// The caps of docs/DESIGN.mcp.md §9.3.
//
// A default exists so that an agent that names no limit gets a page rather than
// a table; a maximum exists because this surface answers in one response and a
// caller can always page. Neither is silent: the answer reports the limit that
// was applied and whether anything was left behind.
const (
	defaultRowLimit = 100
	maxRowLimit     = 500

	defaultCommitLimit = 25
	maxCommitLimit     = 100
)

// databaseRef is how every tool of this chapter addresses a database: the two
// fields of the URL, both without the "~", exactly as list_databases answers
// them.
//
// It is embedded in each tool's input struct, so the derived schema carries
// owner and name as two required properties of that tool rather than as a nested
// object — an agent copying an address out of a link fills in two strings, and
// does not have to learn a wrapper type first.
type databaseRef struct {
	Owner string `json:"owner" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted)"`
	Name  string `json:"name" jsonschema:"the database name, as list_databases reports it"`
}

// owner is the username with the sigil and any stray whitespace removed. A
// leading "~" is tolerated rather than refused for read.go's reason: it is what
// a link shows, so an agent copying an address is more likely to include it than
// not.
func (r databaseRef) owner() string { return strings.TrimPrefix(strings.TrimSpace(r.Owner), "~") }

func (r databaseRef) name() string { return strings.TrimSpace(r.Name) }

// String renders the address the way a link does, which is how every sentence a
// caller reads names the database it is about.
func (r databaseRef) String() string { return "~" + r.owner() + "/" + r.name() }

// --- the shapes a caller decodes -------------------------------------------

// branchJSON is one branch and the head commit it points at.
type branchJSON struct {
	Name string `json:"name"`
	Head string `json:"head"`
}

type listBranchesInput struct {
	databaseRef
}

type listBranchesOutput struct {
	Branches []branchJSON `json:"branches"`

	// DefaultBranch is browse.DefaultBranch's answer over the list above, and it
	// is reported rather than left for the agent to work out: it is the ref every
	// tool here falls back to, so a caller that wants to name the same one
	// explicitly should not have to reimplement the rule. It is "" for a database
	// with no branches at all.
	DefaultBranch string `json:"default_branch"`
}

// columnJSON is one column of a table's schema.
type columnJSON struct {
	Name       string `json:"name"`
	Type       string `json:"type"`
	PrimaryKey bool   `json:"primary_key"`
	Nullable   bool   `json:"nullable"`
}

// tableJSON is one table at a ref: its schema and its exact row count.
type tableJSON struct {
	Name     string       `json:"name"`
	Columns  []columnJSON `json:"columns"`
	RowCount uint64       `json:"row_count"`
}

type listTablesInput struct {
	databaseRef
	Ref string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`
}

type listTablesOutput struct {
	// Ref is the ref actually read, which is the default branch when the call
	// named none. An agent that omitted it learns from the answer which branch it
	// is looking at, and can name that one for the rest of a series of calls.
	Ref    string      `json:"ref"`
	Tables []tableJSON `json:"tables"`
}

type readRowsInput struct {
	databaseRef
	Table string `json:"table" jsonschema:"the table to read, as list_tables names it"`
	Ref   string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to read at; omit it for the database's default branch"`

	// Offset and Limit are pointers so that an omitted argument and an explicit
	// zero are two different calls. An omitted limit is "you choose" and gets the
	// default; a limit of 0 was typed by the caller, means "no rows at all", and
	// is refused rather than quietly turned into 100 — a page nobody asked for
	// reads as an empty table, which is the same class of lie as a silent
	// truncation.
	Offset *int `json:"offset,omitempty" jsonschema:"how many rows to skip, zero or more; defaults to 0"`
	Limit  *int `json:"limit,omitempty" jsonschema:"how many rows to return, at most 500; defaults to 100"`
}

type readRowsOutput struct {
	Ref   string `json:"ref"`
	Table string `json:"table"`

	// Columns names the cells of every row, in order: for a keyed table the
	// primary-key columns first, then the rest.
	Columns []string `json:"columns"`

	// Rows are rendered strings and not typed values: this surface reads a bare
	// store without a SQL engine, so what it has is the stored value formatted.
	//
	// A cell that holds no value is null rather than a string. browse renders a
	// real NULL as the text "NULL", which is exactly what a row storing those
	// four characters renders as — harmless on a page, where a reader sees the
	// same thing either way, and not harmless here: this tool hands rows to a
	// machine with no schema beside them, and "is there a value in this cell?"
	// is not a question an agent can answer from the string afterwards. The mask
	// browse carries beside its rows is what makes the two distinguishable, and
	// spending it on a JSON null is the shape that needs no explaining.
	//
	// An unprintable value still reads as "<binary>" and is still not
	// distinguishable from a row that stores that text. That one stays: it
	// answers what a value *is*, and the caller can see it is there.
	Rows [][]*string `json:"rows"`

	Offset int `json:"offset"`

	// Limit is the limit that was applied, which is not always the one asked for:
	// a request above the cap is answered at the cap, and saying so here is what
	// keeps that from being a silent clamp.
	Limit int `json:"limit"`

	// Total is the number of rows in the whole table at this ref — the honest
	// denominator of the page above, straight from browse.RowPage.
	Total int `json:"total"`

	// Truncated reports that rows remain after this page (docs/DESIGN.mcp.md
	// §9.3). Raise offset to read them.
	Truncated bool `json:"truncated"`
}

// commitJSON is one commit of a log listing.
//
// The author's email is deliberately absent: the browse UI publishes the name
// and not the address (web/templates/log.html), and a surface reachable by any
// token has no business publishing more of a person than the page does.
type commitJSON struct {
	Hash    string    `json:"hash"`
	Author  string    `json:"author"`
	Date    time.Time `json:"date"`
	Message string    `json:"message"`
	Parents []string  `json:"parents"`
}

type getCommitLogInput struct {
	databaseRef
	Ref   string `json:"ref,omitempty" jsonschema:"a branch name or a commit hash to start from; omit it for the database's default branch"`
	From  string `json:"from,omitempty" jsonschema:"the \"next\" hash of a previous page, to continue where it stopped; it takes the place of ref"`
	Limit *int   `json:"limit,omitempty" jsonschema:"how many commits to return, at most 100; defaults to 25"`
}

type getCommitLogOutput struct {
	// Ref is the ref this page was read at, and is empty when the call continued
	// a cursor: a from-hash is a point in the graph and claiming it belongs to
	// some branch would be a claim this service did not check.
	Ref     string       `json:"ref"`
	Commits []commitJSON `json:"commits"`

	// Next is the hash to pass as from for the following page, or "" at the end
	// of the history.
	Next string `json:"next"`

	// Limit is the limit applied, as in readRowsOutput.
	Limit int `json:"limit"`

	// Truncated reports that the history goes on past this page.
	//
	// There is no total beside it, and that is not an omission: a commit count
	// means walking the whole graph, browse/ offers no such call, and a number
	// that expensive would be paid for by every caller of a paging tool. Next is
	// the honest continuation here — it is present exactly when something was
	// left behind.
	Truncated bool `json:"truncated"`
}

// tableDiffJSON is how one table changed in a commit.
type tableDiffJSON struct {
	Name          string `json:"name"`
	Added         bool   `json:"added"`
	Dropped       bool   `json:"dropped"`
	SchemaChanged bool   `json:"schema_changed"`

	// The row counts are exact, with one documented exception browse/ owns: when
	// the primary key set changed there is no row-level correspondence to count
	// across, so the three are 0 and SchemaChanged is true.
	RowsAdded    int64 `json:"rows_added"`
	RowsRemoved  int64 `json:"rows_removed"`
	RowsModified int64 `json:"rows_modified"`
}

type getCommitDiffInput struct {
	databaseRef
	Hash string `json:"hash" jsonschema:"the commit to summarize; a branch name is accepted and means that branch's head"`
}

type getCommitDiffOutput struct {
	// Hash is the resolved commit hash, which is worth carrying back when the
	// call named a branch: it pins which commit the answer is about even if the
	// branch moves a moment later.
	Hash   string          `json:"hash"`
	Tables []tableDiffJSON `json:"tables"`
}

// --- registration -----------------------------------------------------------

// registerBrowseTools installs the tools of docs/DESIGN.mcp.md §9.1.
//
// The descriptions are the agent-facing documentation of this surface: what the
// tool answers, how to address it, what it costs, and — for every one of them —
// what it cannot do, since an agent that knows there is no SQL here stops
// looking for it.
func (s *Server) registerBrowseTools() {
	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "list_branches",
		Annotations: readOnlyTool,
		Description: "List the branches of one hosted Dolt database, each with the hash of its head commit.\n\n" +
			"Address the database by the `owner` and `name` that list_databases reports (the two parts of " +
			"its URL, without the \"~\").\n\n" +
			"`default_branch` is the branch every other tool reads when you pass no `ref`: \"main\" when it " +
			"exists, otherwise the first branch by name. A database nothing has been pushed to yet has no " +
			"branches, and answers an empty list with an empty default.\n\n" +
			"A database you may not read is reported as not existing, which is the same answer a name " +
			"nobody took gets.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listBranchesInput) (*mcp.CallToolResult, listBranchesOutput, error) {
		out, err := s.listBranches(ctx, in)
		return nil, out, err
	})

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "list_tables",
		Annotations: readOnlyTool,
		Description: "List the tables of one hosted Dolt database at one ref, each with its columns — name, SQL " +
			"type, whether it is part of the primary key, whether it is nullable — and its exact row count.\n\n" +
			"`ref` is a branch name or a commit hash; omit it to read the database's default branch, which " +
			"the answer names back to you. A ref that matches neither is reported as such, and says so " +
			"about that database rather than pretending the database is missing.\n\n" +
			"This is the schema of the data, not a query interface: there is no SQL on this surface and " +
			"there will not be one. Read a table with read_rows and project or aggregate it yourself.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listTablesInput) (*mcp.CallToolResult, listTablesOutput, error) {
		out, err := s.listTables(ctx, in)
		return nil, out, err
	})

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "read_rows",
		Annotations: readOnlyTool,
		Description: "Read one page of rows from one table of a hosted Dolt database.\n\n" +
			"`columns` names the cells of every row in order (primary-key columns first for a keyed " +
			"table). Cells are rendered strings, because this surface reads a bare store without a SQL " +
			"engine: a cell that holds no value is `null`, and an unprintable one is the string " +
			"`<binary>`.\n\n" +
			"`limit` defaults to 100 and is capped at 500; the `limit` in the answer is the one actually " +
			"applied, so a larger request is visibly answered at the cap. `total` is the number of rows " +
			"in the whole table, and `truncated` says rows remain after this page — read them by raising " +
			"`offset`, which is O(1) here regardless of how far in it points.\n\n" +
			"Rows come back in the table's own key order and cannot be filtered or sorted server-side. " +
			"For a beads tracker, prefer the beads tools: they answer the questions this table would " +
			"make you assemble by hand.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in readRowsInput) (*mcp.CallToolResult, readRowsOutput, error) {
		out, err := s.readRows(ctx, in)
		return nil, out, err
	})

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "get_commit_log",
		Annotations: readOnlyTool,
		Description: "Read the commit history of a hosted Dolt database, newest first: hash, author, date, " +
			"message and parent hashes.\n\n" +
			"`ref` is a branch name or a commit hash to start from; omit it for the default branch. " +
			"`limit` defaults to 25 and is capped at 100.\n\n" +
			"When the history goes on past the page, `truncated` is true and `next` carries the hash of " +
			"the commit that follows it: pass that as `from` for the next page. `from` takes the place of " +
			"`ref`, so a continued page reports no ref. There is no commit total — counting one would " +
			"mean walking the entire graph — so `next` is how you tell a full answer from a clipped one.\n\n" +
			"A merge commit lists every parent; the history is walked in reverse topological order.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitLogInput) (*mcp.CallToolResult, getCommitLogOutput, error) {
		out, err := s.getCommitLog(ctx, in)
		return nil, out, err
	})

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "get_commit_diff",
		Annotations: readOnlyTool,
		Description: "Summarize what one commit changed, table by table, against its first parent — and for the " +
			"initial commit against an empty database, where every table reads as added.\n\n" +
			"`hash` is a commit hash; a branch name is accepted and means that branch's head. Each entry " +
			"says whether the table was added or dropped, whether its schema changed, and how many rows " +
			"were added, removed and modified.\n\n" +
			"The row counts are exact, with one exception: when the primary key set changed there is no " +
			"row-level correspondence to count across, so `schema_changed` is true and the three counts " +
			"are 0.\n\n" +
			"This is a summary and never a row-level diff — no cell values are reported. To see what the " +
			"rows became, read the table at this commit with read_rows, passing the hash as `ref`.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in getCommitDiffInput) (*mcp.CallToolResult, getCommitDiffOutput, error) {
		out, err := s.getCommitDiff(ctx, in)
		return nil, out, err
	})
}

// --- the handlers -----------------------------------------------------------

// listBranches answers list_branches: every branch of one database with its
// head, plus the default the other tools fall back to.
func (s *Server) listBranches(ctx context.Context, in listBranchesInput) (listBranchesOutput, error) {
	const tool = "list_branches"
	var out listBranchesOutput

	repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
	if err != nil {
		return out, err
	}
	sess, err := s.openStore(ctx, tool, repo)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	branches, err := sess.Branches(ctx)
	if err != nil {
		// Branches resolves no ref and names no table, so there is no miss it can
		// report: whatever went wrong here is this service's.
		return out, internalError(err, tool)
	}

	out.Branches = make([]branchJSON, 0, len(branches))
	for _, b := range branches {
		out.Branches = append(out.Branches, branchJSON{Name: b.Name, Head: b.Head})
	}
	out.DefaultBranch = browse.DefaultBranch(branches)
	return out, nil
}

// listTables answers list_tables: the schema of a database at one ref.
func (s *Server) listTables(ctx context.Context, in listTablesInput) (listTablesOutput, error) {
	const tool = "list_tables"
	var out listTablesOutput

	repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
	if err != nil {
		return out, err
	}
	sess, err := s.openStore(ctx, tool, repo)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
	if err != nil {
		return out, err
	}

	tables, err := sess.Tables(ctx, ref)
	if err != nil {
		return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
	}

	out.Ref = ref
	out.Tables = make([]tableJSON, 0, len(tables))
	for _, t := range tables {
		cols := make([]columnJSON, 0, len(t.Columns))
		for _, c := range t.Columns {
			cols = append(cols, columnJSON{
				Name:       c.Name,
				Type:       c.Type,
				PrimaryKey: c.PrimaryKey,
				Nullable:   c.Nullable,
			})
		}
		out.Tables = append(out.Tables, tableJSON{Name: t.Name, Columns: cols, RowCount: t.RowCount})
	}
	return out, nil
}

// readRows answers read_rows: one page of a table, with the denominator that
// makes the page readable as a page.
func (s *Server) readRows(ctx context.Context, in readRowsInput) (readRowsOutput, error) {
	const tool = "read_rows"
	var out readRowsOutput

	table := strings.TrimSpace(in.Table)
	if table == "" {
		return out, errors.New("name the table to read; list_tables names the tables of a database")
	}
	offset, err := pageOffset(in.Offset)
	if err != nil {
		return out, err
	}
	limit, err := pageLimit(in.Limit, defaultRowLimit, maxRowLimit, "rows")
	if err != nil {
		return out, err
	}

	repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
	if err != nil {
		return out, err
	}
	sess, err := s.openStore(ctx, tool, repo)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	ref, err := refFor(ctx, sess, tool, in.databaseRef, in.Ref)
	if err != nil {
		return out, err
	}

	page, err := sess.Rows(ctx, ref, table, offset, limit)
	if err != nil {
		// Two misses about a database the caller can see, and the table arm is
		// asked first because it is the more specific of the two.
		if errors.Is(err, browse.ErrTableNotFound) {
			return out, errors.New(noSuchTable(in.databaseRef, ref, table))
		}
		return out, refMiss(err, tool, noSuchRef(in.databaseRef, ref))
	}

	out = readRowsOutput{
		Ref:     ref,
		Table:   table,
		Columns: page.Columns,
		Rows:    nullableCells(page),
		Offset:  page.Offset,
		Limit:   limit,
		Total:   page.Total,
		// The page stopped short exactly when something is left after it. Reading
		// it off the page's own offset and length, rather than off "did I get
		// limit rows back", is what keeps this honest when browse returns a short
		// page for a reason of its own.
		Truncated: page.Offset+len(page.Rows) < page.Total,
	}
	if out.Columns == nil {
		out.Columns = []string{}
	}
	return out, nil
}

// nullableCells reads a page's rows through the NULL mask beside them: a cell
// the mask reports as holding no value becomes a nil pointer, which is the JSON
// null a caller decodes, and every other cell is the string browse rendered.
//
// It never returns nil, so an empty page answers an empty array rather than a
// null one — the rule read.go states for every list on this surface.
//
// A page whose mask is missing, or shorter than its rows, reports values and
// never nulls. browse builds a mask parallel to the rows for every page it
// returns, so that arm is about a page this package did not get from browse;
// claiming an absence for a cell nothing was observed about would be inventing
// the very answer the mask exists to carry.
func nullableCells(page *browse.RowPage) [][]*string {
	out := make([][]*string, 0, len(page.Rows))
	for i, row := range page.Rows {
		var mask []bool
		if i < len(page.Nulls) {
			mask = page.Nulls[i]
		}
		cells := make([]*string, len(row))
		for j, v := range row {
			if j < len(mask) && mask[j] {
				continue // nil: this cell holds no value at all
			}
			cells[j] = &v
		}
		out = append(out, cells)
	}
	return out
}

// getCommitLog answers get_commit_log: a page of history with the cursor that
// continues it.
func (s *Server) getCommitLog(ctx context.Context, in getCommitLogInput) (getCommitLogOutput, error) {
	const tool = "get_commit_log"
	var out getCommitLogOutput

	limit, err := pageLimit(in.Limit, defaultCommitLimit, maxCommitLimit, "commits")
	if err != nil {
		return out, err
	}
	from := strings.TrimSpace(in.From)

	repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
	if err != nil {
		return out, err
	}
	sess, err := s.openStore(ctx, tool, repo)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	// A cursor is a point in the graph and browse.Log starts there, ignoring the
	// ref entirely. Resolving a default branch anyway would cost a read and buy a
	// ref the answer could not honestly claim the page belongs to.
	ref := ""
	if from == "" {
		if ref, err = refFor(ctx, sess, tool, in.databaseRef, in.Ref); err != nil {
			return out, err
		}
	}

	commits, next, err := sess.Log(ctx, ref, from, limit)
	if err != nil {
		// A garbage or unknown cursor is an ordinary miss: browse wraps it in
		// ErrRefNotFound the same as any other ref it cannot resolve, and refMiss
		// reads that sentinel here. A genuine failure of the store still takes the
		// protocol arm below.
		//
		// The sentence names whichever of ref and from the call actually supplied:
		// with a cursor set, ref is "" (it was never resolved, see above), and a
		// refusal that named it anyway would send the caller looking for a typo in
		// a branch name it never typed.
		missing := noSuchRef(in.databaseRef, ref)
		if from != "" {
			missing = noSuchCursor(in.databaseRef, from)
		}
		return out, refMiss(err, tool, missing)
	}

	out.Ref = ref
	out.Limit = limit
	out.Next = next
	out.Truncated = next != ""
	out.Commits = make([]commitJSON, 0, len(commits))
	for _, c := range commits {
		parents := c.ParentHashes
		if parents == nil {
			parents = []string{}
		}
		out.Commits = append(out.Commits, commitJSON{
			Hash:    c.Hash,
			Author:  c.Author,
			Date:    c.Date,
			Message: c.Message,
			Parents: parents,
		})
	}
	return out, nil
}

// getCommitDiff answers get_commit_diff: the per-table summary browse computes
// for one commit against its first parent.
func (s *Server) getCommitDiff(ctx context.Context, in getCommitDiffInput) (getCommitDiffOutput, error) {
	const tool = "get_commit_diff"
	var out getCommitDiffOutput

	hash := strings.TrimSpace(in.Hash)
	if hash == "" {
		return out, errors.New("name the commit to summarize; get_commit_log lists the hashes of a database")
	}

	repo, err := s.resolveDatabase(ctx, tool, in.databaseRef)
	if err != nil {
		return out, err
	}
	sess, err := s.openStore(ctx, tool, repo)
	if err != nil {
		return out, err
	}
	defer sess.Close()

	// No default here: a diff is about one named commit, and defaulting to the
	// head would answer a question the caller did not ask.
	diff, err := sess.CommitSummary(ctx, hash)
	if err != nil {
		return out, refMiss(err, tool, noSuchCommit(in.databaseRef, hash))
	}

	out.Hash = diff.Hash
	out.Tables = make([]tableDiffJSON, 0, len(diff.Tables))
	for _, t := range diff.Tables {
		out.Tables = append(out.Tables, tableDiffJSON{
			Name:          t.Name,
			Added:         t.Added,
			Dropped:       t.Dropped,
			SchemaChanged: t.SchemaChanged,
			RowsAdded:     t.RowsAdded,
			RowsRemoved:   t.RowsRemoved,
			RowsModified:  t.RowsModified,
		})
	}
	return out, nil
}

// --- resolving, opening, capping --------------------------------------------

// resolveDatabase turns an address into a repository the caller is allowed to
// read, or into the one refusal this surface has.
//
// It is the browse handlers' dance call for call (web/router.go's
// loadRepoForBrowse, docs/DESIGN.mcp.md §4.3): the row, the caller's ACL grant,
// core.Allowed for OpBrowse. Two differences from the web, both deliberate:
//
//   - There is no forbidden arm. The web tells a caller who may see that a
//     database exists but may not read it apart from one who may not learn it
//     exists at all (core.NotFoundForPrivate); here both are the same sentence,
//     so the distinction cannot be read out of a pair of answers. That is
//     stricter than the web and never looser, so nothing becomes visible that
//     was not.
//   - An ACL lookup that fails is a protocol error, where the web degrades to
//     "no grant" and falls back to visibility. On a page that degradation costs
//     a signed-in user a rendering; here it would tell an agent that a database
//     it was granted access to does not exist, and an agent believes that and
//     rewrites its plan. "I could not check" is not "you may not".
func (s *Server) resolveDatabase(ctx context.Context, tool string, ref databaseRef) (*core.Repo, error) {
	if ref.owner() == "" || ref.name() == "" {
		return nil, errors.New("address a database by both its owner and its name, as list_databases reports them")
	}

	caller := callerOf(ctx)
	missing := noSuchDatabase(ref)

	repo, err := s.repos.GetRepoByOwnerAndName(ctx, ref.owner(), ref.name())
	if err != nil {
		return nil, missingOrDenied(err, tool, missing)
	}

	// An anonymous caller holds no ACL entry and there is no user id to look one
	// up by; visibility decides alone, which is what core.Allowed does with a nil
	// grant.
	var mode *core.AccessMode
	if caller != nil {
		if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil {
			return nil, internalError(err, tool)
		}
	}
	if !core.Allowed(caller, repo, mode, core.OpBrowse) {
		return nil, errors.New(missing)
	}
	return repo, nil
}

// openStore opens the bare store of a database the caller may read. The handler
// that calls it closes the session (defer sess.Close()).
//
// A store that will not open is a protocol error and not a miss: the database
// exists, the caller may read it, and this service could not. list_databases
// answers differently — it degrades that one entry and keeps listing, because a
// listing is about a set — but a tool whose whole subject is this database has
// nothing to answer with, and "not found" would be a false statement about the
// data rather than a true one about the server.
func (s *Server) openStore(ctx context.Context, tool string, repo *core.Repo) (BrowseSession, error) {
	sess, err := s.opener.Open(ctx, repo.Path)
	if err != nil {
		return nil, internalError(fmt.Errorf("opening the store of %s/%s: %w", repo.OwnerName, repo.Name, err), tool)
	}
	return sess, nil
}

// refFor answers which ref a call reads at: the one it named, or the
// repository's default branch when it named none (docs/DESIGN.mcp.md §9.1), so
// that an agent which does not care about branches never has to name one.
//
// A named ref is passed through unchecked, because browse resolves a branch name
// *or* a commit hash and this package has no business deciding which of the two
// a string is. One that resolves to neither comes back as a miss from the call
// that used it.
//
// A database with no branches at all has no default to fall back to. That is an
// answer and not a failure — nothing has been pushed to it yet, exactly the
// database list_databases reports with a null content — so it is a sentence the
// agent reads rather than an error the client raises.
func refFor(ctx context.Context, sess BrowseSession, tool string, ref databaseRef, named string) (string, error) {
	if named = strings.TrimSpace(named); named != "" {
		return named, nil
	}

	branches, err := sess.Branches(ctx)
	if err != nil {
		return "", internalError(err, tool)
	}
	name := browse.DefaultBranch(branches)
	if name == "" {
		return "", errors.New(ref.String() + " has no branches: nothing has been pushed to it yet, so there is nothing to read")
	}
	return name, nil
}

// pageOffset validates the offset of a paging tool: absent is 0, and a negative
// one is refused rather than clamped. A caller that computed -3 has a bug, and
// answering its first page would hide it.
func pageOffset(want *int) (int, error) {
	if want == nil {
		return 0, nil
	}
	if *want < 0 {
		return 0, fmt.Errorf("offset must be zero or more, not %d", *want)
	}
	return *want, nil
}

// pageLimit applies a cap of docs/DESIGN.mcp.md §9.3: absent takes the default,
// more than the maximum is answered at the maximum, and zero or less is refused.
//
// Clamping and refusing are not inconsistent. A caller asking for 5000 rows
// wants as many as it can have, and gets them with the applied limit stated in
// the answer, so nothing is hidden. A caller asking for 0 or -1 wants something
// that does not exist, and quietly handing it a default page would make the
// answer a statement about a request nobody made.
func pageLimit(want *int, def, max int, noun string) (int, error) {
	if want == nil {
		return def, nil
	}
	if *want <= 0 {
		return 0, fmt.Errorf("limit must be a positive number of %s, not %d", noun, *want)
	}
	if *want > max {
		return max, nil
	}
	return *want, nil
}

// --- the sentences a caller reads -------------------------------------------
//
// Every one of them is built from the arguments of the call being answered and
// never from an error's own text, which is errors.go's rule: browse names
// on-disk paths and dolt internals, and db/ names what it looked up.

// noSuchDatabase is the masked not-found: one sentence for a database that does
// not exist and for one this caller may not read, so that the two cannot be told
// apart by an agent probing names.
func noSuchDatabase(ref databaseRef) string {
	return "no database " + ref.String() + ": it does not exist, or the credential this call carries does not reach it"
}

// noSuchRef is an ordinary answer about a database the caller *can* see, and
// says so by naming it — the opposite of the masked sentence above.
func noSuchRef(ref databaseRef, named string) string {
	return fmt.Sprintf("%s has no branch or commit %q; list_branches names its branches", ref, named)
}

// noSuchCursor is get_commit_log's miss for a from-cursor that names no commit:
// the counterpart of noSuchRef for the one call where a page can be continued by
// hash instead of by ref, so the refusal points at "from" rather than sending the
// caller to list_branches over a value that was never a branch name.
func noSuchCursor(ref databaseRef, from string) string {
	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)
}

func noSuchTable(ref databaseRef, at, table string) string {
	return fmt.Sprintf("%s has no table %q at %q; list_tables names the tables there", ref, table, at)
}

func noSuchCommit(ref databaseRef, hash string) string {
	return fmt.Sprintf("%s has no commit %q; get_commit_log lists its history", ref, hash)
}