| 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 |
} |