package api 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" ) // The chatbot-facing MCP server lets a chatbot drive task creation and // orchestration. Unlike the per-task agent server, it spans all tasks and is // guarded by the single shared API bearer token (config api_token / SetAPIToken). type submitTaskMCPInput struct { Instructions string `json:"instructions" jsonschema:"complete instructions for the agent that will run this task"` Name string `json:"name,omitempty" jsonschema:"short human-readable task name; defaults to the first line of instructions"` Project string `json:"project,omitempty" jsonschema:"name of a configured project; used to resolve the repository when repository_url is omitted"` RepositoryURL string `json:"repository_url,omitempty" jsonschema:"git repository URL to work in; required unless a project resolves to one"` Model string `json:"model,omitempty" jsonschema:"optional model override, e.g. sonnet or opus"` MaxBudgetUSD float64 `json:"max_budget_usd,omitempty" jsonschema:"optional spend cap in USD"` Timeout string `json:"timeout,omitempty" jsonschema:"optional duration like 15m or 1h"` Tags []string `json:"tags,omitempty" jsonschema:"optional tags"` } type listTasksMCPInput struct { State string `json:"state,omitempty" jsonschema:"optional state filter, e.g. RUNNING, READY, BLOCKED, COMPLETED, FAILED"` Limit int `json:"limit,omitempty" jsonschema:"optional max number of tasks to return"` } type taskIDInput struct { TaskID string `json:"task_id" jsonschema:"the task ID"` } type getEventsMCPInput struct { TaskID string `json:"task_id" jsonschema:"the task ID"` SinceSeq int64 `json:"since_seq,omitempty" jsonschema:"return only events with seq greater than this value"` } type answerQuestionMCPInput struct { TaskID string `json:"task_id" jsonschema:"the task ID of the BLOCKED task"` Answer string `json:"answer" jsonschema:"the answer to the agent's question"` } type rejectTaskMCPInput struct { TaskID string `json:"task_id" jsonschema:"the task ID"` 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"` State task.State `json:"state"` Project string `json:"project,omitempty"` } func mcpText(text string) *mcp.CallToolResult { return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} } func mcpJSON(v any) (*mcp.CallToolResult, any, error) { b, err := json.MarshalIndent(v, "", " ") if err != nil { return nil, nil, err } return mcpText(string(b)), nil, nil } // newChatbotServer builds the chatbot-facing MCP server bound to this Server's // store and pool. func (s *Server) newChatbotServer() *mcp.Server { srv := mcp.NewServer(&mcp.Implementation{Name: "claudomator-chatbot", Version: "1"}, nil) mcp.AddTool(srv, &mcp.Tool{ Name: "submit_task", Description: "Create a task and immediately queue it for execution. Returns the new task ID and state.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in submitTaskMCPInput) (*mcp.CallToolResult, any, error) { spec := submitTaskSpec{ Name: in.Name, Instructions: in.Instructions, Project: in.Project, RepositoryURL: in.RepositoryURL, Model: in.Model, MaxBudgetUSD: in.MaxBudgetUSD, Tags: in.Tags, } if strings.TrimSpace(in.Timeout) != "" { d, err := time.ParseDuration(in.Timeout) if err != nil { return nil, nil, badRequestf("invalid timeout %q: %v", in.Timeout, err) } spec.Timeout = d } t, err := s.submitTask(ctx, spec) if err != nil { return nil, nil, err } return mcpJSON(compactTask{ID: t.ID, Name: t.Name, State: t.State, Project: t.Project}) }) mcp.AddTool(srv, &mcp.Tool{ Name: "list_tasks", Description: "List tasks, optionally filtered by state. Returns compact records; use get_task for full detail.", }, func(_ context.Context, _ *mcp.CallToolRequest, in listTasksMCPInput) (*mcp.CallToolResult, any, error) { filter := storage.TaskFilter{Limit: in.Limit} if in.State != "" { st := task.State(in.State) if !validTaskStates[st] { return nil, nil, badRequestf("invalid state %q", in.State) } filter.State = st } tasks, err := s.store.ListTasks(filter) if err != nil { return nil, nil, err } out := make([]compactTask, 0, len(tasks)) for _, tk := range tasks { out = append(out, compactTask{ID: tk.ID, Name: tk.Name, State: tk.State, Project: tk.Project}) } return mcpJSON(out) }) mcp.AddTool(srv, &mcp.Tool{ Name: "get_task", Description: "Get one task with enriched detail (changestats, deployment status, error message).", }, func(_ context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { tk, err := s.store.GetTask(in.TaskID) if err != nil { return nil, nil, errTaskNotFound } return mcpJSON(s.enrichTask(tk)) }) mcp.AddTool(srv, &mcp.Tool{ Name: "get_events", Description: "Get the observability event stream for a task in seq order. Pass since_seq to page incrementally.", }, func(_ context.Context, _ *mcp.CallToolRequest, in getEventsMCPInput) (*mcp.CallToolResult, any, error) { if _, err := s.store.GetTask(in.TaskID); err != nil { return nil, nil, errTaskNotFound } events, err := s.store.ListEvents(in.TaskID, in.SinceSeq) if err != nil { return nil, nil, err } if events == nil { events = []*event.Event{} } return mcpJSON(events) }) mcp.AddTool(srv, &mcp.Tool{ Name: "answer_question", Description: "Answer a BLOCKED task's clarification question. The task resumes from where the agent left off.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in answerQuestionMCPInput) (*mcp.CallToolResult, any, error) { if err := s.answerTaskQuestion(ctx, in.TaskID, in.Answer); err != nil { return nil, nil, err } return mcpText("Answer recorded; task queued for resume."), nil, nil }) mcp.AddTool(srv, &mcp.Tool{ Name: "accept_task", Description: "Accept a READY task, marking it COMPLETED.", }, func(ctx context.Context, _ *mcp.CallToolRequest, in taskIDInput) (*mcp.CallToolResult, any, error) { if err := s.acceptTask(ctx, in.TaskID, event.ActorChatbot); err != nil { return nil, nil, err } return mcpText("Task accepted."), nil, nil }) mcp.AddTool(srv, &mcp.Tool{ Name: "reject_task", Description: "Reject a READY task, sending it back to PENDING with an optional comment for the next attempt.", }, func(_ context.Context, _ *mcp.CallToolRequest, in rejectTaskMCPInput) (*mcp.CallToolResult, any, error) { if err := s.rejectTask(in.TaskID, in.Comment); err != nil { return nil, nil, err } return mcpText("Task rejected."), nil, nil }) 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 } // chatbotMCPHandler returns the HTTP handler for the chatbot MCP server. It // serves only when an API token is configured and the request presents it as a // bearer credential; otherwise the underlying handler receives a nil server and // rejects the request. func (s *Server) chatbotMCPHandler() http.Handler { srv := s.newChatbotServer() return mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { if s.apiToken == "" { return nil } tok := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if subtle.ConstantTimeCompare([]byte(tok), []byte(s.apiToken)) != 1 { return nil } return srv }, nil) }