~bigbes/sr-ht-dolt

ref: 8dfe078bcda61dc7ec82d607468747771d9dfa41 sr-ht-dolt/mcpsrv/read.go -rw-r--r-- 14.0 KiB
8dfe078b — Eugene Blikh beads: report a clipped read from the remaining projections 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
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"go.bigb.es/auxilia/scribe"

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

// The tools of docs/DESIGN.mcp.md ch. 9. This commit registers one —
// list_databases, the entry point every other tool's arguments are built from —
// and the rules below are written once here because they hold for all of them:
//
//   - A database is addressed as {owner, name}, both without the "~". That is
//     what the URL says and what an agent can copy out of a link, and it is why
//     this listing answers with the two fields separately rather than with one
//     pre-joined string a tool would then have to take apart.
//   - Visibility is not re-implemented. The listings apply the listing rule on
//     the far side of the seam (ports.go), and every tool that resolves a named
//     database will reproduce the browse dance instead of inventing a second
//     reading of core.Allowed.
//   - Nothing derived is recomputed here: the default branch is
//     browse.DefaultBranch, the beads fingerprint is beads.Applies. A second
//     copy of either is how the board and this surface would start disagreeing
//     about what a beads database is.
//   - Every list is an array — empty rather than null — so an agent can loop
//     without a nil check, and anything genuinely unknown is a null rather than
//     a zero value that reads as an answer.

// databaseJSON is one hosted database as list_databases reports it.
//
// The row's id, and the on-disk path it is served from, are both deliberately
// absent. A database is addressed as {owner, name} on every surface, and
// publishing an internal key an agent has no tool to use would only invite the
// next tool to accept one; the path is the deployment's and no caller's.
type databaseJSON struct {
	Owner       string          `json:"owner"`
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Visibility  core.Visibility `json:"visibility"`

	// Content is everything that had to be read out of the database itself, and
	// it is one nullable object rather than four nullable fields so that "the
	// store could not be read" is one statement an agent checks once.
	//
	// A null Content with an empty ContentError is a database that exists and
	// carries no commits yet — a store created by a push that has not arrived.
	// A null Content *with* a ContentError is a store this daemon could not
	// read; the metadata beside it still came from Postgres and is still true.
	Content *databaseContentJSON `json:"content"`

	// ContentError is a fixed sentence, never the underlying failure: the real
	// one names on-disk paths and dolt internals. The cause is logged instead.
	ContentError string `json:"content_error,omitempty"`
}

// databaseContentJSON is what one read of a database's store answers about it
// at its default branch.
type databaseContentJSON struct {
	// DefaultBranch is browse.DefaultBranch's answer — "main" when it exists,
	// otherwise the first branch by name. It is the ref every tool that takes an
	// optional one falls back to, so an agent that does not care about branches
	// never has to name one.
	DefaultBranch string `json:"default_branch"`

	// Head is the hash of that branch's head commit, as the branch itself
	// reports it.
	Head string `json:"head"`

	// HeadTime is when that commit was authored. It is null only if the branch
	// head could not be read as a commit, which is a broken store rather than a
	// young one.
	HeadTime *time.Time `json:"head_time"`

	// IsBeads reports whether the tables at the default branch carry the beads
	// fingerprint (beads.Applies), i.e. whether the beads-aware tools of ch. 9.2
	// will answer about this database. It is the one reading of that fingerprint
	// on this instance, shared with the web board.
	IsBeads bool `json:"is_beads"`
}

// listDatabasesInput carries the one argument today's queries make necessary.
//
// docs/DESIGN.mcp.md §9.1 gives this tool no arguments at all — "every database
// the caller may list" — and that is not answerable with the queries this
// service has. db/repos.go enumerates in exactly two ways: by owner
// (ListReposByOwner, which applies the listing rule including anonymity) and by
// membership (ListReposForDashboard, "owned or ACL'd", which needs a user id).
// There is no "every PUBLIC database on the instance" query, and inventing one
// is a change to db/'s file set rather than to this one. So the argument is
// optional and the two arms are exactly the two queries.
type listDatabasesInput struct {
	// Owner is a SourceHut username without the "~". A leading one is tolerated
	// rather than refused: it is what a link shows, so an agent copying an
	// address is more likely to include it than not, and refusing it would be a
	// sentence about punctuation in place of an answer.
	Owner string `json:"owner,omitempty" jsonschema:"the SourceHut username whose databases to list, without the \"~\". Omit it to list your own — the databases you own or hold an ACL entry on — which requires a credential."`
}

