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.Refs())
require.False(t, s.last.Spaces.Everything(), "naming spaces restricts the search")
// Omitting it is the meta-project: a filter that excludes nothing. It
// reaches the index saying so, rather than as an empty list that the index
// would have to interpret.
call(t, session, "spec_search", map[string]any{"query": "storage"})
require.True(t, s.last.Spaces.Everything())
require.False(t, s.last.Spaces.MatchesNothing())
// 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 <mark>approved</mark> 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 <mark>, 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 -----------------------------------------------------------------
// A backend with no write side registers only the read tools. A half-wired
// write tool 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)
}