~bigbes/sr-ht-dolt

ref: dcdc9790d7fa085b5238eb39f196ccc8a2c0d5da sr-ht-dolt/mcpsrv/mcpsrv_test.go -rw-r--r-- 27.0 KiB
dcdc9790 — Eugene Blikh mcpsrv: serve a stateless read-only MCP surface 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
package mcpsrv_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"testing"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

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

// The tool half of the suite: what the surface answers, driven through a real
// in-process MCP client over fakes — no Postgres, no store on disk. The
// transport half (who the answer is computed for, and what a request refused
// before a handler looks like) is http_test.go.

// testOrigin is what [dolt.sr.ht]origin says on the instance these tests
// pretend to be, and therefore the Host the allowlist admits.
const testOrigin = "https://dolt.example.org"

// --- the fixture ------------------------------------------------------------
//
// One owner with one database of every shape that matters, because the two
// things this surface has to get right are both properties of a *set*: which
// databases a caller may list, and what a listing says about one it cannot fully
// read.

const (
	aliceID = 1 // the owner of every fixture database
	bobID   = 2 // a grantee: an ACL entry on the private one and nothing else
	carolID = 3 // a stranger: an account with no relationship to any of them
)

// storePath is the on-disk store dir of a database, as db/ stores it on the
// row. It never leaves the server (nothing in a tool result carries it), which
// is why the fakes key on it rather than on a name.
func storePath(owner, name string) string { return "/stores/~" + owner + "/" + name }

type fixture struct {
	name       string
	visibility core.Visibility
	acl        map[int]core.AccessMode

	// session is what opening this database's store yields. A nil one is a store
	// that will not open at all, which is the entry a listing has to survive.
	session *fakeSession
}

func fixtures() []fixture {
	return []fixture{
		{
			name:       "notes",
			visibility: core.VisibilityPublic,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "aaaa"}},
				commits:  []browse.CommitInfo{{Hash: "aaaa", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "notes", Columns: []browse.ColumnInfo{{Name: "id"}}}},
			},
		},
		{
			// The one a beads-aware tool will answer about: its tables carry the
			// fingerprint beads.Applies looks for.
			name:       "tracker",
			visibility: core.VisibilityPublic,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "bbbb"}},
				commits:  []browse.CommitInfo{{Hash: "bbbb", Date: headTime}},
				tables:   beadsTables(),
			},
		},
		{
			// A store created by a repository that has never been pushed to.
			name:       "empty",
			visibility: core.VisibilityPublic,
			session:    &fakeSession{},
		},
		{
			// A store this daemon cannot open. It is in the fixture rather than in
			// one test of its own because the property worth pinning is that it
			// does not take the rest of the listing with it.
			name:       "broken",
			visibility: core.VisibilityPublic,
			session:    nil,
		},
		{
			name:       "drafts",
			visibility: core.VisibilityUnlisted,
			session: &fakeSession{
				branches: []browse.Branch{{Name: "main", Head: "dddd"}},
				commits:  []browse.CommitInfo{{Hash: "dddd", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "drafts"}},
			},
		},
		{
			name:       "secrets",
			visibility: core.VisibilityPrivate,
			acl:        map[int]core.AccessMode{bobID: core.AccessRO},
			session: &fakeSession{
				branches: []browse.Branch{{Name: "release", Head: "eeee"}},
				commits:  []browse.CommitInfo{{Hash: "eeee", Date: headTime}},
				tables:   []browse.TableInfo{{Name: "secrets"}},
			},
		},
	}
}

// headTime is the commit time every fixture head carries, fixed so that a test
// can assert the value rather than merely that something was rendered.
var headTime = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)

// beadsTables is the minimum beads.Applies accepts: issues + dependencies, with
// issues carrying id and status. The fingerprint is beads/'s and is not restated
// here — this is a fixture that satisfies it, not a second copy of it.
func beadsTables() []browse.TableInfo {
	return []browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}, {Name: "title"}}},
		{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "from_id"}, {Name: "to_id"}}},
	}
}

