summaryrefslogtreecommitdiff
path: root/internal/api/chatbotmcp_test.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/chatbotmcp_test.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/chatbotmcp_test.go')
-rw-r--r--internal/api/chatbotmcp_test.go233
1 files changed, 233 insertions, 0 deletions
diff --git a/internal/api/chatbotmcp_test.go b/internal/api/chatbotmcp_test.go
new file mode 100644
index 0000000..d13b0d7
--- /dev/null
+++ b/internal/api/chatbotmcp_test.go
@@ -0,0 +1,233 @@
+package api
+
+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"
+)
+
+// chatbotClient starts the api server with the given token and returns a
+// connected MCP client session pointed at /chatbot/mcp.
+func chatbotClient(t *testing.T, srv *Server, token string) *mcp.ClientSession {
+ t.Helper()
+ ts := httptest.NewServer(srv.Handler())
+ t.Cleanup(ts.Close)
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: token, base: http.DefaultTransport}},
+ }, nil)
+ if err != nil {
+ t.Fatalf("connect to /chatbot/mcp: %v", err)
+ }
+ t.Cleanup(func() { cs.Close() })
+ return cs
+}
+
+func callText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string {
+ t.Helper()
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args})
+ if err != nil {
+ t.Fatalf("CallTool %s transport error: %v", name, err)
+ }
+ if res.IsError {
+ t.Fatalf("CallTool %s returned tool error: %s", name, toolText(res))
+ }
+ return toolText(res)
+}
+
+func toolText(res *mcp.CallToolResult) string {
+ if len(res.Content) == 0 {
+ return ""
+ }
+ if tc, ok := res.Content[0].(*mcp.TextContent); ok {
+ return tc.Text
+ }
+ return ""
+}
+
+func TestChatbotMCP_RequiresToken(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ ts := httptest.NewServer(srv.Handler())
+ defer ts.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil)
+ _, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{
+ Endpoint: ts.URL + "/chatbot/mcp",
+ HTTPClient: &http.Client{Transport: tokenRT{token: "wrong", base: http.DefaultTransport}},
+ }, nil)
+ if err == nil {
+ t.Fatal("expected connect with wrong token to be rejected")
+ }
+}
+
+func TestChatbotMCP_SubmitTask_CreatesAndQueues(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "submit_task", map[string]any{
+ "instructions": "fix the flaky test in package foo",
+ "repository_url": "https://github.com/user/repo",
+ })
+ var ct compactTask
+ if err := json.Unmarshal([]byte(out), &ct); err != nil {
+ t.Fatalf("unmarshal submit_task result %q: %v", out, err)
+ }
+ if ct.ID == "" {
+ t.Fatal("submit_task returned empty task ID")
+ }
+ if ct.Name != "fix the flaky test in package foo" {
+ t.Errorf("name defaulted wrong: %q", ct.Name)
+ }
+ if _, err := store.GetTask(ct.ID); err != nil {
+ t.Fatalf("submitted task not persisted: %v", err)
+ }
+}
+
+func TestChatbotMCP_SubmitTask_RequiresRepo(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "submit_task",
+ Arguments: map[string]any{"instructions": "do a thing"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected submit_task without repository_url to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetAndListTasks(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-ready", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ got := callText(t, cs, "get_task", map[string]any{"task_id": "ct-ready"})
+ var view map[string]any
+ if err := json.Unmarshal([]byte(got), &view); err != nil {
+ t.Fatalf("unmarshal get_task: %v", err)
+ }
+ if view["id"] != "ct-ready" {
+ t.Errorf("get_task id: got %v", view["id"])
+ }
+
+ listed := callText(t, cs, "list_tasks", map[string]any{"state": "READY"})
+ var tasks []compactTask
+ if err := json.Unmarshal([]byte(listed), &tasks); err != nil {
+ t.Fatalf("unmarshal list_tasks: %v", err)
+ }
+ if len(tasks) != 1 || tasks[0].ID != "ct-ready" {
+ t.Errorf("list_tasks returned %+v", tasks)
+ }
+}
+
+func TestChatbotMCP_GetTask_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+ srv.SetAPIToken("s3cret")
+ cs := chatbotClient(t, srv, "s3cret")
+
+ res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "get_task",
+ Arguments: map[string]any{"task_id": "nope"},
+ })
+ if err != nil {
+ t.Fatalf("transport error: %v", err)
+ }
+ if !res.IsError {
+ t.Fatal("expected get_task on missing task to be a tool error")
+ }
+}
+
+func TestChatbotMCP_GetEvents(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-events", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ out := callText(t, cs, "get_events", map[string]any{"task_id": "ct-events"})
+ var events []map[string]any
+ if err := json.Unmarshal([]byte(out), &events); err != nil {
+ t.Fatalf("unmarshal get_events %q: %v", out, err)
+ }
+ // State walk to READY emits state_change events.
+ if len(events) == 0 {
+ t.Error("expected at least one event for a task that walked to READY")
+ }
+}
+
+func TestChatbotMCP_AcceptTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-accept", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "accept_task", map[string]any{"task_id": "ct-accept"})
+ got, _ := store.GetTask("ct-accept")
+ if got.State != task.StateCompleted {
+ t.Errorf("after accept_task: want COMPLETED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_RejectTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-reject", task.StateReady)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "reject_task", map[string]any{"task_id": "ct-reject", "comment": "needs work"})
+ got, _ := store.GetTask("ct-reject")
+ if got.State != task.StatePending {
+ t.Errorf("after reject_task: want PENDING, got %s", got.State)
+ }
+ if got.RejectionComment != "needs work" {
+ t.Errorf("rejection comment: got %q", got.RejectionComment)
+ }
+}
+
+func TestChatbotMCP_CancelTask(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-cancel", task.StateQueued)
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "cancel_task", map[string]any{"task_id": "ct-cancel"})
+ got, _ := store.GetTask("ct-cancel")
+ if got.State != task.StateCancelled {
+ t.Errorf("after cancel_task: want CANCELLED, got %s", got.State)
+ }
+}
+
+func TestChatbotMCP_AnswerQuestion(t *testing.T) {
+ srv, store := testServer(t)
+ srv.SetAPIToken("s3cret")
+ createTaskWithState(t, store, "ct-answer", task.StateBlocked)
+ if err := store.CreateExecution(&storage.Execution{
+ ID: "ct-answer-exec",
+ TaskID: "ct-answer",
+ SessionID: "550e8400-e29b-41d4-a716-446655440099",
+ Status: "BLOCKED",
+ }); err != nil {
+ t.Fatalf("create execution: %v", err)
+ }
+ cs := chatbotClient(t, srv, "s3cret")
+
+ callText(t, cs, "answer_question", map[string]any{"task_id": "ct-answer", "answer": "use main"})
+ got, _ := store.GetTask("ct-answer")
+ if got.State != task.StateQueued && got.State != task.StateRunning && got.State != task.StateReady {
+ t.Errorf("after answer_question: want QUEUED/RUNNING/READY, got %s", got.State)
+ }
+}