summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-07-08 22:19:49 +0000
committerClaudomator Agent <agent@claudomator>2026-07-08 22:19:49 +0000
commit46116500b757b176c15011329a52967b6010e24e (patch)
tree420d22c92d1087e29e4fa73f518055f742abc34a /internal
parent3f4bd9742003b70c81802772309b2abbe434ec72 (diff)
feat(api): add create_story/get_story/list_stories/accept_story to chatbot MCP
Diffstat (limited to 'internal')
-rw-r--r--internal/api/chatbotmcp.go82
-rw-r--r--internal/api/chatbotmcp_test.go138
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")
+ }
+}