// listable is the listing rule of docs/DESIGN.mcp.md §4.3 spelled out
// independently of the implementation under test: PUBLIC to everyone including
// anonymity, plus whatever the viewer owns or holds an ACL entry on. UNLISTED
// and PRIVATE are absent for everybody else.
//
// It is written here rather than derived from the fake so that the expectation
// and the fake cannot drift into agreement with each other and away from the
// rule.
func listable(f fixture, viewer *core.Caller) bool {
	if f.visibility == core.VisibilityPublic {
		return true
	}
	if viewer == nil {
		return false
	}
	if viewer.UserID == aliceID {
		return true // alice owns every fixture
	}
	_, ok := f.acl[viewer.UserID]
	return ok
}

func listableNames(viewer *core.Caller) []string {
	var out []string
	for _, f := range fixtures() {
		if listable(f, viewer) {
			out = append(out, f.name)
		}
	}
	return out
}

// --- the fakes --------------------------------------------------------------

// fakeRepos is the metadata store: the fixture rows, plus the two listing
// queries db/repos.go actually has and the ACL lookup. It applies db/'s SQL as
// Go, and nothing else — no visibility rule beyond the one ListReposByOwner
// documents, so a surface that leaned on the store to hide something would fail
// here rather than pass by accident.
type fakeRepos struct {
	repos []*core.Repo
	acl   map[int]map[int]core.AccessMode // repoID -> userID -> mode

	// listErr, when set, is what both listings answer: a metadata store that
	// could not be read.
	listErr error
}

var _ mcpsrv.Repos = (*fakeRepos)(nil)

func newFakeRepos() *fakeRepos {
	f := &fakeRepos{acl: map[int]map[int]core.AccessMode{}}
	for i, fx := range fixtures() {
		id := i + 1
		f.repos = append(f.repos, &core.Repo{
			ID:          id,
			Name:        fx.name,
			Description: "the " + fx.name + " database",
			OwnerID:     aliceID,
			OwnerName:   "alice",
			Path:        storePath("alice", fx.name),
			Visibility:  fx.visibility,
		})
		for userID, mode := range fx.acl {
			if f.acl[id] == nil {
				f.acl[id] = map[int]core.AccessMode{}
			}
			f.acl[id][userID] = mode
		}
	}
	return f
}

func (f *fakeRepos) GetRepoByOwnerAndName(_ context.Context, owner, name string) (*core.Repo, error) {
	for _, r := range f.repos {
		if r.OwnerName == owner && r.Name == name {
			return r, nil
		}
	}
	return nil, db.ErrNotFound
}

func (f *fakeRepos) ListReposByOwner(_ context.Context, owner string, viewer *core.Caller) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerName != owner {
			continue
		}
		visible := r.Visibility == core.VisibilityPublic
		if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
			visible = true
		}
		if visible {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) {
	if f.listErr != nil {
		return nil, f.listErr
	}
	var out []*core.Repo
	for _, r := range f.repos {
		if r.OwnerID == userID || f.hasACL(r.ID, userID) {
			out = append(out, r)
		}
	}
	return out, nil
}

func (f *fakeRepos) EffectiveAccess(_ context.Context, userID, repoID int) (*core.AccessMode, error) {
	mode, ok := f.acl[repoID][userID]
	if !ok {
		return nil, nil
	}
	return &mode, nil
}

func (f *fakeRepos) hasACL(repoID, userID int) bool {
	_, ok := f.acl[repoID][userID]
	return ok
}

// fakeOpener resolves a store path to its fixture session. A path with no
// session is a store that will not open, which is how the "broken" fixture
// behaves.
type fakeOpener struct {
	sessions map[string]*fakeSession
	opened   []string
}

var _ mcpsrv.BrowseOpener = (*fakeOpener)(nil)

func newFakeOpener() *fakeOpener {
	o := &fakeOpener{sessions: map[string]*fakeSession{}}
	for _, fx := range fixtures() {
		if fx.session != nil {
			o.sessions[storePath("alice", fx.name)] = fx.session
		}
	}
	return o
}

func (o *fakeOpener) Open(_ context.Context, diskPath string) (mcpsrv.BrowseSession, error) {
	o.opened = append(o.opened, diskPath)
	sess, ok := o.sessions[diskPath]
	if !ok {
		return nil, fmt.Errorf("no store at %s", diskPath)
	}
	return sess, nil
}

