Files
kandev/apps/backend/internal/mcp/server/server.go
T
João Salavisa 0b7bbd93d5 feat(mcp): accept repository_url and local_path on add_branch_to_task_kandev (#1256)
* feat(mcp): accept repository_url and local_path on add_branch_to_task_kandev

Mirror create_task_kandev's repo-locator surface so agents can attach a
branch without first looking up the repository UUID. The MCP tool now
takes repository_id OR repository_url OR local_path; the service
resolves alternatives via the same workspace-scoped find-or-create
path used by create_task.

* fix(mcp): run executor guard before repo resolution on add_branch_to_task

ResolveRepositoryRef may FindOrCreateRepository / CreateRepository
inside the github_url / local_path paths. Running it before
requireWorktreeExecutorForBranchAdd let a rejected add_branch call
on a docker/sprites/SSH task leak an orphan Repository row into
the workspace.

Move the executor check ahead of resolution and add a regression
test that asserts ListRepositories count is unchanged after a
rejected call with a brand-new github_url.

* test(mcp): assert task_id default in add_branch local_path test

* fix(mcp): reject multiple repo locators on add_branch_to_task

resolveRepoInput applies a silent precedence (repository_id >
github_url > local_path), so an agent that accidentally supplies
two of the three locators got a behaviour change with no signal.
Reject at the WS handler with a clear error so the mistake is
surfaced.

Also refresh the AddBranchToTask docstring to document the new
LocalPath / GitHubURL inputs and to correct the workspace-membership
check ownership (now enforced by ResolveRepositoryRef → resolveRepoInput
for the alternative paths).

* fix(mcp): classify resolve_repository errors as validation, not internal

Failures from ResolveRepositoryRef are user-fixable input mistakes
(malformed URL, unknown local_path, cross-workspace repository_id).
Without a matching case the wrapper "resolve repository: ..." message
fell through to ErrorCodeInternalError, so an agent that supplied
e.g. a bitbucket URL saw a server-fault code instead of a validation
error it could correct.

* fix(mcp): narrow add_branch error classification + use wire field name

Two follow-ups to the round-3/4 review feedback:

- Mutual-exclusion error mentioned the MCP tool alias `repository_url`
  but the WS handler sees the wire field `github_url`. A direct WS
  caller would see an error naming a field it never passed.
- The broad `"resolve repository: "` prefix classified ALL wrapped
  errors as Validation, including DB / system failures from
  CreateRepository / ListRepositories inside ResolveRepositoryRef.
  Narrow to specific user-fixable patterns (GitHub URL parse failures,
  cross-workspace repository_id) so genuine internal errors no longer
  masquerade as validation rejections.

* fix(mcp): mutual-exclusion check at MCP tier names agent-facing alias

WS-tier rejection works for direct WS callers but its error mentions
github_url, the wire field — confusing for MCP agents that supplied
repository_url. Move the check up to addBranchToTaskHandler so the
error reads "pass at most one of repository_id, repository_url,
local_path" using the names the agent actually typed. WS handler
still validates as defense-in-depth.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-03 03:04:04 +01:00

778 lines
37 KiB
Go

// Package mcp provides MCP server functionality for agentctl.
// It exposes MCP tools that forward requests to the Kandev backend via the agent stream.
package mcp
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// BackendClient is the interface for communicating with the Kandev backend.
// MCP tool handlers use this to forward requests to the backend.
type BackendClient interface {
// RequestPayload sends a request to the backend and unmarshals the response.
RequestPayload(ctx context.Context, action string, payload, result interface{}) error
}
// MCP mode constants control which tools are registered.
const (
// ModeTask registers kanban, plan, and interaction tools (default for task-solving agents).
ModeTask = "task"
// ModeConfig registers configuration tools for workflows, agents, and MCP servers.
ModeConfig = "config"
// ModeExternal registers config tools plus create_task_kandev for external coding agents
// (Claude Code, Cursor, etc.) that connect to the backend's MCP endpoint.
// No session-scoped tools (plan, ask_user_question) since there is no live session.
ModeExternal = "external"
// ModeOffice registers plan and interaction tools for office agents.
// Kanban tools are excluded because office agents use CLI commands instead.
ModeOffice = "office"
)
// MCP payload keys reused across tool registrations. Extracted so a future
// wire-protocol rename touches every tool in one place AND so goconst
// doesn't flag the literals as repeated string occurrences.
const (
mcpKeyTaskID = "task_id"
mcpKeyRepositoryID = "repository_id"
mcpKeyRepositoryURL = "repository_url"
mcpKeyLocalPath = "local_path"
mcpKeyGitHubURL = "github_url"
mcpKeyBaseBranch = "base_branch"
mcpKeyCheckoutBranch = "checkout_branch"
)
// locatorCount returns how many of the supplied repository-locator strings
// are non-empty. Used by add_branch / create_task mutual-exclusion checks
// so a chain of `if a != "" && b != "" { ... }` doesn't repeat at each call
// site.
func locatorCount(locators ...string) int {
n := 0
for _, s := range locators {
if s != "" {
n++
}
}
return n
}
// normalizeMode returns a valid MCP mode, defaulting unknown values to ModeTask.
func normalizeMode(mode string) string {
switch mode {
case ModeConfig, ModeExternal, ModeOffice:
return mode
default:
return ModeTask
}
}
// Server wraps the MCP server with backend client for communication.
type Server struct {
backend BackendClient
sessionID string
taskID string
disableAskQuestion bool
mode string // "task" (default), "config", or "office"
mcpServer *server.MCPServer
sseServer *server.SSEServer
httpServer *server.StreamableHTTPServer
logger *logger.Logger
mcpLogger *zap.Logger // optional file logger for MCP debug traces
mu sync.Mutex
running bool
}
// New creates a new MCP server for agentctl.
// port is the HTTP server port used to build the SSE base URL (http://localhost:<port>).
// mcpLogFile is an optional file path for MCP debug logging; pass "" to disable.
func New(backend BackendClient, sessionID, taskID string, port int, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, mcpMode string) *Server {
s := newServer(backend, sessionID, taskID, log, mcpLogFile, disableAskQuestion, mcpMode)
// Create SSE server for Claude Desktop, Cursor, etc.
// WithBaseURL ensures the SSE endpoint event includes the full message URL
// (e.g. http://localhost:10005/message?sessionId=xxx) so MCP clients can POST back.
s.sseServer = server.NewSSEServer(s.mcpServer,
server.WithBaseURL(fmt.Sprintf("http://localhost:%d", port)),
)
// Create Streamable HTTP server for Codex
s.httpServer = server.NewStreamableHTTPServer(s.mcpServer,
server.WithEndpointPath("/mcp"),
)
return s
}
// NewExternal creates an MCP server for the Kandev backend's external endpoint.
// External coding agents (Claude Code, Cursor, etc.) connect here to manage Kandev
// configuration and create tasks. Routes are mounted under /mcp on the backend.
//
// baseURL is the publicly reachable backend URL (e.g. "http://localhost:38429").
// It is used to build the message endpoint URL emitted in SSE events.
func NewExternal(backend BackendClient, baseURL string, log *logger.Logger, mcpLogFile string) *Server {
// External mode has no live session, so disable ask-question and use empty IDs.
s := newServer(backend, "", "", log, mcpLogFile, true, ModeExternal)
// SSE handlers are mounted at /mcp/sse and /mcp/message — the static base path
// makes the SSE endpoint event emit the correct full message URL.
s.sseServer = server.NewSSEServer(s.mcpServer,
server.WithBaseURL(baseURL),
server.WithStaticBasePath("/mcp"),
)
// Streamable HTTP transport handler — mounted at /mcp on the backend.
s.httpServer = server.NewStreamableHTTPServer(s.mcpServer,
server.WithEndpointPath("/mcp"),
)
return s
}
// newServer builds the shared parts of a Server (logger, mcp-go server, tools).
// Callers are responsible for constructing sseServer and httpServer with the
// transport configuration appropriate for their hosting environment.
func newServer(backend BackendClient, sessionID, taskID string, log *logger.Logger, mcpLogFile string, disableAskQuestion bool, mcpMode string) *Server {
mcpMode = normalizeMode(mcpMode)
s := &Server{
backend: backend,
sessionID: sessionID,
taskID: taskID,
disableAskQuestion: disableAskQuestion,
mode: mcpMode,
logger: log.WithFields(zap.String("component", "mcp-server")),
}
// Set up optional file logger for MCP debug traces
if mcpLogFile != "" {
fileCfg := zap.NewProductionConfig()
fileCfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
fileCfg.OutputPaths = []string{mcpLogFile}
fileCfg.ErrorOutputPaths = []string{mcpLogFile}
if fl, err := fileCfg.Build(); err == nil {
s.mcpLogger = fl
log.Info("MCP file logger enabled", zap.String("path", mcpLogFile))
} else {
log.Warn("failed to create MCP file logger", zap.Error(err))
}
}
s.mcpServer = server.NewMCPServer(
"kandev-mcp",
"1.0.0",
server.WithToolCapabilities(true),
)
s.registerTools()
s.running = true
return s
}
// RegisterRoutes adds MCP routes to the gin router at the root.
// Used by agentctl which serves the MCP transport at /sse, /message, /mcp.
func (s *Server) RegisterRoutes(router gin.IRouter) {
router.GET("/sse", gin.WrapH(s.sseServer.SSEHandler()))
router.POST("/message", gin.WrapH(s.sseServer.MessageHandler()))
router.Any("/mcp", gin.WrapH(s.httpServer))
s.logger.Info("registered MCP routes", zap.String("sse", "/sse"), zap.String("http", "/mcp"))
}
// RegisterBackendRoutes adds MCP routes namespaced under /mcp to the gin router.
// Used by the Kandev backend so that all MCP endpoints (/mcp, /mcp/sse, /mcp/message)
// share a clean URL prefix on the multi-purpose backend HTTP server.
func (s *Server) RegisterBackendRoutes(router gin.IRouter) {
router.GET("/mcp/sse", gin.WrapH(s.sseServer.SSEHandler()))
router.POST("/mcp/message", gin.WrapH(s.sseServer.MessageHandler()))
router.Any("/mcp", gin.WrapH(s.httpServer))
s.logger.Info("registered MCP backend routes",
zap.String("sse", "/mcp/sse"),
zap.String("message", "/mcp/message"),
zap.String("http", "/mcp"))
}
// Close shuts down the MCP server.
func (s *Server) Close(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return nil
}
s.running = false
if s.sseServer != nil {
if err := s.sseServer.Shutdown(ctx); err != nil {
s.logger.Warn("failed to shutdown SSE server", zap.Error(err))
}
}
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
s.logger.Warn("failed to shutdown HTTP server", zap.Error(err))
}
}
if s.mcpLogger != nil {
_ = s.mcpLogger.Sync()
}
return nil
}
// wrapHandler wraps a tool handler with debug logging for tracing MCP calls.
func (s *Server) wrapHandler(toolName string, handler server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
start := time.Now()
args := req.GetArguments()
s.logger.Debug("MCP tool call",
zap.String("tool", toolName),
zap.Any("args", args))
if s.mcpLogger != nil {
s.mcpLogger.Debug("MCP tool call",
zap.String("tool", toolName),
zap.String("session_id", s.sessionID),
zap.Any("args", args))
}
result, err := handler(ctx, req)
duration := time.Since(start)
switch {
case err != nil:
s.logger.Debug("MCP tool error",
zap.String("tool", toolName),
zap.Duration("duration", duration),
zap.Error(err))
if s.mcpLogger != nil {
s.mcpLogger.Debug("MCP tool error",
zap.String("tool", toolName),
zap.String("session_id", s.sessionID),
zap.Duration("duration", duration),
zap.Error(err))
}
case result != nil && result.IsError:
s.logger.Debug("MCP tool returned error",
zap.String("tool", toolName),
zap.Duration("duration", duration),
zap.Any("result", result.Content))
if s.mcpLogger != nil {
s.mcpLogger.Debug("MCP tool returned error",
zap.String("tool", toolName),
zap.String("session_id", s.sessionID),
zap.Duration("duration", duration),
zap.Any("result", result.Content))
}
default:
s.logger.Debug("MCP tool success",
zap.String("tool", toolName),
zap.Duration("duration", duration))
if s.mcpLogger != nil {
s.mcpLogger.Debug("MCP tool success",
zap.String("tool", toolName),
zap.String("session_id", s.sessionID),
zap.Duration("duration", duration))
}
}
return result, err
}
}
// SetMode changes the MCP server mode and re-registers tools accordingly.
// This allows reconfiguring the tool set after initial creation (e.g., when
// a session transitions to plan/config mode on a pre-existing workspace).
func (s *Server) SetMode(mode string) {
s.mu.Lock()
defer s.mu.Unlock()
s.mode = normalizeMode(mode)
// Clear all existing tools and re-register for the new mode.
s.mcpServer.SetTools() // empty call clears all tools
s.registerTools()
}
// registerTools registers MCP tools based on the server mode.
func (s *Server) registerTools() {
count := 0
switch s.mode {
case ModeConfig:
s.registerConfigWorkflowTools()
count += 12
s.registerConfigAgentTools()
count += 4
s.registerConfigMcpTools()
count += 4
s.registerConfigExecutorTools()
count += 5
s.registerConfigTaskTools()
count += 6
if !s.disableAskQuestion {
s.registerInteractionTools()
count++
}
case ModeExternal:
// External coding agents get config tools plus create_task_kandev so
// they can both manage Kandev configuration and spawn new tasks.
// No interaction or plan tools (no live session to attach them to).
s.registerConfigWorkflowTools()
count += 12
s.registerConfigAgentTools()
count += 4
s.registerConfigMcpTools()
count += 4
s.registerConfigExecutorTools()
count += 5
s.registerConfigTaskTools()
count += 6
s.registerCreateTaskTool()
count++
case ModeOffice:
// Office agents use `agentctl kandev …` subcommands for every
// office mutation (create task, delegate, comment, …). The MCP
// surface for office mode only keeps:
// - ask_user_question — interactive prompt path
// - plan tools — structured plan capture
// - related-tasks — discover parent/child/sibling IDs
// - task-document tools — parent/child coordination docs
// delegate_task was removed in favour of
// `agentctl kandev tasks create --parent $KANDEV_TASK_ID …`.
if !s.disableAskQuestion {
s.registerInteractionTools()
count++
}
s.registerPlanTools()
count += 4
s.registerRelatedTasksTool()
count++
s.registerTaskDocumentTools()
count += 3
default: // ModeTask
// Kanban tasks get list_related_tasks_kandev (useful for finding
// a sibling to message_task_kandev) but NOT the task-document
// tools — those are office coordination plumbing.
s.registerKanbanTools()
count += 13
if !s.disableAskQuestion {
s.registerInteractionTools()
count++
}
s.registerPlanTools()
count += 4
s.registerRelatedTasksTool()
count++
// Task-mode only: requires a live session to attach the new
// (repository, branch) to. External mode has no such context.
s.registerAddBranchToTaskTool()
count++
}
s.logger.Info("registered MCP tools",
zap.String("mode", s.mode),
zap.Int("count", count),
zap.Bool("disable_ask_question", s.disableAskQuestion))
}
func (s *Server) registerKanbanTools() {
// Use NewToolWithRawSchema for parameter-less tools to ensure the schema
// includes "properties": {}. The default ToolInputSchema type in mcp-go uses
// omitempty which drops empty properties maps, causing OpenAI API validation
// errors ("object schema missing properties").
s.mcpServer.AddTool(
mcp.NewToolWithRawSchema("list_workspaces_kandev",
"List all workspaces. Use this first to get workspace IDs.",
json.RawMessage(`{"type":"object","properties":{}}`),
),
s.wrapHandler("list_workspaces_kandev", s.listWorkspacesHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("list_workflows_kandev",
mcp.WithDescription("List all workflows in a workspace."),
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
),
s.wrapHandler("list_workflows_kandev", s.listWorkflowsHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("list_workflow_steps_kandev",
mcp.WithDescription("List all workflow steps in a workflow."),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")),
),
s.wrapHandler("list_workflow_steps_kandev", s.listWorkflowStepsHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("list_tasks_kandev",
mcp.WithDescription("List all tasks in a workflow. Each task includes its associated GitHub pull requests (number, url, title, state) under the \"prs\" field when any exist — use the PR state (open/closed/merged) to find tasks whose work has landed."),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("The workflow ID")),
),
s.wrapHandler("list_tasks_kandev", s.listTasksHandler()),
)
s.registerCreateTaskTool()
s.mcpServer.AddTool(
mcp.NewToolWithRawSchema("list_agents_kandev",
"List all configured agents with their profiles. Use this to find available agent_profile_ids for create_task_kandev.",
json.RawMessage(`{"type":"object","properties":{}}`),
),
s.wrapHandler("list_agents_kandev", s.listAgentsHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("list_executor_profiles_kandev",
mcp.WithDescription("List all profiles for an executor. Use this to find available executor_profile_ids for create_task_kandev. Standard executor IDs: exec-local (standalone process), exec-worktree (git worktree), exec-local-docker (Docker container), exec-sprites (cloud)."),
mcp.WithString("executor_id", mcp.Required(), mcp.Description("The executor ID (e.g. exec-local, exec-worktree, exec-local-docker, exec-sprites)")),
),
s.wrapHandler("list_executor_profiles_kandev", s.listExecutorProfilesHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("update_task_kandev",
mcp.WithDescription("Update an existing task."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
mcp.WithString("title", mcp.Description("New title")),
mcp.WithString("description", mcp.Description("New description")),
mcp.WithString("state", mcp.Description("New state: not_started, in_progress, etc.")),
),
s.wrapHandler("update_task_kandev", s.updateTaskHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("move_task_kandev",
mcp.WithDescription("Move a task to a different workflow step. Optionally send a hand-off prompt to the receiving agent — required only when handing the task off mid-turn (e.g. QA → review) with specific instructions. Plain admin/config moves can omit prompt."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
mcp.WithString("workflow_id", mcp.Required(), mcp.Description("Target workflow ID")),
mcp.WithString("workflow_step_id", mcp.Required(), mcp.Description("Target workflow step ID")),
mcp.WithNumber("position", mcp.Description("Position within the step (0-based)")),
mcp.WithString("prompt", mcp.Description("Optional hand-off message for the receiving agent. When supplied AND the source session is mid-turn, the move is deferred to the agent's turn-end and the prompt is delivered at the new step (concatenated after the step's own auto_start prompt, if any). Omit for plain admin/config moves where there's no agent to address.")),
),
s.wrapHandler("move_task_kandev", s.moveTaskHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("delete_task_kandev",
mcp.WithDescription("Delete a task permanently. Use to clean up orphaned, duplicate, or test tasks you no longer need. This cannot be undone — prefer archive_task_kandev when the task may still be wanted. Restoring an archived task is a user action done from the UI, not via MCP."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to delete")),
),
s.wrapHandler("delete_task_kandev", s.deleteTaskHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("archive_task_kandev",
mcp.WithDescription("Archive a task. The task is hidden from active board views but kept in the database. Use to tidy up finished or abandoned tasks. Unarchiving is a user action done from the UI, not via MCP."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to archive")),
),
s.wrapHandler("archive_task_kandev", s.archiveTaskHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("message_task_kandev",
mcp.WithDescription(`Send a follow-up prompt (message) to an existing task's primary session.
Use this to communicate with a sibling task, a parent task, or any task you know the ID of — for example to ask a delegated subtask for clarification, hand it new context, or nudge a paused task forward.
Behaviour by session state:
- Running/starting: the message is queued and delivered when the current turn ends.
- Idle (waiting for input or completed): the message is sent immediately as a new turn.
- Created (not yet started): the agent is started with this message as its first prompt.
- Failed/cancelled: an error is returned (use create_task_kandev to start fresh).
Returns the dispatch status: "queued", "sent", or "started".`),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The target task's full UUID (not a truncated prefix)")),
mcp.WithString("prompt", mcp.Required(), mcp.Description("The message to deliver to the task's agent")),
),
s.wrapHandler("message_task_kandev", s.messageTaskHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("get_task_conversation_kandev",
mcp.WithDescription("Get conversation history for a task. If session_id is omitted, the primary session is used."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID")),
mcp.WithString("session_id", mcp.Description("Optional session ID (must belong to task_id)")),
mcp.WithNumber("limit", mcp.Description("Optional page size (defaults to backend setting, max backend-capped)")),
mcp.WithString("before", mcp.Description("Optional cursor message ID to fetch messages before this ID")),
mcp.WithString("after", mcp.Description("Optional cursor message ID to fetch messages after this ID")),
mcp.WithString("sort", mcp.Description("Optional sort order: asc or desc")),
mcp.WithArray("message_types", mcp.Description("Optional message type filters (e.g. message, tool_call, error)"), mcp.Items(map[string]any{"type": "string"})),
),
s.wrapHandler("get_task_conversation_kandev", s.getTaskConversationHandler()),
)
}
// registerCreateTaskTool registers the create_task_kandev tool. Shared between
// kanban (task) mode and external mode. The tool description and parent_id
// guidance differ by mode: in external mode there is no current task, so the
// 'self' shorthand is omitted.
func (s *Server) registerCreateTaskTool() {
toolDesc := `Create a new task or subtask and auto-start an agent on it.
WHEN TO USE parent_id='self':
- Breaking down your current task into phases/steps → use parent_id='self'
- Creating tasks from a plan → use parent_id='self' (inherits repo, workspace, workflow)
- Delegating work to another agent → use parent_id='self'
- Delegating work that lives in a sibling repo → use parent_id='self' AND pass repository_url / repository_id / local_path to point the subtask at that repo
WHEN TO OMIT parent_id (top-level task):
- Creating an unrelated, standalone task
- Provide a repository via repository_url, repository_id, or local_path
- workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous
IMPORTANT:
- Subtasks inherit workspace, workflow, agent profile, and executor from the parent
- Subtasks inherit the parent's repository unless you supply repository_url, repository_id, or local_path — in which case the subtask targets that repo instead (must live in the parent's workspace)
- base_branch behaviour:
- Same repo as parent (no repo args): subtask inherits the parent's base_branch (sibling branches off the same starting point — useful for PR stacks)
- Different repo (you passed repository_url / repository_id / local_path): subtask defaults to that repo's default_branch
- Pass base_branch explicitly to override either default. Use list_repositories_kandev to see each repo's default_branch.
- Top-level tasks need a repository via repository_url, repository_id, or local_path
- 'description' is the sub-agent's initial prompt — be specific and detailed
- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later, or creating a tracking task with no immediate work). When in doubt, leave it true.
- Kanban subtasks cannot have their own subtasks (max nesting depth is 1). To break work down further, create a sibling under the same parent. (Office task trees are exempt.)`
parentDesc := "Parent task ID for subtasks. Use 'self' to create a subtask of your current task (RECOMMENDED for plan phases, delegated work). Omit only for unrelated top-level tasks."
if s.mode == ModeExternal {
toolDesc = `Create a new top-level task and auto-start an agent on it.
IMPORTANT:
- Provide a repository via repository_url, repository_id, or local_path
- workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous
- 'description' is the agent's initial prompt — be specific and detailed
- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later). When in doubt, leave it true.
- Use parent_id only when delegating to a known existing task by its ID`
parentDesc = "Optional parent task ID. Omit for top-level tasks; provide an existing task ID only to create a subtask of that task."
}
s.mcpServer.AddTool(
mcp.NewTool("create_task_kandev",
mcp.WithDescription(toolDesc),
mcp.WithString("parent_id", mcp.Description(parentDesc)),
mcp.WithString("workspace_id", mcp.Description("The workspace ID. Auto-resolved if only one workspace exists. Inherited from parent for subtasks.")),
mcp.WithString("workflow_id", mcp.Description("The workflow ID. Auto-resolved if the workspace has only one workflow. Inherited from parent for subtasks.")),
mcp.WithString("workflow_step_id", mcp.Description("The workflow step ID (optional, auto-resolved if omitted)")),
mcp.WithString("title", mcp.Required(), mcp.Description("The task title")),
mcp.WithString("description", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. REQUIRED for subtasks: without a description the sub-agent starts with no context and cannot do useful work. Be specific and detailed.")),
mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")),
mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")),
mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true — leave it true unless you specifically want a placeholder task with no agent running. Setting false leaves the task waiting for the user to click 'Start agent' in the UI; the description is preserved but no work happens automatically.")),
mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("base_branch", mcp.Description("Base branch for the repository (e.g. 'main'). Optional. Defaults: same-repo subtasks inherit the parent's base_branch; cross-repo subtasks and top-level tasks fall back to the repository's default_branch (visible via list_repositories_kandev).")),
),
s.wrapHandler("create_task_kandev", s.createTaskHandler()),
)
}
// registerAddBranchToTaskTool registers add_branch_to_task_kandev. Scoped to
// task mode only — external coding agents have no live session context to
// attach the new worktree to, and shipping this tool through the shared
// create-task path would silently widen the external surface.
func (s *Server) registerAddBranchToTaskTool() {
s.mcpServer.AddTool(
mcp.NewTool("add_branch_to_task_kandev",
mcp.WithDescription(`Attach an additional (repository, branch) worktree to an existing task.
Use this when the task should open more than one PR — same repo with different branches, or a second repository entirely. The new branch gets its own worktree under the task directory and behaves like any other multi-repo entry for changes, PRs, and review surfaces.
IMPORTANT:
- Only works on tasks running the WORKTREE executor. Tasks on docker / sprites / local-pc / SSH / remote_docker reject this tool because sibling worktrees are a git-worktree-specific layout — other executors bind one workspace path per task and the new branch would silently never appear on disk.
- task_id defaults to your CURRENT task when omitted — pass it explicitly only to target a different task.
- Repository selection (matches create_task_kandev): pass exactly one of repository_id / repository_url / local_path. For single-repo tasks all three are optional — the service auto-resolves to the task's only repository. Multi-repo tasks must identify the target repo explicitly.
- checkout_branch is the branch the new worktree will check out. Leave empty to create a fresh feature branch from base_branch.
- base_branch is optional; defaults to the repository's default_branch.
- The (task_id, repository_id, base_branch, checkout_branch) tuple must be unique on the task — re-adding the same combination is an error, not a no-op.`),
mcp.WithString("task_id", mcp.Description("The task to attach the branch to. Defaults to the current task when omitted.")),
mcp.WithString("repository_id", mcp.Description("Repository UUID. Optional for single-repo tasks (auto-resolved). Required for multi-repo tasks unless repository_url or local_path is supplied.")),
mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). Alternative to repository_id when you don't have the UUID handy. The repository is found-or-created in the task's workspace.")),
mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Alternative to repository_id for the local worktree flow. The repository is found-or-created in the task's workspace.")),
mcp.WithString("checkout_branch", mcp.Description("Existing branch to check out in the new worktree (e.g. a PR head branch). Empty to create a fresh feature branch from base_branch.")),
mcp.WithString("base_branch", mcp.Description("Branch to base the worktree on. Defaults to the repository's default_branch.")),
),
s.wrapHandler("add_branch_to_task_kandev", s.addBranchToTaskHandler()),
)
}
func (s *Server) addBranchToTaskHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
taskID := req.GetString(mcpKeyTaskID, "")
if taskID == "" {
taskID = s.taskID
}
if taskID == "" {
return mcp.NewToolResultError("task_id is required (no current task context to default to)"), nil
}
// Mutual-exclusion gate at the MCP tier so the error names the
// agent-facing alias (repository_url) instead of the WS wire field
// (github_url). The WS handler still re-validates for direct WS
// callers that don't go through this tool.
repositoryID := req.GetString(mcpKeyRepositoryID, "")
repositoryURL := req.GetString(mcpKeyRepositoryURL, "")
localPath := req.GetString(mcpKeyLocalPath, "")
if locatorCount(repositoryID, repositoryURL, localPath) > 1 {
return mcp.NewToolResultError("pass at most one of repository_id, repository_url, local_path"), nil
}
// repository_url is the tool-facing alias used by create_task_kandev;
// translate to github_url on the wire so the WS handler can reuse the
// same field name as the rest of the multi-repo payloads.
payload := map[string]interface{}{
mcpKeyTaskID: taskID,
mcpKeyRepositoryID: repositoryID,
mcpKeyLocalPath: localPath,
mcpKeyGitHubURL: repositoryURL,
mcpKeyCheckoutBranch: req.GetString(mcpKeyCheckoutBranch, ""),
mcpKeyBaseBranch: req.GetString(mcpKeyBaseBranch, ""),
}
var result map[string]interface{}
if err := s.backend.RequestPayload(ctx, ws.ActionMCPAddBranchToTask, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) registerInteractionTools() {
s.mcpServer.AddTool(
mcp.NewTool("ask_user_question_kandev",
mcp.WithDescription(`Ask the user one or more clarifying questions in a single tool call.
Use this tool when you need user input to proceed. Bundle related questions
together in one call so the user answers them all in one back-and-forth instead
of sequential round-trips. Each question is rendered as its own card; the user
selects an option or provides a custom text response per question, and the
agent receives a map keyed by question id once every question has been answered.
IMPORTANT:
- Provide 1 to 4 questions per call.
- Each question must have 2 to 6 concrete, actionable options.
- Each option must have a short "label" (1-5 words) and a "description"
explaining what selecting it means. NEVER use meta-text like "Answer below".
- Only call this tool when you genuinely need information you cannot infer.
Example usage:
{
"questions": [
{
"id": "db",
"prompt": "Which database should I use for this project?",
"options": [
{"label": "PostgreSQL", "description": "Relational, good for complex queries"},
{"label": "MongoDB", "description": "Document database, flexible schema"},
{"label": "SQLite", "description": "Embedded, simple setup"}
]
},
{
"id": "migration",
"prompt": "How should I handle the existing user data during migration?",
"options": [
{"label": "Migrate all", "description": "Keep all existing records"},
{"label": "Archive old", "description": "Archive records older than 1 year"},
{"label": "Fresh start", "description": "Delete existing data and start fresh"}
]
}
],
"context": "Backend redesign — picking the persistence layer and migration policy together."
}
The response is a JSON object keyed by each question id. Each entry may include
"selected_option" (the option_id the user picked), "custom_text" (the user's
free-form answer; can co-exist with a selected option), or "answered": false
when the user did not respond to that question. When the user skipped the entire
bundle, the envelope also carries "rejected": true and an optional
"reject_reason". Example success response:
{
"db": {"selected_option": "q1_opt1"},
"migration": {"custom_text": "Migrate all but flag rows older than 2 years"}
}
Example rejection:
{
"rejected": true,
"reject_reason": "User skipped",
"db": {"answered": false, "rejected": true},
"migration": {"answered": false, "rejected": true}
}`),
mcp.WithArray(questionsArg, mcp.Required(),
mcp.Description(`Array of 1-4 question objects. Each question must have a "prompt" (the question text) and an "options" array (2-6 entries with label + description). Optional fields: "id" (stable identifier in the response map; auto-generated if omitted), "title" (≤12 chars short label).`),
mcp.MinItems(1),
mcp.MaxItems(4),
mcp.Items(buildQuestionSchemaItem()),
),
mcp.WithString("context", mcp.Description("Optional shared background information to help the user understand why you're asking these questions.")),
),
s.wrapHandler("ask_user_question_kandev", s.askUserQuestionHandler()),
)
}
func (s *Server) registerPlanTools() {
s.mcpServer.AddTool(
mcp.NewTool("create_task_plan_kandev",
mcp.WithDescription("Create or save a task plan. Use this to save your implementation plan for the current task."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to create a plan for")),
mcp.WithString("content", mcp.Required(), mcp.Description("The plan content in markdown format")),
mcp.WithString("title", mcp.Description("Optional title for the plan (default: 'Plan')")),
),
s.wrapHandler("create_task_plan_kandev", s.createTaskPlanHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("get_task_plan_kandev",
mcp.WithDescription("Get the current plan for a task. Use this to retrieve an existing plan, including any user edits."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to get the plan for")),
),
s.wrapHandler("get_task_plan_kandev", s.getTaskPlanHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("update_task_plan_kandev",
mcp.WithDescription("Update an existing task plan. Use this to modify the plan during implementation."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to update the plan for")),
mcp.WithString("content", mcp.Required(), mcp.Description("The updated plan content in markdown format")),
mcp.WithString("title", mcp.Description("Optional new title for the plan")),
),
s.wrapHandler("update_task_plan_kandev", s.updateTaskPlanHandler()),
)
s.mcpServer.AddTool(
mcp.NewTool("delete_task_plan_kandev",
mcp.WithDescription("Delete a task plan."),
mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to delete the plan for")),
),
s.wrapHandler("delete_task_plan_kandev", s.deleteTaskPlanHandler()),
)
}
// buildQuestionSchemaItem describes the shape of a single question object in
// the ask_user_question_kandev tool schema. Hoisted out of registerInteractionTools
// to keep the registration body short and to deduplicate the JSON-schema
// keyword strings (linter goconst rules).
func buildQuestionSchemaItem() map[string]any {
const typeKey = "type"
const propsKey = "properties"
const reqKey = "required"
const objType = "object"
const stringType = "string"
str := func(desc string) map[string]any {
return map[string]any{typeKey: stringType, descriptionArg: desc}
}
return map[string]any{
typeKey: objType,
propsKey: map[string]any{
idArg: str("Stable identifier used as the key in the response map. Auto-assigned (q1, q2, ...) if omitted."),
titleArg: str("Optional short label (≤12 chars) shown above the prompt."),
promptArg: str("The question text shown to the user."),
optionsArg: map[string]any{
typeKey: "array",
descriptionArg: "2-6 concrete, actionable choices.",
"minItems": 2,
"maxItems": 6,
"items": map[string]any{
typeKey: objType,
propsKey: map[string]any{
labelArg: str("Short text (1-5 words) shown as the clickable option."),
descriptionArg: str("Brief explanation of what this option means."),
},
reqKey: []string{labelArg, descriptionArg},
},
},
},
reqKey: []string{promptArg, optionsArg},
}
}