// listDatabasesOutput wraps the array in an object rather than serving a bare
// one, so that a later addition is a new field and not a change of the
// document's type.
type listDatabasesOutput struct {
	Databases []databaseJSON `json:"databases"`
}

// contentUnreadable is the ContentError sentence. It is one string for every
// cause — a missing store dir, a corrupt manifest, a ref that will not resolve —
// because the difference is the operator's business (it is in the log) and not
// the agent's: nothing an agent can do about a broken store differs by cause.
const contentUnreadable = "this database's store could not be read on the server; its metadata below is still accurate"

// readOnlyTool is the annotation every tool of this surface carries, and it is
// one shared value rather than one per registration so that "every tool here is
// a read" is a property of the package instead of a habit five call sites are
// keeping up. It tells a client it may retry a call freely; this surface has no
// write tool at all, and ports.go is why it cannot grow one by accident.
var readOnlyTool = &mcp.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true}

// register installs the tools on the protocol server.
//
// The whole surface is registered from this one function — the chapters live in
// their own files, but what an agent is offered is listed in a single place, so
// that reading it answers "what can be called here" completely.
//
// The descriptions are the agent-facing documentation of this service and are
// written for a reader who has never seen the design document — what the tool
// answers, how to address what it answers about, and what it cannot answer.
func (s *Server) register() {
	// The generic tools of docs/DESIGN.mcp.md §9.1, over the browse seam.
	s.registerBrowseTools()

	// The beads-aware tools of docs/DESIGN.mcp.md §9.2, over the beads
	// projection. They are advertised for every database on this instance — MCP's
	// tool list is static per server — and refuse per database, which is why
	// registering them is unconditional here.
	s.registerBeadsTools()

	// The last of §9.2, in a file of its own because it is the one tool here that
	// answers about a *set* of databases: ready_work with no database named is
	// the cross-database aggregation of docs/DESIGN.views.md ch. 4, the same
	// function the /ready page renders.
	s.registerReadyWork()

	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "list_databases",
		Annotations: readOnlyTool,
		Description: "List hosted Dolt databases with their visibility, description, default branch and " +
			"head commit, and whether each one is a beads issue tracker.\n\n" +
			"Pass `owner` — a SourceHut username without the \"~\" — to list that user's databases. " +
			"Omit it to list your own: everything you own or have been granted access to, which needs " +
			"a bearer token.\n\n" +
			"There is no way to enumerate every database on this instance: the service can only " +
			"answer per owner, or about you. Without `owner` and without a credential there is " +
			"nothing to list, and the call says so.\n\n" +
			"What you see depends on the token you present: a user's public databases to everyone, " +
			"plus any of theirs you own or hold access to. An unlisted database of somebody else " +
			"never appears here and is still readable if you address it directly; a private one you " +
			"have no access to is reported as not existing, which is the same answer a name nobody " +
			"took gets.\n\n" +
			"Address a database in the other tools as the `owner` and `name` of an entry here. " +
			"`content` is null for a database with no commits yet, and carries `content_error` when " +
			"its store could not be read.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in listDatabasesInput) (*mcp.CallToolResult, listDatabasesOutput, error) {
		out, err := s.listDatabases(ctx, in)
		return nil, out, err
	})
}