// fakeSession is one bare store as browse/ reads it. Only the three methods
// list_databases calls answer; the rest are the seam's, waiting for the tools of
// ch. 9.1, and a handler that reached one here would fail loudly rather than
// read an empty result.
type fakeSession struct {
	branches []browse.Branch
	commits  []browse.CommitInfo
	tables   []browse.TableInfo

	logErr    error
	tablesErr error

	closes int
}

var _ mcpsrv.BrowseSession = (*fakeSession)(nil)

func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }

func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
	if s.logErr != nil {
		return nil, "", s.logErr
	}
	return s.commits, "", nil
}

func (s *fakeSession) Tables(context.Context, string) ([]browse.TableInfo, error) {
	if s.tablesErr != nil {
		return nil, s.tablesErr
	}
	return s.tables, nil
}

func (s *fakeSession) TableHash(context.Context, string, string) (string, bool, error) {
	panic("TableHash: no tool of this phase reads a table hash")
}

func (s *fakeSession) Rows(context.Context, string, string, int, int) (*browse.RowPage, error) {
	panic("Rows: no tool of this phase reads rows")
}

func (s *fakeSession) CommitSummary(context.Context, string) (*browse.CommitDiff, error) {
	panic("CommitSummary: no tool of this phase reads a diff")
}

func (s *fakeSession) Close() error {
	s.closes++
	return nil
}

// --- callers ----------------------------------------------------------------

// The three principals of the visibility matrix, as the *auth.AuthContext every
// plane of this service produces. Anonymous is a nil one, and it is a caller
// like any other.
func alice() *auth.AuthContext { return user(aliceID, "alice") }
func bob() *auth.AuthContext   { return user(bobID, "bob") }
func carol() *auth.AuthContext { return user(carolID, "carol") }

func user(id int, name string) *auth.AuthContext {
	return &auth.AuthContext{UserID: id, Username: name, UserType: auth.USER_TYPE_USER}
}

// authnContext is a context carrying ac as the resolved caller, exactly as the
// credential middleware leaves it — authn.WithCaller stores a nil one as-is and
// CallerFromContext reads it back as anonymous, which is why an anonymous test
// needs no special case.
func authnContext(ac *auth.AuthContext) context.Context {
	return authn.WithCaller(context.Background(), ac)
}

func coreCaller(ac *auth.AuthContext) *core.Caller {
	if ac == nil {
		return nil
	}
	return &core.Caller{UserID: ac.UserID, Username: ac.Username, UserType: core.UserType(ac.UserType)}
}

// --- plumbing ---------------------------------------------------------------

// newServer builds the surface over the fakes, failing the test on a wiring
// error rather than returning one.
func newServer(t *testing.T, repos mcpsrv.Repos, opener mcpsrv.BrowseOpener) *mcpsrv.Server {
	t.Helper()
	s, err := mcpsrv.New(repos, opener, nil, testOrigin)
	require.NoError(t, err)
	return s
}

// connect runs an in-process MCP client against the real server, with ac as the
// caller.
//
// The caller is injected by connecting the session with a context carrying it,
// which is exactly what happens in production: the SDK connects a session with
// the context of the HTTP request, and every tool handler descends from it. Over
// HTTP the credential middleware puts it there (http_test.go drives that path);
// here the test puts it there directly, and the handlers cannot tell.
func connect(t *testing.T, s *mcpsrv.Server, ac *auth.AuthContext) *mcp.ClientSession {
	t.Helper()
	ctx := authnContext(ac)

	serverTransport, clientTransport := mcp.NewInMemoryTransports()
	serverConn, err := s.Connect(ctx, serverTransport)
	require.NoError(t, err)
	t.Cleanup(func() { _ = serverConn.Close() })

	client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil)
	session, err := client.Connect(context.Background(), clientTransport, nil)
	require.NoError(t, err)
	t.Cleanup(func() { _ = session.Close() })
	return session
}

// call makes a tool call, failing the test on a protocol error — which is the
// distinction errors.go draws: a missing database is a result, a store that
// could not answer is a protocol error, and a test that conflated them would
// pass for the wrong reason.
func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult {
	t.Helper()
	res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
	require.NoError(t, err, "protocol-level failure calling %s", name)
	return res
}

// decode reads a successful tool result into out, asserting it is not an error
// result on the way.
func decode(t *testing.T, res *mcp.CallToolResult, out any) {
	t.Helper()
	require.False(t, res.IsError, "unexpected tool error: %s", errorText(res))
	require.NotNil(t, res.StructuredContent, "no structured output")
	raw, err := json.Marshal(res.StructuredContent)
	require.NoError(t, err)
	require.NoError(t, json.Unmarshal(raw, out))
}

