You've already forked agentic-coding-workflow
test(mcp): 13 PM tool tests + fix jsonschema tags
Add tools_pm_test.go with 13 integration tests covering all 16 PM tools via SDK in-memory transport with fake services. Fix jsonschema tags in tools_pm.go and tools_chat.go — SDK's tag parser rejects "required,min=1" and "required,description=X" syntax; use plain description text instead (non-pointer fields are auto-required). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,9 @@ func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type listRecentMessagesIn struct {
|
type listRecentMessagesIn struct {
|
||||||
ConversationID string `json:"conversation_id" jsonschema:"required,description=UUID of the conversation"`
|
ConversationID string `json:"conversation_id" jsonschema:"UUID of the conversation"`
|
||||||
BeforeID *int64 `json:"before_id,omitempty" jsonschema:"description=Cursor: return messages with ID < this value"`
|
BeforeID *int64 `json:"before_id,omitempty" jsonschema:"cursor: return messages with ID < this value"`
|
||||||
Limit int `json:"limit,omitempty" jsonschema:"description=Max messages to return (default 50, max 200)"`
|
Limit int `json:"limit,omitempty" jsonschema:"max messages to return (default 50, max 200)"`
|
||||||
}
|
}
|
||||||
type messageSummary struct {
|
type messageSummary struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
+24
-24
@@ -76,7 +76,7 @@ func registerListProjects(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== list_workspaces =====
|
// ===== list_workspaces =====
|
||||||
|
|
||||||
type listWorkspacesIn struct {
|
type listWorkspacesIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required,project slug"`
|
ProjectSlug string `json:"project_slug" jsonschema:"project slug"`
|
||||||
}
|
}
|
||||||
type workspaceSummary struct {
|
type workspaceSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -126,7 +126,7 @@ func registerListWorkspaces(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== list_requirements =====
|
// ===== list_requirements =====
|
||||||
|
|
||||||
type listRequirementsIn struct {
|
type listRequirementsIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -178,7 +178,7 @@ func registerListRequirements(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== list_issues =====
|
// ===== list_issues =====
|
||||||
|
|
||||||
type listIssuesIn struct {
|
type listIssuesIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
RequirementNumber any `json:"requirement_number,omitempty"`
|
RequirementNumber any `json:"requirement_number,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
AssigneeID string `json:"assignee_id,omitempty"`
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
@@ -246,8 +246,8 @@ func registerListIssues(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== get_issue =====
|
// ===== get_issue =====
|
||||||
|
|
||||||
type getIssueIn struct {
|
type getIssueIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
}
|
}
|
||||||
type issueDetail struct {
|
type issueDetail struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
@@ -289,8 +289,8 @@ func registerGetIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== get_requirement =====
|
// ===== get_requirement =====
|
||||||
|
|
||||||
type getRequirementIn struct {
|
type getRequirementIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
}
|
}
|
||||||
type requirementDetail struct {
|
type requirementDetail struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
@@ -334,8 +334,8 @@ func registerGetRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== create_issue =====
|
// ===== create_issue =====
|
||||||
|
|
||||||
type createIssueIn struct {
|
type createIssueIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Title string `json:"title" jsonschema:"required,minLength=1"`
|
Title string `json:"title" jsonschema:"title (non-empty)"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
type issueCreateOut struct {
|
type issueCreateOut struct {
|
||||||
@@ -377,8 +377,8 @@ func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== update_issue =====
|
// ===== update_issue =====
|
||||||
|
|
||||||
type updateIssueIn struct {
|
type updateIssueIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -426,8 +426,8 @@ func registerUpdateIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== close/reopen_issue =====
|
// ===== close/reopen_issue =====
|
||||||
|
|
||||||
type closeIssueIn struct {
|
type closeIssueIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
}
|
}
|
||||||
type okOut struct {
|
type okOut struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
@@ -486,8 +486,8 @@ func registerReopenIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== assign_issue =====
|
// ===== assign_issue =====
|
||||||
|
|
||||||
type assignIssueIn struct {
|
type assignIssueIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
AssigneeID *string `json:"assignee_id,omitempty"`
|
AssigneeID *string `json:"assignee_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,8 +527,8 @@ func registerAssignIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== create_requirement =====
|
// ===== create_requirement =====
|
||||||
|
|
||||||
type createRequirementIn struct {
|
type createRequirementIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Title string `json:"title" jsonschema:"required,minLength=1"`
|
Title string `json:"title" jsonschema:"title (non-empty)"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
type requirementCreateOut struct {
|
type requirementCreateOut struct {
|
||||||
@@ -571,8 +571,8 @@ func registerCreateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== update_requirement =====
|
// ===== update_requirement =====
|
||||||
|
|
||||||
type updateRequirementIn struct {
|
type updateRequirementIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -617,8 +617,8 @@ func registerUpdateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== close/reopen_requirement =====
|
// ===== close/reopen_requirement =====
|
||||||
|
|
||||||
type closeRequirementIn struct {
|
type closeRequirementIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
@@ -674,9 +674,9 @@ func registerReopenRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
|||||||
// ===== set_requirement_phase =====
|
// ===== set_requirement_phase =====
|
||||||
|
|
||||||
type setRequirementPhaseIn struct {
|
type setRequirementPhaseIn struct {
|
||||||
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
ProjectSlug string `json:"project_slug" jsonschema:"required field"`
|
||||||
Number int `json:"number" jsonschema:"required,min=1"`
|
Number int `json:"number" jsonschema:"issue number (>=1)"`
|
||||||
Phase string `json:"phase" jsonschema:"required"`
|
Phase string `json:"phase" jsonschema:"required field"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
|
func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
|||||||
@@ -0,0 +1,577 @@
|
|||||||
|
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 }
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user