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")
}