summaryrefslogtreecommitdiff
path: root/internal/api/server.go
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-25 04:57:37 +0000
committerClaude <noreply@anthropic.com>2026-05-25 04:57:37 +0000
commite766b4c3ef13181ec6adf90ec4abe9db0129fab1 (patch)
tree760b26fdd0342d1d41371d25a0950e83610ced13 /internal/api/server.go
parent1ea57a7dbf4d34045a361e9bba9191de06cd1749 (diff)
feat(api): chatbot-facing MCP server for task orchestration (Phase 3)
Adds a chatbot-facing MCP server mounted at /chatbot/mcp, guarded by the shared API bearer token (new config api_token, wired through SetAPIToken). Tools: submit_task, list_tasks, get_task, get_events, answer_question, accept_task, reject_task, cancel_task. submit_task creates and immediately queues a task, resolving the repository URL from a named project when not given explicitly. To keep the REST API and the MCP surface from drifting, the accept/reject/ cancel/answer operations are extracted into a shared service layer (taskops.go) with typed errors mapped to REST status codes; the existing HTTP handlers now delegate to it. The endpoint is only served when a token is configured and presented as a bearer. https://claude.ai/code/session_01SESwn7kQ7oP62trWw6pc39
Diffstat (limited to 'internal/api/server.go')
-rw-r--r--internal/api/server.go125
1 files changed, 15 insertions, 110 deletions
diff --git a/internal/api/server.go b/internal/api/server.go
index 1522b72..c9fadb9 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -191,6 +191,12 @@ func (s *Server) routes() {
s.mux.Handle("GET /mcp", mcpHandler)
s.mux.Handle("DELETE /mcp", mcpHandler)
}
+ // Chatbot-facing MCP server. Always mounted; the handler refuses to serve
+ // unless a shared API token is configured and presented as a bearer.
+ chatbotHandler := s.chatbotMCPHandler()
+ s.mux.Handle("POST /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("GET /chatbot/mcp", chatbotHandler)
+ s.mux.Handle("DELETE /chatbot/mcp", chatbotHandler)
s.mux.Handle("GET /", http.FileServerFS(webui.Files))
}
@@ -282,41 +288,16 @@ func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCancelTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
- tk, err := s.store.GetTask(taskID)
+ msg, err := s.cancelTask(taskID, event.ActorUser)
if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- // If the task is actively running in the pool, cancel it there.
- if s.pool.Cancel(taskID) {
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancellation requested", "task_id": taskID})
- return
- }
- // For non-running tasks (PENDING, QUEUED), transition directly to CANCELLED.
- if !task.ValidTransition(tk.State, task.StateCancelled) {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task cannot be cancelled from state " + string(tk.State)})
- return
- }
- if err := s.store.UpdateTaskStateBy(taskID, task.StateCancelled, event.ActorUser); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to cancel task"})
+ writeOpError(w, err)
return
}
- writeJSON(w, http.StatusOK, map[string]string{"message": "task cancelled", "task_id": taskID})
+ writeJSON(w, http.StatusOK, map[string]string{"message": msg, "task_id": taskID})
}
func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
-
- tk, err := s.questionStore.GetTask(taskID)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if tk.State != task.StateBlocked {
- writeJSON(w, http.StatusConflict, map[string]string{"error": "task is not blocked"})
- return
- }
-
var input struct {
Answer string `json:"answer"`
}
@@ -324,61 +305,10 @@ func (s *Server) handleAnswerQuestion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if input.Answer == "" {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "answer is required"})
- return
- }
-
- // Look up the session ID from the most recent execution.
- latest, err := s.questionStore.GetLatestExecution(taskID)
- if err != nil || latest.SessionID == "" {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "no resumable session found"})
- return
- }
-
- // Record the Q&A interaction before clearing the question.
- if tk.QuestionJSON != "" {
- var qData struct {
- Text string `json:"text"`
- Options []string `json:"options"`
- }
- if jsonErr := json.Unmarshal([]byte(tk.QuestionJSON), &qData); jsonErr == nil {
- interaction := task.Interaction{
- QuestionText: qData.Text,
- Options: qData.Options,
- Answer: input.Answer,
- AskedAt: tk.UpdatedAt,
- }
- if appendErr := s.questionStore.AppendTaskInteraction(taskID, interaction); appendErr != nil {
- s.logger.Error("failed to append interaction", "taskID", taskID, "error", appendErr)
- }
- }
- }
-
- // Clear the question and transition to QUEUED.
- if err := s.questionStore.UpdateTaskQuestion(taskID, ""); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to clear question"})
- return
- }
- if err := s.questionStore.UpdateTaskState(taskID, task.StateQueued); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to queue task"})
+ if err := s.answerTaskQuestion(s.ctx, taskID, input.Answer); err != nil {
+ writeOpError(w, err)
return
}
-
- // Submit a resume execution. Carry the sandbox path so the runner uses
- // the same working directory where Claude stored its session files.
- resumeExec := &storage.Execution{
- ID: uuid.New().String(),
- TaskID: taskID,
- ResumeSessionID: latest.SessionID,
- ResumeAnswer: input.Answer,
- SandboxDir: latest.SandboxDir,
- }
- if err := s.pool.SubmitResume(s.ctx, tk, resumeExec); err != nil {
- writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
- return
- }
-
writeJSON(w, http.StatusOK, map[string]string{"message": "task queued for resume", "task_id": taskID})
}
@@ -704,40 +634,15 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAcceptTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StateCompleted) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be accepted from state %s", t.State),
- })
- return
- }
- if err := s.store.UpdateTaskStateBy(id, task.StateCompleted, event.ActorUser); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.acceptTask(r.Context(), id, event.ActorUser); err != nil {
+ writeOpError(w, err)
return
}
- if t.StoryID != "" {
- go s.pool.CheckStoryCompletion(r.Context(), t.StoryID)
- }
writeJSON(w, http.StatusOK, map[string]string{"message": "task accepted", "task_id": id})
}
func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
- t, err := s.store.GetTask(id)
- if err != nil {
- writeJSON(w, http.StatusNotFound, map[string]string{"error": "task not found"})
- return
- }
- if !task.ValidTransition(t.State, task.StatePending) {
- writeJSON(w, http.StatusConflict, map[string]string{
- "error": fmt.Sprintf("task cannot be rejected from state %s", t.State),
- })
- return
- }
var input struct {
Comment string `json:"comment"`
}
@@ -745,8 +650,8 @@ func (s *Server) handleRejectTask(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
return
}
- if err := s.store.RejectTask(id, input.Comment); err != nil {
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ if err := s.rejectTask(id, input.Comment); err != nil {
+ writeOpError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "task rejected", "task_id": id})