package mcpsrv_test import ( "context" "encoding/json" "errors" "fmt" "sort" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/doc" "sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv" "sourcecraft.dev/bigbes/sr-ht-spec/search" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // --- fixtures --------------------------------------------------------------- // The two revisions every read test is written against: an approved head whose // SPEC-0007 says "approved", and an older pinned revision whose SPEC-0007 says // something else. Nothing distinguishes them but the revision, which is the // point — one storage tier, one code path, a different ref. const ( approvedRev = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" olderRev = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" // proposalRev is a real commit sha that happens to be a proposal branch // tip. It is readable only because the caller named it. proposalRev = "cccccccccccccccccccccccccccccccccccccccc" ) var fxSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"} func blob(n int) string { return fmt.Sprintf("%040x", n) } func md(id, title, status, body string) []byte { return []byte(fmt.Sprintf("---\nid: %s\ntitle: %s\nstatus: %s\n---\n\n%s\n", id, title, status, body)) } // fakeReader stands in for *service.Service: a document set per revision, and // nothing else. It holds no repository, which is the property that makes these // tests run without git. type fakeReader struct { spaces []*service.Space revs map[string][]service.Document approved string openErr error listErr error lastRevs []string } func (f *fakeReader) ListSpaces(context.Context) ([]*service.Space, error) { return f.spaces, nil } func (f *fakeReader) OpenSpace(_ context.Context, ref core.SpaceRef) (*service.Space, error) { if f.openErr != nil { return nil, f.openErr } for _, sp := range f.spaces { if sp.Ref == ref { return sp, nil } } return nil, fmt.Errorf("%w: space %s", service.ErrNotFound, ref) } // Archive is the one read the tools make. It mirrors what service/ does — // resolve the revision first, read at the resolved sha, build the archive out // of the result — and builds it through service.ArchiveFrom, so the addressing // rule under test is the production one rather than a fixture's idea of it. func (f *fakeReader) Archive(_ context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, error) { resolved, err := f.resolveRev(rev) if err != nil { return nil, nil, err } if f.listErr != nil { return nil, nil, f.listErr } f.lastRevs = append(f.lastRevs, resolved) return service.ArchiveFrom(sp.Ref, resolved, f.revs[resolved]) } func (f *fakeReader) resolveRev(rev string) (string, error) { if rev == service.ApprovedRev { return f.approved, nil } if _, ok := f.revs[rev]; !ok { return "", fmt.Errorf("%w: revision %q", service.ErrNotFound, rev) } return rev, nil } type fakeSearcher struct { last search.Query calls int results search.Results err error } func (f *fakeSearcher) Search(_ context.Context, q search.Query) (search.Results, error) { f.last = q f.calls++ return f.results, f.err } // newFixture builds the standard two-revision, two-space backend. func newFixture() (*fakeReader, *fakeSearcher) { doc := func(path string, data []byte, rev string, n int) service.Document { return service.Document{Path: path, Blob: blob(n), Rev: rev, Data: data} } r := &fakeReader{ spaces: []*service.Space{ {Ref: fxSpace, ID: 1}, {Ref: core.SpaceRef{Owner: "bigbes", Name: "notes"}, ID: 2}, }, approved: approvedRev, revs: map[string][]service.Document{ approvedRev: { doc("specs/0007-storage.md", md("SPEC-0007", "Storage model", "review", "the approved body"), approvedRev, 1), doc("notes/untitled.md", []byte("# Loose note\n\nno frontmatter here\n"), approvedRev, 2), }, olderRev: { doc("specs/0007-storage.md", md("SPEC-0007", "Storage model", "draft", "the older body"), olderRev, 3), }, proposalRev: { doc("specs/0007-storage.md", md("SPEC-0007", "Storage model", "draft", "unreviewed proposal body"), proposalRev, 4), }, }, } return r, &fakeSearcher{} } // --- plumbing --------------------------------------------------------------- func connect(t *testing.T, r mcpsrv.Reader, s mcpsrv.Searcher) *mcp.ClientSession { t.Helper() ctx := context.Background() serverTransport, clientTransport := mcp.NewInMemoryTransports() srv, err := mcpsrv.New(mcpsrv.Backend{Docs: r, Index: s}, "test") require.NoError(t, err) serverConn, err := srv.Connect(ctx, serverTransport, nil) require.NoError(t, err) t.Cleanup(func() { _ = serverConn.Close() }) client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "test"}, nil) session, err := client.Connect(ctx, clientTransport, nil) require.NoError(t, err) t.Cleanup(func() { _ = session.Close() }) return session } 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 } 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)) } 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 } type readResult struct { Space string `json:"space"` ID string `json:"id"` DocID string `json:"doc_id"` Path string `json:"path"` Rev string `json:"rev"` Blob string `json:"blob"` Pinned bool `json:"pinned"` Title string `json:"title"` Section string `json:"section"` Status string `json:"status"` Tags []string `json:"tags"` Markdown string `json:"markdown"` } // --- the read contract ------------------------------------------------------ // The default read is the approved head. This is the property the whole service // exists for: an agent that names no revision must never be handed unreviewed // text. func TestReadDefaultsToApprovedHead(t *testing.T) { r, s := newFixture() session := connect(t, r, s) res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", }) var out readResult decode(t, res, &out) require.Contains(t, out.Markdown, "the approved body") require.NotContains(t, out.Markdown, "older body") require.Equal(t, approvedRev, out.Rev, "the approved head is reported so the caller can pin it") require.False(t, out.Pinned) require.Equal(t, "SPEC-0007", out.ID) require.Equal(t, "SPEC-0007", out.DocID) require.Equal(t, "specs/0007-storage.md", out.Path) require.Equal(t, blob(1), out.Blob) require.Equal(t, "review", out.Status, "status is authored metadata, not approval state") // Everything below the tool read the resolved sha, never the empty string: // a merge landing mid-read cannot make one answer describe two revisions. require.Equal(t, []string{approvedRev}, r.lastRevs) } // A pinned rev returns that revision, not the head. func TestReadPinnedRevReturnsThatRevision(t *testing.T) { r, s := newFixture() session := connect(t, r, s) res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", "rev": olderRev, }) var out readResult decode(t, res, &out) require.Contains(t, out.Markdown, "the older body") require.Equal(t, olderRev, out.Rev) require.True(t, out.Pinned) require.Equal(t, "draft", out.Status) require.Equal(t, []string{olderRev}, r.lastRevs) } // Proposal content is reachable only by naming its commit — never by naming a // branch. This is the guard that keeps unreviewed text out of an agent's // context by accident. func TestReadRefusesRefNames(t *testing.T) { r, s := newFixture() session := connect(t, r, s) for _, rev := range []string{"proposals/42", "main", "HEAD", "cafe"} { res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", "rev": rev, }) require.True(t, res.IsError, "rev %q was accepted", rev) require.Contains(t, errorText(res), "object name") require.Contains(t, errorText(res), "approved head") } require.Empty(t, r.lastRevs, "a rejected rev never reaches the service layer") // Naming the commit itself is deliberate, and works. res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", "rev": proposalRev, }) var out readResult decode(t, res, &out) require.Contains(t, out.Markdown, "unreviewed proposal body") require.True(t, out.Pinned) } // The design's addressing rule, both halves: id when it is well-formed and // unique, path when there is no usable id. func TestReadAddressing(t *testing.T) { r, s := newFixture() session := connect(t, r, s) cases := []struct{ name, document, wantID, wantPath string }{ {"by id", "SPEC-0007", "SPEC-0007", "specs/0007-storage.md"}, {"by path", "specs/0007-storage.md", "SPEC-0007", "specs/0007-storage.md"}, {"by extensionless path", "specs/0007-storage", "SPEC-0007", "specs/0007-storage.md"}, {"id-less document by path", "notes/untitled", "notes/untitled", "notes/untitled.md"}, {"id-less document by path with extension", "notes/untitled.md", "notes/untitled", "notes/untitled.md"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": c.document, }) var out readResult decode(t, res, &out) require.Equal(t, c.wantID, out.ID) require.Equal(t, c.wantPath, out.Path) }) } // A document with no frontmatter id reports none rather than inventing one // from its path. res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "notes/untitled", }) var out readResult decode(t, res, &out) require.Empty(t, out.DocID) } // A duplicated id resolves to neither document, and says which two claim it. func TestReadDuplicateIDIsAmbiguous(t *testing.T) { r, s := newFixture() r.revs[approvedRev] = []service.Document{ {Path: "specs/a.md", Blob: blob(5), Rev: approvedRev, Data: md("SPEC-0007", "A", "draft", "a")}, {Path: "specs/b.md", Blob: blob(6), Rev: approvedRev, Data: md("SPEC-0007", "B", "draft", "b")}, } session := connect(t, r, s) res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", }) require.True(t, res.IsError) require.Contains(t, errorText(res), "claimed by 2 documents") require.Contains(t, errorText(res), "specs/a.md") require.Contains(t, errorText(res), "specs/b.md") // Both are still readable by path: a duplicated id is excluded from id // resolution, not from the archive. var out readResult decode(t, call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "specs/a.md", }), &out) require.Equal(t, "A", out.Title) } // A missing document, space or revision is a tool error with a usable message, // never a panic and never an empty document. func TestReadMissing(t *testing.T) { r, s := newFixture() session := connect(t, r, s) res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-9999", }) require.True(t, res.IsError) require.Contains(t, errorText(res), `no document "SPEC-9999"`) require.Contains(t, errorText(res), "~bigbes/rfcs") res = call(t, session, "spec_read", map[string]any{ "space": "~bigbes/nope", "document": "SPEC-0007", }) require.True(t, res.IsError) require.Contains(t, errorText(res), "not found") res = call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", "rev": blob(99), }) require.True(t, res.IsError) require.Contains(t, errorText(res), "not found") res = call(t, session, "spec_read", map[string]any{"space": "", "document": "SPEC-0007"}) require.True(t, res.IsError) require.Contains(t, errorText(res), "space must not be empty") res = call(t, session, "spec_read", map[string]any{"space": "~bigbes/rfcs", "document": " "}) require.True(t, res.IsError) require.Contains(t, errorText(res), "document must not be empty") } // A failure below service/ surfaces as a tool error rather than taking the // session down. func TestReadBackendFailure(t *testing.T) { r, s := newFixture() r.listErr = errors.New("git object store is on fire") session := connect(t, r, s) res := call(t, session, "spec_read", map[string]any{ "space": "~bigbes/rfcs", "document": "SPEC-0007", }) require.True(t, res.IsError) require.Contains(t, errorText(res), "on fire") } // --- search ----------------------------------------------------------------- type searchResult struct { Hits []struct { Space string `json:"space"` ID string `json:"id"` Path string `json:"path"` Rev string `json:"rev"` Anchor string `json:"anchor"` Title string `json:"title"` Section string `json:"section"` Score float64 `json:"score"` Snippet string `json:"snippet"` } `json:"hits"` Total uint64 `json:"total"` } // The spaces argument is the project filter, and it reaches the index as one. func TestSearchSpaceFilter(t *testing.T) { r, s := newFixture() session := connect(t, r, s) call(t, session, "spec_search", map[string]any{ "query": "storage", "spaces": []string{"~bigbes/rfcs", "bigbes/notes"}, }) require.Equal(t, []core.SpaceRef{ {Owner: "bigbes", Name: "rfcs"}, {Owner: "bigbes", Name: "notes"}, }, s.last.Spaces) // Omitting it is the meta-project: a filter that excludes nothing. call(t, session, "spec_search", map[string]any{"query": "storage"}) require.Nil(t, s.last.Spaces) // Sections pass through the same way. call(t, session, "spec_search", map[string]any{"query": "storage", "sections": []string{"specs"}}) require.Equal(t, []string{"specs"}, s.last.Sections) } // An unparseable space is refused rather than dropped: a dropped filter term // silently widens the search past what was asked for. func TestSearchRejectsBadSpace(t *testing.T) { r, s := newFixture() session := connect(t, r, s) res := call(t, session, "spec_search", map[string]any{ "query": "storage", "spaces": []string{"~bigbes/rfcs", "not a space ref"}, }) require.True(t, res.IsError) require.Contains(t, errorText(res), "not a space ref") require.Zero(t, s.calls, "nothing was searched") res = call(t, session, "spec_search", map[string]any{"query": " "}) require.True(t, res.IsError) require.Contains(t, errorText(res), "query must not be empty") require.Zero(t, s.calls) } func TestSearchLimit(t *testing.T) { r, s := newFixture() session := connect(t, r, s) call(t, session, "spec_search", map[string]any{"query": "q"}) require.Equal(t, search.DefaultLimit, s.last.Limit) call(t, session, "spec_search", map[string]any{"query": "q", "limit": 5, "offset": 10}) require.Equal(t, 5, s.last.Limit) require.Equal(t, 10, s.last.Offset) call(t, session, "spec_search", map[string]any{"query": "q", "limit": 100000}) require.Equal(t, 100, s.last.Limit) } // Hits carry everything spec_read needs, the snippet is plain text, and a log // entry's id is one spec_read accepts. func TestSearchHitShape(t *testing.T) { r, s := newFixture() s.results = search.Results{ Total: 2, Hits: []search.Hit{ { Space: fxSpace, ID: "SPEC-0007", Rev: approvedRev, Path: "specs/0007-storage.md", Title: "Storage model", Section: "specs", Score: 1.5, Snippet: "the approved body & nothing else", }, { Space: fxSpace, ID: "notes/dev-log#2026-05-31-1", Rev: approvedRev, Path: "notes/dev-log.md", Anchor: "2026-05-31-shipped", Section: "log", Title: "2026-05-31 shipped", Score: 0.9, }, }, } session := connect(t, r, s) var out searchResult decode(t, call(t, session, "spec_search", map[string]any{"query": "approved"}), &out) require.Equal(t, uint64(2), out.Total) require.Len(t, out.Hits, 2) h := out.Hits[0] require.Equal(t, "~bigbes/rfcs", h.Space) require.Equal(t, "SPEC-0007", h.ID) require.Equal(t, "specs/0007-storage.md", h.Path) require.Equal(t, approvedRev, h.Rev) require.Equal(t, "the approved body & nothing else", h.Snippet, "the snippet is plain text: no , no HTML entities") // The indexed id of a log entry carries the entry suffix; the id reported // is the document one, and the entry's position is the anchor. require.Equal(t, "notes/dev-log", out.Hits[1].ID) require.Equal(t, "2026-05-31-shipped", out.Hits[1].Anchor) // And that id round-trips through spec_read. r.revs[approvedRev] = append(r.revs[approvedRev], service.Document{ Path: "notes/dev-log.md", Blob: blob(7), Rev: approvedRev, Data: []byte("# Dev log\n\n## 2026-05-31 shipped\n\ndone\n"), }) var doc readResult decode(t, call(t, session, "spec_read", map[string]any{ "space": out.Hits[1].Space, "document": out.Hits[1].ID, }), &doc) require.Equal(t, "notes/dev-log.md", doc.Path) } func TestSearchBackendFailure(t *testing.T) { r, s := newFixture() s.err = errors.New("index is closed") session := connect(t, r, s) res := call(t, session, "spec_search", map[string]any{"query": "q"}) require.True(t, res.IsError) require.Contains(t, errorText(res), "index is closed") } // --- list ------------------------------------------------------------------- type listResult struct { Spaces []struct { Space string `json:"space"` Owner string `json:"owner"` Name string `json:"name"` } `json:"spaces"` Space string `json:"space"` Rev string `json:"rev"` Documents []struct { ID string `json:"id"` DocID string `json:"doc_id"` Path string `json:"path"` Blob string `json:"blob"` Title string `json:"title"` Section string `json:"section"` Status string `json:"status"` } `json:"documents"` } func TestListSpaces(t *testing.T) { r, s := newFixture() session := connect(t, r, s) var out listResult decode(t, call(t, session, "spec_list", map[string]any{}), &out) require.Len(t, out.Spaces, 2) require.Equal(t, "~bigbes/rfcs", out.Spaces[0].Space) require.Equal(t, "bigbes", out.Spaces[0].Owner) require.Equal(t, "rfcs", out.Spaces[0].Name) require.Empty(t, out.Documents) } func TestListDocuments(t *testing.T) { r, s := newFixture() session := connect(t, r, s) var out listResult decode(t, call(t, session, "spec_list", map[string]any{"space": "~bigbes/rfcs"}), &out) require.Equal(t, "~bigbes/rfcs", out.Space) require.Equal(t, approvedRev, out.Rev, "listing defaults to the approved head, like every other read") require.Len(t, out.Documents, 2) require.Empty(t, out.Spaces) byID := map[string]string{} for _, d := range out.Documents { byID[d.ID] = d.Path } ids := make([]string, 0, len(byID)) for id := range byID { ids = append(ids, id) } sort.Strings(ids) require.Equal(t, []string{"SPEC-0007", "notes/untitled"}, ids) require.Equal(t, "specs/0007-storage.md", byID["SPEC-0007"]) // A pinned revision lists that revision. decode(t, call(t, session, "spec_list", map[string]any{ "space": "~bigbes/rfcs", "rev": olderRev, }), &out) require.Equal(t, olderRev, out.Rev) require.Len(t, out.Documents, 1) require.Equal(t, "draft", out.Documents[0].Status) } // A rev with no space is a caller that meant to name one. Answering the other // question would look like it had worked. func TestListRevWithoutSpace(t *testing.T) { r, s := newFixture() session := connect(t, r, s) res := call(t, session, "spec_list", map[string]any{"rev": approvedRev}) require.True(t, res.IsError) require.Contains(t, errorText(res), "pass space as well") res = call(t, session, "spec_list", map[string]any{"space": "~bigbes/rfcs", "rev": "proposals/42"}) require.True(t, res.IsError) require.Contains(t, errorText(res), "object name") } // --- wiring ----------------------------------------------------------------- // Only read tools exist. The write tools are Phase 3, and a half-wired one is // worse than none: an agent that sees spec_propose will call it. func TestOnlyReadToolsAreRegistered(t *testing.T) { r, s := newFixture() session := connect(t, r, s) var names []string for tool, err := range session.Tools(context.Background(), nil) { require.NoError(t, err) names = append(names, tool.Name) require.True(t, tool.Annotations.ReadOnlyHint, "%s is not annotated read-only", tool.Name) require.NotEmpty(t, tool.Description) } sort.Strings(names) require.Equal(t, []string{"spec_list", "spec_read", "spec_search"}, names) } func TestNewRefusesAnIncompleteBackend(t *testing.T) { r, s := newFixture() _, err := mcpsrv.New(mcpsrv.Backend{Index: s}, "test") require.ErrorContains(t, err, "no document reader") _, err = mcpsrv.New(mcpsrv.Backend{Docs: r}, "test") require.ErrorContains(t, err, "no search index") _, err = mcpsrv.Handler(mcpsrv.Backend{}, "test", "https://spec.srht.bigb.es") require.Error(t, err) }