package mcpsrv_test import ( "context" "encoding/json" "errors" "reflect" "sort" "testing" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv" "sourcecraft.dev/bigbes/sr-ht-spec/service" ) // stubWriter is a write backend that only has to exist: these tests are about // which tools the server offers and what arguments they take, and every one of // them fails before a call would reach it. type stubWriter struct{ called bool } func (s *stubWriter) Propose(context.Context, service.ProposeRequest) (service.ProposeResult, error) { s.called = true return service.ProposeResult{}, errors.New("not reached") } func (s *stubWriter) Threads(context.Context, authn.Principal, int) ([]service.Thread, error) { s.called = true return nil, errors.New("not reached") } func (s *stubWriter) ReplyTo(context.Context, authn.Principal, int, string) (service.Comment, error) { s.called = true return service.Comment{}, errors.New("not reached") } func (s *stubWriter) GetProposal(context.Context, int) (service.Proposal, error) { s.called = true return service.Proposal{}, errors.New("not reached") } func (s *stubWriter) ProposalDiff(context.Context, service.Proposal) ([]service.ProposalDoc, error) { s.called = true return nil, errors.New("not reached") } // answeringWriter answers instead of failing, so one call can be made end to // end. It applies no ACL, unlike the service it stands in for: an in-memory // session carries no principal, since the resolver middleware is HTTP's. type answeringWriter struct{ stubWriter } func (*answeringWriter) Threads(context.Context, authn.Principal, int) ([]service.Thread, error) { return []service.Thread{{ Root: service.Comment{ ID: 3, Body: "this needs a rationale", Author: "bigbes", Created: time.Date(2026, 7, 24, 9, 0, 0, 0, time.UTC), }, DocPath: "specs/0007-storage.md", Anchor: core.CommentAnchor{ DocID: "SPEC-0007", HeadingPath: []string{"Storage"}, BlockHash: "deadbeef", Side: core.SideNew, }, Replies: []service.Comment{{ ID: 5, ParentID: 3, Body: "adding it", Author: "claude-code/spec-writer", Agent: true, Created: time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC), }}, Block: -1, }}, nil } func (*answeringWriter) GetProposal(_ context.Context, id int) (service.Proposal, error) { return service.Proposal{ID: id}, nil } func (*answeringWriter) ProposalDiff(context.Context, service.Proposal) ([]service.ProposalDoc, error) { // The proposal no longer changes the commented document — the agent reverted // it — so the thread anchors to nothing and must say so rather than vanish. return nil, nil } // connectWriting is connect with a write backend, so the two write tools are // registered. func connectWriting(t *testing.T, w mcpsrv.Writer) *mcp.ClientSession { t.Helper() ctx := context.Background() serverTransport, clientTransport := mcp.NewInMemoryTransports() r, s := newFixture() srv, err := mcpsrv.New(mcpsrv.Backend{Docs: r, Index: s, Write: w}, "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 toolsOf(t *testing.T, s *mcp.ClientSession) map[string]*mcp.Tool { t.Helper() out := map[string]*mcp.Tool{} for tool, err := range s.Tools(context.Background(), nil) { require.NoError(t, err) out[tool.Name] = tool } return out } // The write tools appear only with a write backend, and spec_comment is one of // them: listing threads and replying travel together, since a listing an agent // cannot answer is half a loop. func TestCommentToolNeedsAWriteBackend(t *testing.T) { r, s := newFixture() require.NotContains(t, toolsOf(t, connect(t, r, s)), "spec_comment") tools := toolsOf(t, connectWriting(t, &stubWriter{})) names := make([]string, 0, len(tools)) for n := range tools { names = append(names, n) } sort.Strings(names) require.Equal(t, []string{"spec_comment", "spec_list", "spec_propose", "spec_read", "spec_search"}, names) // Neither write tool claims to be read-only: a client that retried one // freely would post the same reply twice. require.Nil(t, tools["spec_comment"].Annotations) require.NotEmpty(t, tools["spec_comment"].Description) } // The tool's whole argument surface is proposal, thread and body. There is no // argument that opens a thread and none that resolves one, and this test is the // one that would notice if somebody added one: an unresolved thread suppresses // policy auto-merge, so an agent able to resolve its own critique would control // the gate that exists to hold its output back. func TestCommentToolOffersNoWayToOpenOrResolveAThread(t *testing.T) { tools := toolsOf(t, connectWriting(t, &stubWriter{})) // The schema arrives as the client sees it — decoded JSON, not the Go value // the server built — which is the form an agent actually reads. raw, err := json.Marshal(tools["spec_comment"].InputSchema) require.NoError(t, err) var schema struct { Properties map[string]json.RawMessage `json:"properties"` } require.NoError(t, json.Unmarshal(raw, &schema)) args := make([]string, 0, len(schema.Properties)) for name := range schema.Properties { args = append(args, name) } sort.Strings(args) require.Equal(t, []string{"body", "proposal", "thread"}, args) // And the schema is closed, so a resolve-shaped argument is refused by // validation rather than ignored — an ignored one would let a caller believe // it had resolved the thread. stub := &stubWriter{} res := call(t, connectWriting(t, stub), "spec_comment", map[string]any{ "proposal": 7, "thread": 1, "resolve": true, }) require.True(t, res.IsError, "an unknown argument was accepted") require.Contains(t, errorText(res), `additional properties ["resolve"]`, "the refusal must be the schema rejecting the argument, not a later validation") require.False(t, stub.called, "a rejected call reached the service") } // The whole listing as an agent receives it: the tool's output schema and its // JSON are derived from Go structs by reflection, and the root comment's fields // are promoted from an embedded one — so the flattening is a library detail // that only a real call over the transport can confirm. func TestCommentListReachesTheClientFlat(t *testing.T) { res := call(t, connectWriting(t, &answeringWriter{}), "spec_comment", map[string]any{"proposal": 7}) var out struct { Proposal int `json:"proposal"` Threads []struct { Thread int `json:"thread"` Author string `json:"author"` Agent bool `json:"agent"` Body string `json:"body"` Created string `json:"created"` Document string `json:"document"` DocID string `json:"doc_id"` Heading []string `json:"heading"` Side string `json:"side"` State string `json:"state"` Block int `json:"block"` Open bool `json:"open"` Replies []struct { ID int `json:"id"` Thread int `json:"thread"` Author string `json:"author"` Agent bool `json:"agent"` Body string `json:"body"` } `json:"replies"` } `json:"threads"` } decode(t, res, &out) require.Equal(t, 7, out.Proposal) require.Len(t, out.Threads, 1) th := out.Threads[0] require.Equal(t, 3, th.Thread) require.Equal(t, "bigbes", th.Author) require.False(t, th.Agent) require.Equal(t, "this needs a rationale", th.Body) require.Equal(t, "2026-07-24T09:00:00Z", th.Created) require.Equal(t, "specs/0007-storage.md", th.Document) require.Equal(t, "SPEC-0007", th.DocID) require.Equal(t, []string{"Storage"}, th.Heading) require.Equal(t, "new", th.Side) require.True(t, th.Open) // The document the comment was on is no longer part of the proposal, so the // anchor is reported lost rather than dropped or pointed at block 0. require.Equal(t, "outdated", th.State) require.Equal(t, -1, th.Block) require.Len(t, th.Replies, 1) require.Equal(t, 3, th.Replies[0].Thread) require.True(t, th.Replies[0].Agent, "an agent's own reply is marked as one") require.Equal(t, "adding it", th.Replies[0].Body) } // The structural half of the same rule. *service.Service has CommentOn and // ResolveThread — both owner-only there — and the comment handler is written // against this interface precisely so it cannot name them. Widening it is the // change this test exists to stop; the service-side check is the second lock, // not the only one. func TestCommenterCannotReachOwnerOnlyThreadOperations(t *testing.T) { typ := reflect.TypeOf((*mcpsrv.Commenter)(nil)).Elem() methods := make([]string, 0, typ.NumMethod()) for i := range typ.NumMethod() { methods = append(methods, typ.Method(i).Name) } require.Equal(t, []string{"GetProposal", "ProposalDiff", "ReplyTo", "Threads"}, methods, "spec_comment's backend must reach nothing but reading threads and replying") }