diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/chatbotmcp.go | 82 | ||||
| -rw-r--r-- | internal/api/chatbotmcp_test.go | 138 |
2 files changed, 220 insertions, 0 deletions
diff --git a/internal/api/chatbotmcp.go b/internal/api/chatbotmcp.go index e14b522..7223bcc 100644 --- a/internal/api/chatbotmcp.go +++ b/internal/api/chatbotmcp.go @@ -8,9 +8,11 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) @@ -53,6 +55,24 @@ type rejectTaskMCPInput struct { Comment string `json:"comment,omitempty" jsonschema:"optional reason the work was rejected, fed back to the agent on retry"` } +type createStoryMCPInput struct { + Name string `json:"name" jsonschema:"short human-readable story name"` + Spec string `json:"spec,omitempty" jsonschema:"prose description of the intent -- what a human is asking for and why"` + AcceptanceCriteria []string `json:"acceptance_criteria,omitempty" jsonschema:"optional list of concrete criteria the finished work must satisfy"` + EpicID string `json:"epic_id,omitempty" jsonschema:"optional epic ID to group this story under"` + ProjectID string `json:"project_id,omitempty" jsonschema:"optional loose-reference project ID; not yet used to automatically resolve a repository"` + Priority string `json:"priority,omitempty" jsonschema:"optional priority, e.g. high, normal, low"` +} + +type listStoriesMCPInput struct { + Status string `json:"status,omitempty" jsonschema:"optional status filter, e.g. DISCOVERY, IN_PROGRESS, REVIEW_READY, DONE"` + EpicID string `json:"epic_id,omitempty" jsonschema:"optional epic ID filter"` +} + +type storyIDInput struct { + StoryID string `json:"story_id" jsonschema:"the story ID"` +} + type compactTask struct { ID string `json:"id"` Name string `json:"name"` @@ -196,6 +216,68 @@ func (s *Server) newChatbotServer() *mcp.Server { return mcpText(msg), nil, nil }) + mcp.AddTool(srv, &mcp.Tool{ + Name: "create_story", + Description: "Create a story representing a piece of user intent -- a shippable slice of work realized by a tree of tasks. Use this instead of submit_task when the ask needs to be broken down rather than run as a single step. Returns the new story.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in createStoryMCPInput) (*mcp.CallToolResult, any, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, nil, badRequestf("name is required") + } + st := &story.Story{ + ID: uuid.New().String(), + Name: in.Name, + Status: "DISCOVERY", + DiscoverySource: "agent", + Spec: in.Spec, + AcceptanceCriteria: in.AcceptanceCriteria, + EpicID: in.EpicID, + ProjectID: in.ProjectID, + Priority: in.Priority, + } + if st.AcceptanceCriteria == nil { + st.AcceptanceCriteria = []string{} + } + if err := s.store.CreateStory(st); err != nil { + return nil, nil, err + } + return mcpJSON(st) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_story", + Description: "Get one story by ID.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in storyIDInput) (*mcp.CallToolResult, any, error) { + st, err := s.store.GetStory(in.StoryID) + if err != nil { + return nil, nil, errStoryNotFound + } + return mcpJSON(st) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_stories", + Description: "List stories, optionally filtered by status and/or epic_id.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in listStoriesMCPInput) (*mcp.CallToolResult, any, error) { + stories, err := s.store.ListStories(storage.StoryFilter{Status: in.Status, EpicID: in.EpicID}) + if err != nil { + return nil, nil, err + } + if stories == nil { + stories = []*story.Story{} + } + return mcpJSON(stories) + }) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "accept_story", + Description: "Accept a REVIEW_READY story, marking it DONE. This is the single human/chatbot touchpoint for a whole story's pipeline -- the underlying builder/evaluator/arbitration tasks are auto-accepted by the story orchestrator.", + }, func(_ context.Context, _ *mcp.CallToolRequest, in storyIDInput) (*mcp.CallToolResult, any, error) { + if err := s.acceptStory(in.StoryID, event.ActorChatbot); err != nil { + return nil, nil, err + } + return mcpText("Story accepted."), nil, nil + }) + return srv } diff --git a/internal/api/chatbotmcp_test.go b/internal/api/chatbotmcp_test.go index d13b0d7..cc0960a 100644 --- a/internal/api/chatbotmcp_test.go +++ b/internal/api/chatbotmcp_test.go @@ -8,7 +8,9 @@ import ( "testing" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/storage" + "github.com/thepeterstone/claudomator/internal/story" "github.com/thepeterstone/claudomator/internal/task" ) @@ -231,3 +233,139 @@ func TestChatbotMCP_AnswerQuestion(t *testing.T) { t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State) } } + +func TestChatbotMCP_CreateStory_AndGetStory(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + out := callText(t, cs, "create_story", map[string]any{ + "name": "Add login page", + "spec": "Users should be able to log in with email + password", + "acceptance_criteria": []string{"User can log in", "Invalid credentials show an error"}, + }) + var created story.Story + if err := json.Unmarshal([]byte(out), &created); err != nil { + t.Fatalf("unmarshal create_story result %q: %v", out, err) + } + if created.ID == "" { + t.Fatal("create_story returned empty story ID") + } + if created.Status != "DISCOVERY" { + t.Errorf("Status: want DISCOVERY, got %q", created.Status) + } + + out = callText(t, cs, "get_story", map[string]any{"story_id": created.ID}) + var got story.Story + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal get_story result %q: %v", out, err) + } + if got.Name != "Add login page" { + t.Errorf("Name: want %q, got %q", "Add login page", got.Name) + } + if len(got.AcceptanceCriteria) != 2 { + t.Errorf("AcceptanceCriteria: want 2 items, got %+v", got.AcceptanceCriteria) + } +} + +func TestChatbotMCP_CreateStory_MissingName_ReturnsToolError(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "create_story", Arguments: map[string]any{}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected create_story with no name to return a tool error") + } +} + +func TestChatbotMCP_GetStory_NotFound_ReturnsToolError(t *testing.T) { + srv, _ := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_story", Arguments: map[string]any{"story_id": "nope"}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected get_story with unknown ID to return a tool error") + } +} + +func TestChatbotMCP_ListStories_FiltersByStatus(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "DISCOVERY", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story 1: %v", err) + } + if err := store.CreateStory(&story.Story{ID: "s2", Name: "Two", Status: "DONE", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story 2: %v", err) + } + + out := callText(t, cs, "list_stories", map[string]any{"status": "DONE"}) + var got []story.Story + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal list_stories result %q: %v", out, err) + } + if len(got) != 1 || got[0].ID != "s2" { + t.Errorf("expected only story s2, got %+v", got) + } +} + +func TestChatbotMCP_AcceptStory_SucceedsFromReviewReady(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "REVIEW_READY", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story: %v", err) + } + + callText(t, cs, "accept_story", map[string]any{"story_id": "s1"}) + + got, err := store.GetStory("s1") + if err != nil { + t.Fatalf("get story after accept: %v", err) + } + if got.Status != "DONE" { + t.Errorf("Status: want DONE, got %q", got.Status) + } + + events, err := store.ListEvents("s1", 0) + if err != nil { + t.Fatalf("list events: %v", err) + } + found := false + for _, e := range events { + if e.Kind == event.KindHumanAccepted && e.Actor == event.ActorChatbot { + found = true + } + } + if !found { + t.Error("expected a KindHumanAccepted event with ActorChatbot") + } +} + +func TestChatbotMCP_AcceptStory_FailsFromOtherStatus(t *testing.T) { + srv, store := testServer(t) + srv.SetAPIToken("s3cret") + cs := chatbotClient(t, srv, "s3cret") + + if err := store.CreateStory(&story.Story{ID: "s1", Name: "One", Status: "IN_PROGRESS", AcceptanceCriteria: []string{}}); err != nil { + t.Fatalf("seed story: %v", err) + } + + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "accept_story", Arguments: map[string]any{"story_id": "s1"}}) + if err != nil { + t.Fatalf("CallTool transport error: %v", err) + } + if !res.IsError { + t.Fatal("expected accept_story from IN_PROGRESS to return a tool error") + } +} |
