summaryrefslogtreecommitdiff
path: root/docs/superpowers/plans/2026-07-08-story-mcp-surface.md
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-08 21:56:35 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-08 21:56:35 +0000
commit8e07b5dbcdd80d74a3b220b730c1b36a5de33aae (patch)
tree4ce7036e4964ea9aa7f7250717142f2b5156fde8 /docs/superpowers/plans/2026-07-08-story-mcp-surface.md
parentc82def744f23d09562f0bd980ee8333eeb1b90e4 (diff)
docs: add implementation plan for story-level chatbot MCP surface
Scopes the "MCP surface: story-level tools, additive to what exists" piece of the recursive-story-decomposition design spec on its own, independent of the larger deferred per-node arbitrated-review generalization and builder role system-prompt work.
Diffstat (limited to 'docs/superpowers/plans/2026-07-08-story-mcp-surface.md')
-rw-r--r--docs/superpowers/plans/2026-07-08-story-mcp-surface.md597
1 files changed, 597 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-08-story-mcp-surface.md b/docs/superpowers/plans/2026-07-08-story-mcp-surface.md
new file mode 100644
index 0000000..676b80d
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-08-story-mcp-surface.md
@@ -0,0 +1,597 @@
+# Story-Level Chatbot MCP Surface Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add `create_story`, `get_story`, `list_stories`, and `accept_story` to the chatbot-facing MCP server (`/chatbot/mcp`), so a chatbot can create and drive a Story the same way it already creates and drives a plain Task — closing the "MCP surface: story-level tools, additive to what exists" gap from the recursive-story-decomposition design spec.
+
+**Architecture:** Both new tools and the existing REST endpoints (`internal/api/stories.go`) must share one service-layer implementation, mirroring how `internal/api/taskops.go`'s `acceptTask`/`rejectTask`/`cancelTask` already back both `internal/api/server.go`'s REST handlers and `internal/api/chatbotmcp.go`'s `accept_task`/`reject_task`/`cancel_task` tools — so the two surfaces cannot drift. `create_story`/`get_story`/`list_stories` are thin wrappers around the existing `storage.DB.CreateStory`/`GetStory`/`ListStories` (no new service-layer method needed, since there's no shared state-transition logic to protect — unlike accept, which does need one, exactly like `acceptTask`).
+
+**Tech Stack:** Go, `github.com/modelcontextprotocol/go-sdk/mcp`, existing `internal/story`/`internal/storage` packages.
+
+## Global Constraints
+
+- Every new MCP tool must mirror an existing sibling tool's exact code shape in `internal/api/chatbotmcp.go` (input struct with `jsonschema` tags, `mcp.AddTool` registration, `mcpJSON`/`mcpText` for output) — this file has zero deviation from that shape today and this plan must not introduce any.
+- `story.Story` has **no `repository_url` field** — only the loose-reference `ProjectID` (mirrors `tasks.project`, unenforced, no automatic resolution to a repo). The design spec's prose lists `create_story`'s inputs as "(name, spec, acceptance_criteria, repository_url → story_id)", but that field does not exist on the data model and nothing yet resolves a repo from a story (that requires the deferred root-task-spawning piece of the recursive-decomposition design). This plan's `create_story` accepts `project_id` instead — do not add a `repository_url` field to `story.Story` or to storage as part of this plan; that would be schema scope well beyond an additive MCP wrapper.
+- Do not touch `internal/scheduler/story_orchestrator.go` or anything else in the recursive-decomposition design's larger, still-deferred pieces (per-node arbitrated review generalization, `builder` role system-prompt authoring). This plan is scoped to the MCP surface only.
+
+---
+
+## Task 1: Extract `acceptStory` service-layer helper
+
+**Files:**
+- Modify: `internal/api/stories.go` (add `errStoryNotFound`, add `acceptStory` method, refactor `handleAcceptStory` to call it)
+- Modify: `internal/api/taskops.go` (map `errStoryNotFound` to 404 in `writeOpError`)
+- Test: `internal/api/stories_test.go` (existing `TestServer_AcceptStory_*` tests must still pass unmodified — this task's own verification, no new tests needed)
+
+**Interfaces:**
+- Produces: `func (s *Server) acceptStory(id string, actor event.Actor) error` — Task 2 calls this directly from the new `accept_story` MCP tool.
+
+- [ ] **Step 1: Add `errStoryNotFound` and the `acceptStory` helper**
+
+In `internal/api/stories.go`, add `"errors"` to the import block (it currently imports `encoding/json`, `net/http`, `strconv`, `github.com/google/uuid`, `.../internal/event`, `.../internal/storage`, `.../internal/story`):
+
+```go
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/google/uuid"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/story"
+)
+```
+
+Directly after the imports, add:
+
+```go
+// errStoryNotFound is returned when a story ID does not resolve. REST maps
+// it to 404 (via writeOpError, mirroring errTaskNotFound in taskops.go); MCP
+// surfaces it as a tool error.
+var errStoryNotFound = errors.New("story not found")
+```
+
+Directly before `handleAcceptStory` (which you'll replace in Step 2), add this new method:
+
+```go
+// acceptStory is the shared service-layer accept-gate for a story: both
+// handleAcceptStory (REST) and the accept_story chatbot MCP tool call this,
+// mirroring taskops.go's shared task-operation pattern (see acceptTask) so
+// the two surfaces cannot drift.
+func (s *Server) acceptStory(id string, actor event.Actor) error {
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ return errStoryNotFound
+ }
+ if st.Status != "REVIEW_READY" {
+ return conflictf("story cannot be accepted from status %s", st.Status)
+ }
+
+ from := st.Status
+ st.Status = "DONE"
+ if err := s.store.UpdateStory(st); err != nil {
+ return err
+ }
+
+ payload, _ := json.Marshal(struct {
+ StoryID string `json:"story_id"`
+ From string `json:"from"`
+ To string `json:"to"`
+ }{StoryID: id, From: from, To: "DONE"})
+ if err := s.store.CreateEvent(&event.Event{
+ TaskID: id,
+ Kind: event.KindHumanAccepted,
+ Actor: actor,
+ Payload: payload,
+ }); err != nil {
+ s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err)
+ }
+ return nil
+}
+```
+
+- [ ] **Step 2: Refactor `handleAcceptStory` to call it**
+
+Find the existing `handleAcceptStory` function (its whole body, including the doc comment above it):
+
+```go
+// handleAcceptStory is the human accept-gate for a story: it mirrors
+// handleAcceptTask's pattern exactly (validate current state, transition,
+// emit an event) but operates on story.Story, which (unlike task.Task) has
+// no enforced state machine (story.UpdateStory is unvalidated pass-through —
+// see internal/story's doc comments) — so the "only valid from REVIEW_READY"
+// check is done here, not in the storage layer.
+func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ st, err := s.store.GetStory(id)
+ if err != nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+ if st.Status != "REVIEW_READY" {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": "story cannot be accepted from status " + st.Status})
+ return
+ }
+
+ from := st.Status
+ st.Status = "DONE"
+ if err := s.store.UpdateStory(st); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+
+ payload, _ := json.Marshal(struct {
+ StoryID string `json:"story_id"`
+ From string `json:"from"`
+ To string `json:"to"`
+ }{StoryID: id, From: from, To: "DONE"})
+ if err := s.store.CreateEvent(&event.Event{
+ TaskID: id,
+ Kind: event.KindHumanAccepted,
+ Actor: event.ActorUser,
+ Payload: payload,
+ }); err != nil {
+ s.logger.Error("failed to record human_accepted event", "storyID", id, "error", err)
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id})
+}
+```
+
+Replace it with:
+
+```go
+// handleAcceptStory is the human accept-gate for a story: it mirrors
+// handleAcceptTask's pattern exactly (validate current state, transition,
+// emit an event), delegating to the shared acceptStory service-layer helper
+// so the chatbot MCP accept_story tool cannot drift from this REST behavior.
+func (s *Server) handleAcceptStory(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ if err := s.acceptStory(id, event.ActorUser); err != nil {
+ writeOpError(w, err)
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"message": "story accepted", "story_id": id})
+}
+```
+
+- [ ] **Step 3: Map `errStoryNotFound` to 404 in `writeOpError`**
+
+In `internal/api/taskops.go`, find `writeOpError`:
+
+```go
+// writeOpError maps a service-layer error to the appropriate REST status.
+func writeOpError(w http.ResponseWriter, err error) {
+ switch {
+ case errors.Is(err, errTaskNotFound):
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ }
+ var ce *conflictError
+ if errors.As(err, &ce) {
+ writeJSON(w, http.StatusConflict, map[string]string{"error": ce.Error()})
+ return
+ }
+ var be *badRequestError
+ if errors.As(err, &be) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": be.Error()})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+}
+```
+
+Replace the `switch` block with:
+
+```go
+ switch {
+ case errors.Is(err, errTaskNotFound):
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
+ return
+ case errors.Is(err, errStoryNotFound):
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "story not found"})
+ return
+ }
+```
+
+- [ ] **Step 4: Run the existing story tests to confirm the refactor is behavior-preserving**
+
+Run: `go test ./internal/api/ -run TestServer_AcceptStory -v`
+Expected: `TestServer_AcceptStory_SucceedsFromReviewReady`, `TestServer_AcceptStory_FailsFromOtherStatuses`, and `TestServer_AcceptStory_NotFound` all PASS unmodified — this task is a pure refactor, so these pre-existing tests are the proof it preserves REST behavior exactly. No new tests are added in this task.
+
+- [ ] **Step 5: Run the full package suite, then the full repo suite**
+
+Run: `go test ./internal/api/...`
+Expected: PASS for everything.
+
+Then run: `go test ./...` (the entire repo). A prior unrelated plan in this repo (`docs/superpowers/plans/2026-07-08-subtask-ordering-and-verdict-reporting.md`, Task 4) found that task-scoped tests passing is not sufficient — a separate package's code can fail to compile due to a change here. This task doesn't change any shared interface, but verify the full suite anyway rather than assuming. Paste the actual output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/api/stories.go internal/api/taskops.go
+git commit -m "refactor(api): extract acceptStory service-layer helper, mirroring acceptTask"
+```
+
+---
+
+## Task 2: Add `create_story`/`get_story`/`list_stories`/`accept_story` chatbot MCP tools
+
+**Files:**
+- Modify: `internal/api/chatbotmcp.go` (new input structs + 4 new `mcp.AddTool` registrations)
+- Test: `internal/api/chatbotmcp_test.go` (append)
+
+**Interfaces:**
+- Consumes: `s.acceptStory` (Task 1), `s.store.CreateStory`/`GetStory`/`ListStories` (existing, `internal/storage/story.go`), `storage.StoryFilter{Status, EpicID}` (existing).
+- Produces: nothing new for later work — this is the last piece of the story-level MCP surface described in the design spec.
+
+- [ ] **Step 1: Add the new input structs and imports**
+
+In `internal/api/chatbotmcp.go`, the import block currently reads:
+
+```go
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/event"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+```
+
+Replace it with (adding `github.com/google/uuid` and `.../internal/story`):
+
+```go
+import (
+ "context"
+ "crypto/subtle"
+ "encoding/json"
+ "net/http"
+ "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"
+)
+```
+
+Directly after the existing `rejectTaskMCPInput` struct (find it — ends with its closing `}`, directly before `type compactTask struct`), add these three new input structs:
+
+```go
+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"`
+}
+```
+
+- [ ] **Step 2: Register the four new tools**
+
+In the same file, find the `cancel_task` tool registration (the last `mcp.AddTool` call before `return srv`):
+
+```go
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "cancel_task",
+ Description: "Cancel a RUNNING or QUEUED task.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ msg, err := s.cancelTask(in.TaskID, event.ActorChatbot)
+ if err != nil {
+ return nil, nil, err
+ }
+ return mcpText(msg), nil, nil
+ })
+
+ return srv
+```
+
+Replace it with (adding the four new registrations directly after `cancel_task`, before `return srv`):
+
+```go
+ mcp.AddTool(srv, &mcp.Tool{
+ Name: "cancel_task",
+ Description: "Cancel a RUNNING or QUEUED task.",
+ }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) {
+ msg, err := s.cancelTask(in.TaskID, event.ActorChatbot)
+ if err != nil {
+ return nil, nil, err
+ }
+ 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
+```
+
+- [ ] **Step 3: Write the failing tests**
+
+First, update the import block at the top of `internal/api/chatbotmcp_test.go`. It currently reads:
+
+```go
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+```
+
+Replace it with (adding `.../internal/event` and `.../internal/story`):
+
+```go
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "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"
+)
+```
+
+Then append the following tests to the end of the file:
+
+```go
+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")
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run: `go test ./internal/api/ -run 'TestChatbotMCP_(CreateStory|GetStory|ListStories|AcceptStory)' -v`
+
+Expected: compile failure — `create_story`/`get_story`/`list_stories`/`accept_story` tools don't exist yet. Paste the actual output.
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run the same command as Step 4.
+Expected: PASS, all 6 new tests. Paste the actual output.
+
+- [ ] **Step 6: Run the full package suite, then the full repo suite**
+
+Run: `go test ./internal/api/...`
+Expected: PASS for everything, including the pre-existing `TestChatbotMCP_*` and `TestServer_*Story*` tests.
+
+Then run: `go test ./...` (the entire repo). This must also pass — paste the actual output.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/api/chatbotmcp.go internal/api/chatbotmcp_test.go
+git commit -m "feat(api): add create_story/get_story/list_stories/accept_story to chatbot MCP"
+```
+
+---
+
+## Final Verification
+
+- [ ] Run `go build ./...` — passes.
+- [ ] Run `go test ./...` — passes, full repo.
+- [ ] Run `grep -rn "create_story\|get_story\|list_stories\|accept_story\|acceptStory\|errStoryNotFound" internal/` to visually confirm every file in both tasks' File Maps was actually touched.