// errorText is the message of an error result, which is where this surface's
// refusals are written.
func errorText(res *mcp.CallToolResult) string {
	var s string
	for _, c := range res.Content {
		if tc, ok := c.(*mcp.TextContent); ok {
			s += tc.Text
		}
	}
	return s
}

// resultJSON is the whole result as it went over the wire — content blocks
// included, not just the structured half — for the tests that assert about
// everything a client can see.
func resultJSON(t *testing.T, res *mcp.CallToolResult) string {
	t.Helper()
	raw, err := json.Marshal(res)
	require.NoError(t, err)
	return string(raw)
}

// The shapes a client decodes into, spelled out here rather than exported from
// the package: they are this surface's contract, and a test that reused the
// production structs would pass no matter what those structs said.
type (
	listDatabasesResult struct {
		Databases []databaseResult `json:"databases"`
	}

	databaseResult struct {
		Owner        string           `json:"owner"`
		Name         string           `json:"name"`
		Description  string           `json:"description"`
		Visibility   string           `json:"visibility"`
		Content      *databaseContent `json:"content"`
		ContentError string           `json:"content_error"`
	}

	databaseContent struct {
		DefaultBranch string     `json:"default_branch"`
		Head          string     `json:"head"`
		HeadTime      *time.Time `json:"head_time"`
		IsBeads       bool       `json:"is_beads"`
	}
)

func names(res listDatabasesResult) []string {
	out := make([]string, 0, len(res.Databases))
	for _, d := range res.Databases {
		out = append(out, d.Name)
	}
	return out
}

func listDatabases(t *testing.T, session *mcp.ClientSession, args map[string]any) listDatabasesResult {
	t.Helper()
	var out listDatabasesResult
	decode(t, call(t, session, "list_databases", args), &out)
	return out
}

// --- the constructor --------------------------------------------------------

func TestNewRejectsAMissingSeam(t *testing.T) {
	opener := newFakeOpener()
	repos := newFakeRepos()

	_, err := mcpsrv.New(nil, opener, nil, testOrigin)
	require.Error(t, err, "a surface with no metadata store would answer every call internal error")
	assert.Contains(t, err.Error(), "Repos")

	_, err = mcpsrv.New(repos, nil, nil, testOrigin)
	require.Error(t, err, "a surface that cannot open a store cannot describe one")
	assert.Contains(t, err.Error(), "BrowseOpener")
}

// A nil InstanceValidator is a configuration and not a missing seam: an instance
// with no [tokens.sr.ht] section still serves meta PATs and anonymous callers
// (authn.ResolveBearer's documented contract, docs/DESIGN.mcp.md §10).
func TestNewAcceptsNoTokensDaemon(t *testing.T) {
	s, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, testOrigin)
	require.NoError(t, err)
	require.NotNil(t, s)

	session := connect(t, s, nil)
	assert.NotEmpty(t, listDatabases(t, session, map[string]any{"owner": "alice"}).Databases,
		"an instance without a token daemon still answers an anonymous caller")
}

// The Host allowlist is derived from the origin, so an origin with no host is a
// constructor error rather than a guessed "localhost" — which would make every
// malformed origin agree with a local client on the one code path that decides
// the allowlist.
func TestNewRequiresAnOriginToGuardWith(t *testing.T) {
	for _, origin := range []string{"", "   ", "not a url", "/relative/path"} {
		t.Run(fmt.Sprintf("%q", origin), func(t *testing.T) {
			_, err := mcpsrv.New(newFakeRepos(), newFakeOpener(), nil, origin)
			require.Error(t, err)
			assert.Contains(t, err.Error(), "no host")
		})
	}
}

// --- the tool ---------------------------------------------------------------

