summaryrefslogtreecommitdiff
path: root/internal/api/chatbotmcp.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/chatbotmcp.go')
-rw-r--r--internal/api/chatbotmcp.go218
1 files changed, 218 insertions, 0 deletions
diff --git a/internal/api/chatbotmcp.go b/internal/api/chatbotmcp.go
new file mode 100644
index 0000000..e14b522
--- /dev/null
+++ b/internal/api/chatbotmcp.go
@@ -0,0 +1,218 @@
+package api
+
+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"
+)
+
+// 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 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
+ })
+
+ 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)
+}