// listDatabases answers list_databases: the databases the caller may list, each
// described by one read of its store.
//
// The two arms are the two queries db/ has, and neither of them is this
// package's reading of visibility — ListReposByOwner applies the listing rule
// (PUBLIC to anyone including anonymity, plus what the viewer owns or is ACL'd
// on) and ListReposForDashboard is the caller's own membership. An UNLISTED
// database of another owner is absent from both, which is the design's rule and
// the dashboard's behaviour, not a narrowing invented here.
func (s *Server) listDatabases(ctx context.Context, in listDatabasesInput) (listDatabasesOutput, error) {
	caller := callerOf(ctx)
	owner := strings.TrimPrefix(strings.TrimSpace(in.Owner), "~")

	var (
		repos []*core.Repo
		err   error
	)
	switch {
	case owner != "":
		repos, err = s.repos.ListReposByOwner(ctx, owner, caller)
	case caller == nil:
		// A tool result and not a protocol error: the call was understood, and
		// what it asked for cannot exist rather than could not be produced. The
		// sentence names the way out, because there is one.
		return listDatabasesOutput{}, errors.New(
			"no owner was named and this call carries no credential, so there is nothing to list: " +
				"pass owner to list one user's public databases, or present a bearer token to list your own")
	default:
		repos, err = s.repos.ListReposForDashboard(ctx, caller.UserID)
	}
	if err != nil {
		// No database was addressed, so there is no "that one does not exist" to
		// answer: whatever went wrong enumerating them is this service's.
		return listDatabasesOutput{}, internalError(err, "list_databases")
	}

	out := make([]databaseJSON, 0, len(repos))
	for _, repo := range repos {
		out = append(out, s.describe(ctx, repo))
	}
	return listDatabasesOutput{Databases: out}, nil
}

// describe renders one repository row and adds what its store says about
// itself.
//
// A store that cannot be read costs this database its content and nothing more:
// the listing still names it, with the metadata Postgres holds, and says the
// store could not be read. Two alternatives were rejected. Failing the whole
// call would let one broken store hide every other database from every caller;
// and answering `is_beads: false` for a tracker whose store did not open would
// be a lie an agent has no way to detect, which is the failure mode the whole
// truncation rule of ch. 9.3 exists to avoid.
func (s *Server) describe(ctx context.Context, repo *core.Repo) databaseJSON {
	out := databaseJSON{
		Owner:       repo.OwnerName,
		Name:        repo.Name,
		Description: repo.Description,
		Visibility:  repo.Visibility,
	}

	content, err := s.readContent(ctx, repo)
	if err != nil {
		slog.Error("a hosted store could not be read for list_databases",
			"owner", repo.OwnerName, "database", repo.Name, scribe.Err(err))
		out.ContentError = contentUnreadable
		return out
	}
	out.Content = content
	return out
}

// readContent opens one bare store and reads the three things a listing entry
// carries: the default branch, its head, and whether the tables there are a
// beads tracker.
//
// It returns (nil, nil) for a database with no branches — a store that exists
// and has never been pushed to. That is an answer and not a failure, and it is
// distinguishable from a failure because a failure returns an error.
//
// The session is opened per call and closed here, which is the browse
// discipline: a fresh read of the on-disk manifest every time, so a push that
// landed a second ago is visible and nothing is cached between calls. It is
// also the cost of this tool — one store opened per database listed — and the
// reason the listing carries a head and a fingerprint rather than every tool
// having to ask for them separately.
func (s *Server) readContent(ctx context.Context, repo *core.Repo) (*databaseContentJSON, error) {
	sess, err := s.opener.Open(ctx, repo.Path)
	if err != nil {
		return nil, fmt.Errorf("opening the store: %w", err)
	}
	defer sess.Close()

	branches, err := sess.Branches(ctx)
	if err != nil {
		return nil, fmt.Errorf("listing branches: %w", err)
	}
	name := browse.DefaultBranch(branches)
	if name == "" {
		return nil, nil
	}

	content := &databaseContentJSON{DefaultBranch: name, Head: headOf(branches, name)}

	commits, _, err := sess.Log(ctx, name, "", 1)
	if err != nil {
		return nil, fmt.Errorf("reading the head commit of %q: %w", name, err)
	}
	if len(commits) > 0 {
		at := commits[0].Date
		content.HeadTime = &at
	}

	tables, err := sess.Tables(ctx, name)
	if err != nil {
		return nil, fmt.Errorf("listing tables at %q: %w", name, err)
	}
	content.IsBeads = beads.Applies(tables)

	return content, nil
}

// headOf returns the head hash the branch list carries for name, or "" if the
// list does not name it — which browse.DefaultBranch's contract makes
// impossible, since it picks out of this very list.
func headOf(branches []browse.Branch, name string) string {
	for _, b := range branches {
		if b.Name == name {
			return b.Head
		}
	}
	return ""
}