func TestServerAdvertisesTheToolsOfThisPhase(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	require.Equal(t, mcpsrv.ServerName, session.InitializeResult().ServerInfo.Name)

	res, err := session.ListTools(context.Background(), nil)
	require.NoError(t, err)

	var got []string
	for _, tool := range res.Tools {
		got = append(got, tool.Name)
		assert.NotEmpty(t, tool.Description, "%s: an agent reads the description as its documentation", tool.Name)
		require.NotNil(t, tool.Annotations, "%s: every tool here is a read and must say so", tool.Name)
		assert.True(t, tool.Annotations.ReadOnlyHint, "%s", tool.Name)
		require.NotNil(t, tool.InputSchema, "%s: the schema is derived from the Go struct", tool.Name)
	}
	assert.Equal(t, []string{"list_databases"}, got,
		"this phase registers exactly one tool; the rest arrive with their own commits")
}

func TestListDatabasesDescribesADatabase(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	byName := map[string]databaseResult{}
	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		byName[d.Name] = d
	}

	notes := byName["notes"]
	assert.Equal(t, "alice", notes.Owner)
	assert.Equal(t, "the notes database", notes.Description)
	assert.Equal(t, string(core.VisibilityPublic), notes.Visibility)
	require.NotNil(t, notes.Content)
	assert.Equal(t, "main", notes.Content.DefaultBranch)
	assert.Equal(t, "aaaa", notes.Content.Head)
	require.NotNil(t, notes.Content.HeadTime)
	assert.True(t, headTime.Equal(*notes.Content.HeadTime), "got %v", notes.Content.HeadTime)
	assert.False(t, notes.Content.IsBeads, "a database whose tables are not beads' is not a tracker")

	tracker := byName["tracker"]
	require.NotNil(t, tracker.Content)
	assert.True(t, tracker.Content.IsBeads,
		"the fingerprint is beads.Applies, and this fixture satisfies it")
}

// A database that exists and carries no commits is not a failure: its content is
// null and there is no error beside it. The two nulls are distinguishable, which
// is the whole reason content is one object rather than four fields.
func TestADatabaseWithNoCommitsHasNoContentAndNoError(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	for _, d := range listDatabases(t, session, map[string]any{"owner": "alice"}).Databases {
		if d.Name != "empty" {
			continue
		}
		assert.Nil(t, d.Content, "nothing has been pushed, so there is nothing to describe")
		assert.Empty(t, d.ContentError, "and that is not an error")
		return
	}
	t.Fatal("the empty database was not listed")
}

// One unreadable store costs that database its content and nothing else. The
// alternatives were failing the whole call — one broken store hiding every
// database from every caller — and answering is_beads:false, which is a lie an
// agent cannot detect.
func TestAnUnreadableStoreDoesNotSinkTheListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Subset(t, names(res), []string{"notes", "tracker", "broken"},
		"the other databases are still listed")

	for _, d := range res.Databases {
		if d.Name != "broken" {
			continue
		}
		assert.Nil(t, d.Content, "no content is claimed for a store that did not open")
		assert.NotEmpty(t, d.ContentError, "and the entry says so")
		assert.Equal(t, string(core.VisibilityPublic), d.Visibility,
			"the metadata beside it came from Postgres and is still true")
		return
	}
	t.Fatal("the broken database was not listed")
}

// The failure reaches the caller as a fixed sentence: the real one names on-disk
// paths and dolt internals, and this endpoint is reachable by anyone.
func TestAContentFailureDisclosesNoDetail(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	opener.sessions[storePath("alice", "notes")].tablesErr = errors.New("read /srv/dolt/~alice/notes/manifest: input/output error")

	session := connect(t, newServer(t, repos, opener), nil)
	body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

	assert.NotContains(t, body, "/srv/dolt", "no on-disk path reaches a caller")
	assert.NotContains(t, body, "input/output error")
	assert.Contains(t, body, "could not be read")
}

// Every session a listing opens is closed by the handler that opened it, which
// is the browse discipline: a store held open across calls is a stale manifest
// and a leaked handle.
func TestEverySessionIsClosed(t *testing.T) {
	repos := newFakeRepos()
	opener := newFakeOpener()
	session := connect(t, newServer(t, repos, opener), alice())

	listDatabases(t, session, nil)

	for path, sess := range opener.sessions {
		assert.Equal(t, 1, sess.closes, "%s: opened once, closed once", path)
	}
}

