package mcp import ( "context" "encoding/json" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/user" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // ===== fakes ===== // fakeUserSvc implements user.Service for tests. type fakeUserSvc struct{ users map[uuid.UUID]*user.User } func (s *fakeUserSvc) Login(_ context.Context, _, _, _ string) (string, *user.User, error) { return "", nil, nil } func (s *fakeUserSvc) Logout(_ context.Context, _ string) error { return nil } func (s *fakeUserSvc) ResolveSession(_ context.Context, _ string) (uuid.UUID, bool, error) { return uuid.Nil, false, nil } func (s *fakeUserSvc) Get(_ context.Context, id uuid.UUID) (*user.User, error) { if u, ok := s.users[id]; ok { return u, nil } return nil, errs.New(errs.CodeNotFound, "user") } func (s *fakeUserSvc) Bootstrap(_ context.Context, _, _ string) (*user.User, error) { return nil, nil } func (s *fakeUserSvc) ListAdmins(_ context.Context) ([]*user.User, error) { return nil, nil } func (s *fakeUserSvc) ListUsers(_ context.Context) ([]*user.User, error) { return nil, nil } func (s *fakeUserSvc) CreateUser(_ context.Context, _ user.CreateUserInput) (*user.User, error) { return nil, nil } func (s *fakeUserSvc) UpdateProfile(_ context.Context, _ uuid.UUID, _ string) (*user.User, error) { return nil, nil } func (s *fakeUserSvc) SetAdmin(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { return nil, nil } func (s *fakeUserSvc) SetEnabled(_ context.Context, _, _ uuid.UUID, _ bool) (*user.User, error) { return nil, nil } func (s *fakeUserSvc) AdminResetPassword(_ context.Context, _ uuid.UUID) (string, error) { return "", nil } func (s *fakeUserSvc) ChangePassword(_ context.Context, _ uuid.UUID, _, _ string) error { return nil } func (s *fakeUserSvc) DeleteUser(_ context.Context, _, _ uuid.UUID) error { return nil } // fakeProjectSvc implements project.ProjectService. type fakeProjectSvc struct { projects []*project.Project slugMap map[string]*project.Project idMap map[uuid.UUID]*project.Project } func newFakeProjectSvc(ps ...*project.Project) *fakeProjectSvc { f := &fakeProjectSvc{slugMap: map[string]*project.Project{}, idMap: map[uuid.UUID]*project.Project{}} for _, p := range ps { f.projects = append(f.projects, p) f.slugMap[p.Slug] = p f.idMap[p.ID] = p } return f } func (f *fakeProjectSvc) Create(_ context.Context, _ project.Caller, _ project.CreateProjectInput) (*project.Project, error) { panic("not implemented") } func (f *fakeProjectSvc) Get(_ context.Context, _ project.Caller, slug string) (*project.Project, error) { if p, ok := f.slugMap[slug]; ok { return p, nil } return nil, errs.New(errs.CodeNotFound, "project") } func (f *fakeProjectSvc) GetByID(_ context.Context, _ project.Caller, id uuid.UUID) (*project.Project, error) { if p, ok := f.idMap[id]; ok { return p, nil } return nil, errs.New(errs.CodeNotFound, "project") } func (f *fakeProjectSvc) List(_ context.Context, _ project.Caller, _ bool) ([]*project.Project, error) { return f.projects, nil } func (f *fakeProjectSvc) Update(_ context.Context, _ project.Caller, _ string, _ project.UpdateProjectInput) (*project.Project, error) { panic("not implemented") } func (f *fakeProjectSvc) Archive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") } func (f *fakeProjectSvc) Unarchive(_ context.Context, _ project.Caller, _ string) error { panic("not implemented") } // fakeRequirementSvc implements project.RequirementService. type fakeRequirementSvc struct { items []*project.Requirement } func (f *fakeRequirementSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateRequirementInput) (*project.Requirement, error) { r := &project.Requirement{ ID: uuid.New(), Number: len(f.items) + 1, Title: in.Title, Description: in.Description, Phase: project.PhasePlanning, Status: project.StatusOpen, CreatedAt: time.Now(), } f.items = append(f.items, r) return r, nil } func (f *fakeRequirementSvc) Get(_ context.Context, _ project.Caller, slug string, number int) (*project.Requirement, error) { for _, r := range f.items { if r.Number == number { return r, nil } } return nil, errs.New(errs.CodeNotFound, "requirement") } func (f *fakeRequirementSvc) List(_ context.Context, _ project.Caller, _ string, _ project.RequirementFilter) ([]*project.Requirement, error) { return f.items, nil } func (f *fakeRequirementSvc) Update(_ context.Context, _ project.Caller, slug string, num int, in project.UpdateRequirementInput) (*project.Requirement, error) { r, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return nil, err } if in.Title != nil { r.Title = *in.Title } if in.Description != nil { r.Description = *in.Description } return r, nil } func (f *fakeRequirementSvc) ChangePhase(_ context.Context, _ project.Caller, slug string, num int, to project.Phase) (*project.Requirement, error) { r, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return nil, err } r.Phase = to return r, nil } func (f *fakeRequirementSvc) Close(_ context.Context, _ project.Caller, slug string, num int) error { r, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return err } r.Status = project.StatusClosed return nil } func (f *fakeRequirementSvc) Reopen(_ context.Context, _ project.Caller, slug string, num int) error { r, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return err } r.Status = project.StatusOpen return nil } // fakeIssueSvc implements project.IssueService. type fakeIssueSvc struct { items []*project.Issue } func (f *fakeIssueSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) { i := &project.Issue{ ID: uuid.New(), Number: len(f.items) + 1, Title: in.Title, Description: in.Description, Status: project.StatusOpen, CreatedAt: time.Now(), } f.items = append(f.items, i) return i, nil } func (f *fakeIssueSvc) Get(_ context.Context, _ project.Caller, slug string, number int) (*project.Issue, error) { for _, i := range f.items { if i.Number == number { return i, nil } } return nil, errs.New(errs.CodeNotFound, "issue") } func (f *fakeIssueSvc) List(_ context.Context, _ project.Caller, _ string, _ project.IssueFilter) ([]*project.Issue, error) { return f.items, nil } func (f *fakeIssueSvc) Update(_ context.Context, _ project.Caller, slug string, num int, in project.UpdateIssueInput) (*project.Issue, error) { i, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return nil, err } if in.Title != nil { i.Title = *in.Title } if in.Description != nil { i.Description = *in.Description } return i, nil } func (f *fakeIssueSvc) Assign(_ context.Context, _ project.Caller, slug string, num int, assignee *uuid.UUID) (*project.Issue, error) { i, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return nil, err } i.AssigneeID = assignee return i, nil } func (f *fakeIssueSvc) Close(_ context.Context, _ project.Caller, slug string, num int) error { i, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return err } i.Status = project.StatusClosed return nil } func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string, num int) error { i, err := f.Get(context.Background(), project.Caller{}, slug, num) if err != nil { return err } i.Status = project.StatusOpen return nil } // fakeWorkspaceSvc implements workspace.WorkspaceService. type fakeWorkspaceSvc struct { items []*workspace.Workspace } func (f *fakeWorkspaceSvc) Create(_ context.Context, _ workspace.Caller, _ string, _ workspace.CreateWorkspaceInput) (*workspace.Workspace, error) { panic("not implemented") } func (f *fakeWorkspaceSvc) Get(_ context.Context, _ workspace.Caller, id uuid.UUID) (*workspace.Workspace, error) { for _, w := range f.items { if w.ID == id { return w, nil } } return nil, errs.New(errs.CodeNotFound, "workspace") } func (f *fakeWorkspaceSvc) List(_ context.Context, _ workspace.Caller, _ string) ([]*workspace.Workspace, error) { return f.items, nil } func (f *fakeWorkspaceSvc) Update(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) { panic("not implemented") } func (f *fakeWorkspaceSvc) Delete(_ context.Context, _ workspace.Caller, _ uuid.UUID) error { panic("not implemented") } func (f *fakeWorkspaceSvc) Sync(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) { panic("not implemented") } func (f *fakeWorkspaceSvc) UpsertCredential(_ context.Context, _ workspace.Caller, _ uuid.UUID, _ workspace.UpsertCredentialInput) (*workspace.Credential, error) { panic("not implemented") } func (f *fakeWorkspaceSvc) GetCredentialMeta(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Credential, error) { panic("not implemented") } func (f *fakeWorkspaceSvc) ListBranches(_ context.Context, _ workspace.Caller, _ uuid.UUID) ([]string, error) { panic("not implemented") } // ===== test helpers ===== var ( testUID = uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") testPID = uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb") testWSID = uuid.MustParse("cccccccc-cccc-cccc-cccc-cccccccccccc") ) // testDeps assembles ServerDeps with fake services. func testDeps() ServerDeps { proj := &project.Project{ ID: testPID, Slug: "test-project", Name: "Test Project", Visibility: project.VisibilityPrivate, OwnerID: testUID, CreatedAt: time.Now(), } uSvc := &fakeUserSvc{users: map[uuid.UUID]*user.User{ testUID: {ID: testUID, IsAdmin: true}, }} return ServerDeps{ Caller: NewCallerResolver(uSvc), Projects: newFakeProjectSvc(proj), Reqs: &fakeRequirementSvc{}, Issues: &fakeIssueSvc{}, Workspaces: &fakeWorkspaceSvc{}, } } // callTool wires up an MCP server+client via in-memory transport, // injects an AuthSession into the server's receiving middleware, // and calls a tool by name. // Each call creates a fresh server to avoid middleware accumulation. func callTool(ctx context.Context, deps ServerDeps, toolName string, args map[string]any) (*mcpsdk.CallToolResult, error) { // Extract AuthSession from caller's ctx and inject into server-side via middleware. sess, _ := FromContext(ctx) srv := NewMCPServer(deps) srv.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { if sess != nil { ctx = context.WithValue(ctx, AuthContextKey, sess) } return next(ctx, method, req) } }) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "0.0.1"}, nil) t1, t2 := mcpsdk.NewInMemoryTransports() if _, err := srv.Connect(ctx, t1, nil); err != nil { return nil, err } session, err := client.Connect(ctx, t2, nil) if err != nil { return nil, err } defer session.Close() return session.CallTool(ctx, &mcpsdk.CallToolParams{ Name: toolName, Arguments: args, }) } // unmarshalStructured extracts the StructuredContent from a CallToolResult. func unmarshalStructured(t *testing.T, result *mcpsdk.CallToolResult, out any) { t.Helper() data, err := json.Marshal(result.StructuredContent) require.NoError(t, err) require.NoError(t, json.Unmarshal(data, out)) } // ===== tests ===== func TestListProjects(t *testing.T) { deps := testDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "list_projects", map[string]any{}) require.NoError(t, err) assert.False(t, result.IsError) var out listProjectsOut unmarshalStructured(t, result, &out) require.Len(t, out.Projects, 1) assert.Equal(t, "test-project", out.Projects[0].Slug) } func TestListWorkspaces(t *testing.T) { ws := &workspace.Workspace{ ID: testWSID, Slug: "ws-1", Name: "Workspace 1", DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, } deps := testDeps() deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{ws}} result, err := callTool(ctxWithAuth(Scope{}), deps, "list_workspaces", map[string]any{ "project_slug": "test-project", }) require.NoError(t, err) assert.False(t, result.IsError) var out listWorkspacesOut unmarshalStructured(t, result, &out) require.Len(t, out.Workspaces, 1) assert.Equal(t, "ws-1", out.Workspaces[0].Slug) } func TestCreateAndGetIssue(t *testing.T) { deps := testDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "Bug report", "description": "Something broke", }) require.NoError(t, err) assert.False(t, result.IsError) var createOut issueCreateOut unmarshalStructured(t, result, &createOut) assert.True(t, createOut.OK) assert.Equal(t, "Bug report", createOut.Issue.Title) // Get result, err = callTool(ctxWithAuth(Scope{}), deps, "get_issue", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) var getOut issueDetail unmarshalStructured(t, result, &getOut) assert.Equal(t, "Bug report", getOut.Title) } func TestUpdateIssue(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "Original", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "update_issue", map[string]any{ "project_slug": "test-project", "number": float64(1), "title": "Updated", }) require.NoError(t, err) var out issueUpdateOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) assert.Equal(t, "Updated", out.Issue.Title) } func TestCloseReopenIssue(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "To close", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "close_issue", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) var out okOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) result, err = callTool(ctxWithAuth(Scope{}), deps, "reopen_issue", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) unmarshalStructured(t, result, &out) assert.True(t, out.OK) } func TestAssignIssue(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "Assign me", }) require.NoError(t, err) assigneeID := uuid.New().String() result, err := callTool(ctxWithAuth(Scope{}), deps, "assign_issue", map[string]any{ "project_slug": "test-project", "number": float64(1), "assignee_id": assigneeID, }) require.NoError(t, err) var out okOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) } func TestCreateAndGetRequirement(t *testing.T) { deps := testDeps() result, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "Auth module", "description": "Need auth", }) require.NoError(t, err) var createOut requirementCreateOut unmarshalStructured(t, result, &createOut) assert.True(t, createOut.OK) assert.Equal(t, "Auth module", createOut.Requirement.Title) assert.Equal(t, "planning", createOut.Requirement.Phase) result, err = callTool(ctxWithAuth(Scope{}), deps, "get_requirement", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) var getOut requirementDetail unmarshalStructured(t, result, &getOut) assert.Equal(t, "Auth module", getOut.Title) } func TestUpdateRequirement(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "V1", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "update_requirement", map[string]any{ "project_slug": "test-project", "number": float64(1), "title": "V2", }) require.NoError(t, err) var out requirementCreateOut unmarshalStructured(t, result, &out) assert.Equal(t, "V2", out.Requirement.Title) } func TestCloseReopenRequirement(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "Close me", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "close_requirement", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) var out okOut unmarshalStructured(t, result, &out) assert.True(t, out.OK) result, err = callTool(ctxWithAuth(Scope{}), deps, "reopen_requirement", map[string]any{ "project_slug": "test-project", "number": float64(1), }) require.NoError(t, err) unmarshalStructured(t, result, &out) assert.True(t, out.OK) } func TestSetRequirementPhase(t *testing.T) { deps := testDeps() _, err := callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "Phase test", }) require.NoError(t, err) result, err := callTool(ctxWithAuth(Scope{}), deps, "set_requirement_phase", map[string]any{ "project_slug": "test-project", "number": float64(1), "phase": "auditing", }) require.NoError(t, err) var out requirementCreateOut unmarshalStructured(t, result, &out) assert.Equal(t, "auditing", out.Requirement.Phase) } func TestListRequirements(t *testing.T) { deps := testDeps() _, _ = callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "R1", }) _, _ = callTool(ctxWithAuth(Scope{}), deps, "create_requirement", map[string]any{ "project_slug": "test-project", "title": "R2", }) result, err := callTool(ctxWithAuth(Scope{}), deps, "list_requirements", map[string]any{ "project_slug": "test-project", }) require.NoError(t, err) var out listRequirementsOut unmarshalStructured(t, result, &out) assert.Len(t, out.Requirements, 2) } func TestListIssues(t *testing.T) { deps := testDeps() _, _ = callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "I1", }) _, _ = callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "I2", }) result, err := callTool(ctxWithAuth(Scope{}), deps, "list_issues", map[string]any{ "project_slug": "test-project", }) require.NoError(t, err) var out listIssuesOut unmarshalStructured(t, result, &out) assert.Len(t, out.Issues, 2) } func TestToolScopeCheck(t *testing.T) { deps := testDeps() // Restricted scope: only list_projects restricted := Scope{Tools: []string{"list_projects"}} result, err := callTool(ctxWithAuth(restricted), deps, "list_projects", map[string]any{}) require.NoError(t, err) assert.False(t, result.IsError) result, err = callTool(ctxWithAuth(restricted), deps, "create_issue", map[string]any{ "project_slug": "test-project", "title": "Blocked", }) require.NoError(t, err) assert.True(t, result.IsError) } // ctxWithAuth returns a context with an AuthSession for the test admin user. func ctxWithAuth(scope Scope) context.Context { return context.WithValue(context.Background(), AuthContextKey, &AuthSession{ UserID: testUID.String(), TokenID: uuid.NewString(), Scope: scope, }) }