Files
GitManager/internal/mcp/mcp_test.go
T
TBNilles d3abd4416e Fix MCP tools/list rejection: wrap list_repos slice in object
The go-sdk infers each tool's outputSchema from its handler result
type, and MCP structured output must be type "object". list_repos
returned []repos.State, yielding outputSchema.type "array", which
Claude Desktop rejects at tools/list — taking the whole server down.

Wrap the slice in listReposOutput{Repos: ...} so the schema is an
object, update the round-trip test, and record the struct-result rule
in AGENT.md §8.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-20 08:00:30 -04:00

127 lines
3.6 KiB
Go

package mcp
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"gitmanager/internal/git"
"gitmanager/internal/repos"
"gitmanager/internal/service"
)
// TestMCPRoundTrip exercises the full path: a real temp git repo -> scanner ->
// service -> MCP tools, called by an in-memory MCP client.
func TestMCPRoundTrip(t *testing.T) {
root := t.TempDir()
repoPath := filepath.Join(root, "myrepo")
if err := os.Mkdir(repoPath, 0o755); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "init", "-b", "main")
runGit(t, repoPath, "config", "user.email", "test@example.com")
runGit(t, repoPath, "config", "user.name", "Test")
if err := os.WriteFile(filepath.Join(repoPath, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, repoPath, "add", "-A")
runGit(t, repoPath, "commit", "-m", "first commit")
// Populate the index via the real scanner, then build service + MCP server.
log := slog.New(slog.NewTextHandler(io.Discard, nil))
g := git.New("git")
scanner := repos.NewScanner(g, log, []string{root}, 3, nil, time.Minute, false)
scanner.Refresh(context.Background())
svc := service.New(g, scanner.Index)
srv := NewServer(svc, "test")
// Wire an in-memory client<->server session.
ctx := context.Background()
clientT, serverT := mcpsdk.NewInMemoryTransports()
serverSession, err := srv.Connect(ctx, serverT, nil)
if err != nil {
t.Fatalf("server connect: %v", err)
}
defer serverSession.Close()
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test", Version: "0"}, nil)
cs, err := client.Connect(ctx, clientT, nil)
if err != nil {
t.Fatalf("client connect: %v", err)
}
defer cs.Close()
// list_repos should find our one repo.
res, err := cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "list_repos"})
if err != nil {
t.Fatalf("list_repos: %v", err)
}
var listOut listReposOutput
decodeResult(t, res, &listOut)
states := listOut.Repos
if len(states) != 1 {
t.Fatalf("expected 1 repo, got %d: %+v", len(states), states)
}
if states[0].Name != "myrepo" || states[0].Branch != "main" {
t.Fatalf("unexpected repo state: %+v", states[0])
}
// get_repo should return detail including the commit we made.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": states[0].Path},
})
if err != nil {
t.Fatalf("get_repo: %v", err)
}
var detail repos.Detail
decodeResult(t, res, &detail)
if len(detail.Commits) != 1 || detail.Commits[0].Subject != "first commit" {
t.Fatalf("unexpected detail commits: %+v", detail.Commits)
}
// get_repo with a bad path is a tool error, not a protocol error.
res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "get_repo",
Arguments: map[string]any{"path": filepath.Join(root, "nope")},
})
if err != nil {
t.Fatalf("get_repo(bad) protocol error: %v", err)
}
if !res.IsError {
t.Fatalf("expected IsError for unknown repo, got success")
}
}
// decodeResult unmarshals the JSON text content of a tool result into v.
func decodeResult(t *testing.T, res *mcpsdk.CallToolResult, v any) {
t.Helper()
for _, c := range res.Content {
if tc, ok := c.(*mcpsdk.TextContent); ok {
if err := json.Unmarshal([]byte(tc.Text), v); err != nil {
t.Fatalf("unmarshal result: %v (text=%s)", err, tc.Text)
}
return
}
}
t.Fatalf("no text content in result: %+v", res.Content)
}
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}