// A metadata store that could not answer is a protocol error and not an empty
// listing: an agent must not read "Postgres is down" as "you have no
// databases".
func TestAStoreThatCouldNotAnswerIsAProtocolError(t *testing.T) {
	repos := newFakeRepos()
	repos.listErr = errors.New("dial tcp 127.0.0.1:5432: connection refused")
	session := connect(t, newServer(t, repos, newFakeOpener()), alice())

	res, err := session.CallTool(context.Background(), &mcp.CallToolParams{
		Name:      "list_databases",
		Arguments: map[string]any{"owner": "alice"},
	})
	require.Error(t, err, "a broken metadata store is not an answer")
	assert.NotContains(t, err.Error(), "5432", "and the cause is logged, not sent")
	assert.Nil(t, res)
}

// --- the visibility matrix --------------------------------------------------

// The listing rule of docs/DESIGN.mcp.md §4.3 over every viewer: PUBLIC to
// everyone, an owner sees all of their own, a grantee sees what they were
// granted, and an UNLISTED database of somebody else is absent from the listing
// while remaining readable by direct address.
func TestListDatabasesAppliesTheListingRule(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
		{"a grantee", bob()},
		{"the owner", alice()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			res := listDatabases(t, connect(t, server, tc.caller), map[string]any{"owner": "alice"})
			assert.ElementsMatch(t, listableNames(coreCaller(tc.caller)), names(res))
		})
	}
}

// Nothing a caller may not list leaks through the listing — not as a name, not
// as a description, not in an error message. The private database's name is the
// canary: a stranger who can see the string at all can enumerate what exists.
func TestNothingLeaksToAViewerWhoMayNotList(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	for _, tc := range []struct {
		name   string
		caller *auth.AuthContext
	}{
		{"anonymous", nil},
		{"a stranger", carol()},
	} {
		t.Run(tc.name, func(t *testing.T) {
			session := connect(t, server, tc.caller)
			body := resultJSON(t, call(t, session, "list_databases", map[string]any{"owner": "alice"}))

			assert.NotContains(t, body, "secrets", "a private database is not named to a caller who may not list it")
			assert.NotContains(t, body, "drafts", "and neither is an unlisted one")
		})
	}
}

// An owner named with the sigil is the address a link shows, so it is accepted
// rather than answered with a sentence about punctuation.
func TestTheOwnerArgumentToleratesTheSigil(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	with := listDatabases(t, session, map[string]any{"owner": "~alice"})
	without := listDatabases(t, session, map[string]any{"owner": "alice"})
	assert.Equal(t, names(without), names(with))
	assert.NotEmpty(t, names(with))
}

// An owner nobody has and an owner with nothing visible are one answer: an empty
// listing. That is not a limitation to be fixed — a "no such user" would let an
// agent enumerate accounts through a database listing.
func TestAnUnknownOwnerIsAnEmptyListing(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", map[string]any{"owner": "nobody"})
	assert.False(t, res.IsError, "not knowing an account is not an error")

	var out listDatabasesResult
	decode(t, res, &out)
	assert.Empty(t, out.Databases)
}

// Without an owner the tool answers about the caller: everything they own or
// hold an ACL entry on, whatever its visibility. That is the dashboard query and
// the only "everything I may see" this service can answer.
func TestListDatabasesWithNoOwnerIsTheCallersOwn(t *testing.T) {
	server := newServer(t, newFakeRepos(), newFakeOpener())

	t.Run("the owner sees all of their own", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, alice()), nil)
		assert.ElementsMatch(t, listableNames(coreCaller(alice())), names(res))
	})

	t.Run("a grantee sees what they were granted", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, bob()), nil)
		assert.Equal(t, []string{"secrets"}, names(res))
	})

	t.Run("a stranger owns nothing and is granted nothing", func(t *testing.T) {
		res := listDatabases(t, connect(t, server, carol()), nil)
		assert.Empty(t, res.Databases)
	})
}

// An anonymous caller with no owner named has nothing to be answered about:
// there is no "every public database on this instance" query (db/repos.go), so
// the tool says what it cannot do and names the two ways out instead of
// answering an empty listing that would read as "this instance is empty".
func TestAnonymousWithNoOwnerIsToldWhatToPass(t *testing.T) {
	session := connect(t, newServer(t, newFakeRepos(), newFakeOpener()), nil)

	res := call(t, session, "list_databases", nil)
	require.True(t, res.IsError, "an empty listing here would be a false statement about the instance")

	text := errorText(res)
	assert.Contains(t, text, "owner")
	assert.Contains(t, text, "token")
}