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/activity" "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, activity.New(log, 200)) 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") } // active project starts empty. res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"}) if err != nil { t.Fatalf("get_active_project: %v", err) } var ap struct { Path string `json:"path"` } decodeResult(t, res, &ap) if ap.Path != "" { t.Fatalf("expected empty active project, got %q", ap.Path) } // set_active_project to our repo, then read it back. res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{ Name: "set_active_project", Arguments: map[string]any{"path": states[0].Path}, }) if err != nil { t.Fatalf("set_active_project: %v", err) } if res.IsError { t.Fatalf("set_active_project returned tool error: %+v", res.Content) } res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_active_project"}) decodeResult(t, res, &ap) if ap.Path != states[0].Path { t.Fatalf("active project = %q, want %q", ap.Path, states[0].Path) } // setting an unknown project is a tool error. res, err = cs.CallTool(ctx, &mcpsdk.CallToolParams{ Name: "set_active_project", Arguments: map[string]any{"path": filepath.Join(root, "nope")}, }) if err != nil { t.Fatalf("set_active_project(bad) protocol error: %v", err) } if !res.IsError { t.Fatalf("expected IsError for unknown active project") } // the activity feed should now contain the active-project-changed event. res, _ = cs.CallTool(ctx, &mcpsdk.CallToolParams{Name: "get_activity"}) var act struct { Events []activity.Event `json:"events"` } decodeResult(t, res, &act) found := false for _, ev := range act.Events { if ev.Kind == "active-project-changed" && ev.Repo == states[0].Path && ev.Actor == activity.ActorClaude { found = true } } if !found { t.Fatalf("expected an active-project-changed event by claude, got %+v", act.Events) } } // 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) } }