feat: migrate MCP server from backend to agentctl (#124)

This commit is contained in:
João Salavisa
2026-01-30 15:15:19 +00:00
committed by GitHub
parent 0f449dd01f
commit f229c72393
32 changed files with 1401 additions and 1035 deletions
+7 -14
View File
@@ -14,34 +14,28 @@ BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS += -X main.Version=$(VERSION) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME)
.PHONY: all build build-all build-agentctl build-mcp-server clean run dev test lint fmt vet help
.PHONY: all build build-all build-agentctl clean run dev test lint fmt vet help
## Default target
all: build
## Build the unified kandev binary
build: build-agentctl build-mcp-server
build: build-agentctl
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/kandev
## Build all binaries (unified + individual services + agentctl + mcp-server)
build-all: build build-agentctl build-mcp-server
## Build all binaries (unified + individual services + agentctl)
build-all: build build-agentctl
@echo "Building individual services..."
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agent-manager ./cmd/agent-manager
## Build the agentctl sidecar binary
## Build the agentctl sidecar binary (includes embedded MCP server)
build-agentctl:
@echo "Building agentctl..."
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agentctl ./cmd/agentctl
## Build the standalone MCP server binary
build-mcp-server:
@echo "Building mcp-server..."
@mkdir -p $(BUILD_DIR)
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/mcp-server ./cmd/mcp-server
## Clean build artifacts
clean:
@echo "Cleaning..."
@@ -112,9 +106,8 @@ help:
@echo ""
@echo "Targets:"
@echo " build Build the unified kandev binary (default)"
@echo " build-all Build all binaries (unified + individual services + agentctl + mcp-server)"
@echo " build-agentctl Build the agentctl sidecar binary"
@echo " build-mcp-server Build the standalone MCP server binary"
@echo " build-all Build all binaries (unified + individual services + agentctl)"
@echo " build-agentctl Build the agentctl sidecar binary (includes embedded MCP server)"
@echo " clean Remove build artifacts"
@echo " run Build and run the application"
@echo " dev Run in development mode with a local database"
+20 -1
View File
@@ -20,7 +20,9 @@ import (
"github.com/kandev/kandev/internal/agentctl/server/api"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/instance"
mcpserver "github.com/kandev/kandev/internal/agentctl/server/mcp"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/agentctl/server/wsclient"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/agent"
"go.uber.org/zap"
@@ -82,7 +84,24 @@ func run(cfg *config.Config, log *logger.Logger) {
// Set the server factory to create API servers for each instance
instMgr.SetServerFactory(func(instCfg *config.InstanceConfig, procMgr *process.Manager, instLog *logger.Logger) http.Handler {
return api.NewServer(instCfg, procMgr, instLog).Router()
var mcpSrv *mcpserver.Server
// Create MCP server if backend WS URL is configured
if instCfg.BackendWsURL != "" {
wsClient := wsclient.New(instCfg.BackendWsURL, instLog)
if err := wsClient.Connect(context.Background()); err != nil {
instLog.Error("failed to connect MCP WS client to backend",
zap.Error(err),
zap.String("backend_ws_url", instCfg.BackendWsURL))
} else {
mcpSrv = mcpserver.New(wsClient, instCfg.SessionID, instLog)
instLog.Info("MCP server enabled",
zap.String("backend_ws_url", instCfg.BackendWsURL),
zap.String("session_id", instCfg.SessionID))
}
}
return api.NewServer(instCfg, procMgr, mcpSrv, instLog).Router()
})
// Create control server
+8
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"fmt"
"os"
"go.uber.org/zap"
@@ -91,6 +92,13 @@ func provideLifecycleManager(
lifecycle.RuntimeFallbackWarn,
log,
)
// Set backend WS URL for MCP tunneling
// agentctl instances will connect back to this URL to forward MCP tool calls
backendWsURL := fmt.Sprintf("ws://%s:%d/ws", cfg.Server.Host, cfg.Server.Port)
lifecycleMgr.SetBackendWsURL(backendWsURL)
log.Info("Backend WS URL configured for MCP tunneling", zap.String("url", backendWsURL))
if err := lifecycleMgr.Start(ctx); err != nil {
return nil, nil, err
}
+19 -21
View File
@@ -42,6 +42,9 @@ import (
"github.com/kandev/kandev/internal/clarification"
// MCP handlers
mcphandlers "github.com/kandev/kandev/internal/mcp/handlers"
// Task Service packages
taskcontroller "github.com/kandev/kandev/internal/task/controller"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
@@ -218,30 +221,11 @@ func main() {
}()
}
// ============================================
// MCP SERVER (embedded)
// ============================================
mcpServerURL, mcpCleanup, err := provideMcpServer(ctx, cfg, log)
if err != nil {
log.Fatal("Failed to start MCP server", zap.Error(err))
}
if mcpCleanup != nil {
cleanups = append(cleanups, mcpCleanup)
log.Info("MCP server started", zap.String("url", mcpServerURL))
// Auto-set the MCP server URL if not explicitly configured
if cfg.Agent.McpServerURL == "" {
cfg.Agent.McpServerURL = mcpServerURL
}
// Set MCP server URL on controller and ensure default MCP config for all profiles
agentSettingsController.SetMcpServerURL(mcpServerURL)
if err := agentSettingsController.EnsureDefaultMcpConfig(ctx); err != nil {
log.Warn("Failed to ensure default MCP config", zap.Error(err))
}
}
// ============================================
// AGENT MANAGER
// ============================================
// Note: MCP server is now embedded in agentctl (co-located with agents)
// and tunnels tool calls back to the backend via WebSocket.
lifecycleMgr, agentRegistry, err := provideLifecycleManager(ctx, cfg, log, eventBus, dockerClient, agentSettingsRepo)
if err != nil {
log.Fatal("Failed to initialize agent manager", zap.Error(err))
@@ -563,6 +547,20 @@ func main() {
clarification.RegisterRoutes(router, clarificationStore, gateway.Hub, msgCreator, taskRepo, log)
log.Debug("Registered Clarification handlers (HTTP)")
// MCP handlers for agentctl WS tunnel (agentctl -> backend)
mcpHandlers := mcphandlers.NewHandlers(
workspaceController,
boardController,
taskController,
workflowCtrl,
clarificationStore,
msgCreator,
taskRepo,
log,
)
mcpHandlers.RegisterHandlers(gateway.Dispatcher)
log.Debug("Registered MCP handlers (WebSocket)")
// Health check (simple HTTP for load balancers/monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
-31
View File
@@ -1,31 +0,0 @@
package main
import (
"context"
"fmt"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/mcpserver"
)
// provideMcpServer starts the embedded MCP server if enabled.
// Returns the SSE endpoint URL and a cleanup function.
func provideMcpServer(ctx context.Context, cfg *config.Config, log *logger.Logger) (string, func() error, error) {
if !cfg.Agent.McpServerEnabled {
return "", nil, nil
}
mcpCfg := mcpserver.Config{
Port: cfg.Agent.McpServerPort,
KandevURL: fmt.Sprintf("http://localhost:%d", cfg.Server.Port),
}
srv, cleanup, err := mcpserver.Provide(ctx, mcpCfg, log)
if err != nil {
return "", nil, fmt.Errorf("failed to start MCP server: %w", err)
}
return srv.SSEEndpoint(), cleanup, nil
}
-120
View File
@@ -1,120 +0,0 @@
// Package main is the entry point for the standalone MCP server binary.
// mcp-server provides a Model Context Protocol server that exposes Kandev
// task management tools to MCP-compatible clients (Claude Desktop, Cursor, Codex, etc.)
//
// The server supports two transports:
// - SSE (Server-Sent Events) at /sse for Claude Desktop, Cursor
// - Streamable HTTP at /mcp for Codex
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/mcpserver"
"go.uber.org/zap"
)
// Command-line flags
var (
portFlag = flag.Int("port", 9090, "MCP server port")
kandevURLFlag = flag.String("kandev-url", "http://localhost:8080", "Kandev API URL")
logLevelFlag = flag.String("log-level", "info", "Log level (debug, info, warn, error)")
logFormatFlag = flag.String("log-format", "console", "Log format (console, json)")
)
func main() {
flag.Parse()
// Build configuration from flags and environment
cfg := mcpserver.Config{
Port: getEnvIntOrFlag("MCP_PORT", *portFlag),
KandevURL: getEnvOrFlag("KANDEV_API_URL", *kandevURLFlag),
}
// Initialize logger
log, err := logger.NewLogger(logger.LoggingConfig{
Level: getEnvOrFlag("MCP_LOG_LEVEL", *logLevelFlag),
Format: getEnvOrFlag("MCP_LOG_FORMAT", *logFormatFlag),
OutputPath: "stdout",
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer func() { _ = log.Sync() }()
log.Info("starting mcp-server",
zap.Int("port", cfg.Port),
zap.String("kandev_url", cfg.KandevURL))
run(cfg, log)
}
// run starts the MCP server and waits for shutdown.
func run(cfg mcpserver.Config, log *logger.Logger) {
ctx := context.Background()
srv, cleanup, err := mcpserver.Provide(ctx, cfg, log)
if err != nil {
log.Error("failed to start MCP server", zap.Error(err))
os.Exit(1)
}
log.Info("MCP server started",
zap.String("sse_endpoint", srv.SSEEndpoint()),
zap.String("streamable_http_endpoint", srv.StreamableHTTPEndpoint()))
fmt.Printf("Kandev MCP server running on :%d\n", cfg.Port)
fmt.Printf("Kandev API URL: %s\n", cfg.KandevURL)
fmt.Printf("SSE endpoint: %s (for Claude Desktop, Cursor)\n", srv.SSEEndpoint())
fmt.Printf("Streamable HTTP endpoint: %s (for Codex)\n", srv.StreamableHTTPEndpoint())
// Wait for shutdown signal
waitForShutdown(log, func(ctx context.Context) {
if err := cleanup(); err != nil {
log.Error("error during shutdown", zap.Error(err))
}
})
}
// waitForShutdown waits for shutdown signal and calls cleanup
func waitForShutdown(log *logger.Logger, cleanup func(ctx context.Context)) {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down mcp-server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cleanup(ctx)
log.Info("mcp-server stopped")
}
// getEnvOrFlag returns the environment variable value if set, otherwise the flag value.
func getEnvOrFlag(envKey, flagValue string) string {
if v := os.Getenv(envKey); v != "" {
return v
}
return flagValue
}
// getEnvIntOrFlag returns the environment variable value as int if set, otherwise the flag value.
func getEnvIntOrFlag(envKey string, flagValue int) int {
if v := os.Getenv(envKey); v != "" {
var i int
if _, err := fmt.Sscanf(v, "%d", &i); err == nil {
return i
}
}
return flagValue
}
@@ -30,6 +30,7 @@ type ContainerConfig struct {
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
BackendWsURL string // WebSocket URL to Kandev backend for MCP tunneling
}
// ContainerManager handles Docker container lifecycle operations
@@ -109,6 +110,8 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
Env: config.Credentials,
AutoStart: false,
McpServers: mcpServers,
BackendWsURL: config.BackendWsURL,
SessionID: config.SessionID,
}
resp, err := ctl.CreateInstance(ctx, createReq)
@@ -72,6 +72,10 @@ type Manager struct {
// Workspace info provider for on-demand instance creation
workspaceInfoProvider WorkspaceInfoProvider
// backendWsURL is the WebSocket URL to the Kandev backend for MCP tunneling
// This is passed to agentctl instances so they can forward MCP tool calls
backendWsURL string
// Background cleanup
cleanupInterval time.Duration
stopCh chan struct{}
@@ -170,6 +174,15 @@ func (m *Manager) SetWorktreeManager(worktreeMgr *worktree.Manager) {
m.worktreeMgr = worktreeMgr
}
// SetBackendWsURL sets the WebSocket URL to the Kandev backend for MCP tunneling.
//
// This URL is passed to agentctl instances so they can establish a WebSocket connection
// back to the backend and forward MCP tool calls through the tunnel.
// Format: ws://host:port/ws (e.g., ws://localhost:8080/ws)
func (m *Manager) SetBackendWsURL(url string) {
m.backendWsURL = url
}
// SetWorkspaceInfoProvider sets the provider for workspace information.
//
// The WorkspaceInfoProvider interface allows the lifecycle manager to dynamically create
@@ -441,6 +454,8 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
AgentProfileID: info.AgentProfileID,
WorkspacePath: info.WorkspacePath,
Protocol: string(agentConfig.Protocol),
AgentConfig: agentConfig,
BackendWsURL: m.backendWsURL,
}
runtimeInstance, err := rt.CreateInstance(ctx, req)
@@ -768,6 +783,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
Metadata: metadata,
AgentConfig: agentConfig,
McpServers: mcpServers,
BackendWsURL: m.backendWsURL,
}
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
@@ -67,6 +67,7 @@ type RuntimeCreateRequest struct {
Metadata map[string]interface{}
McpServers []McpServerConfig
AgentConfig *registry.AgentTypeConfig // Agent type info needed by runtimes
BackendWsURL string // WebSocket URL to Kandev backend for MCP tunneling
}
// RuntimeInstance represents an agentctl instance created by a runtime.
@@ -70,6 +70,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe
MainRepoGitDir: mainRepoGitDir,
Credentials: req.Env, // Env contains credentials from the caller
McpServers: req.McpServers,
BackendWsURL: req.BackendWsURL,
}
// Use ContainerManager to launch container
@@ -103,6 +103,8 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea
Env: env,
AutoStart: false,
McpServers: mcpServers,
BackendWsURL: req.BackendWsURL,
SessionID: req.SessionID,
}
resp, err := r.ctl.CreateInstance(ctx, createReq)
@@ -16,6 +16,12 @@ func Resolve(config *ProfileConfig, policy Policy) ([]ResolvedServer, []string,
resolved := make([]ResolvedServer, 0, len(config.Servers))
for name, server := range config.Servers {
// Skip kandev server - it's automatically injected by agentctl with the correct local URL.
// This handles existing profiles that still have the old kandev config in the database.
if name == "kandev" {
continue
}
if !policyAllowsServerName(policy, name) {
warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: server not allowed by policy", name))
continue
@@ -37,7 +37,6 @@ type Controller struct {
mcpService *mcpconfig.Service
modelFetcher *modelfetcher.Fetcher
logger *logger.Logger
mcpServerURL string // URL of the embedded Kandev MCP server
}
type SessionChecker interface {
@@ -57,20 +56,11 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry,
}
}
// SetMcpServerURL sets the URL of the embedded Kandev MCP server.
// This should be called after the MCP server is started.
func (c *Controller) SetMcpServerURL(url string) {
c.mcpServerURL = url
}
// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have the
// Kandev MCP server configured and enabled by default.
// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have
// MCP enabled by default. The kandev MCP server is automatically injected
// by agentctl at session creation time, so profiles don't need to explicitly
// configure it.
func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
if c.mcpServerURL == "" {
c.logger.Debug("MCP server URL not set, skipping default MCP config")
return nil
}
agents, err := c.repo.ListAgents(ctx)
if err != nil {
return err
@@ -98,16 +88,13 @@ func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
continue
}
// Create default MCP config with Kandev server enabled
// Create default MCP config with MCP enabled but no servers configured.
// The kandev MCP server is automatically injected by agentctl when
// creating a new session. Users can add additional external MCP servers.
config := &models.AgentProfileMcpConfig{
ProfileID: profile.ID,
Enabled: true,
Servers: map[string]interface{}{
"kandev": map[string]interface{}{
"type": "sse",
"url": c.mcpServerURL,
},
},
Servers: map[string]interface{}{},
Meta: map[string]interface{}{},
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
@@ -123,8 +110,7 @@ func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
c.logger.Info("created default MCP config for profile",
zap.String("profile_id", profile.ID),
zap.String("profile_name", profile.Name),
zap.String("mcp_server_url", c.mcpServerURL))
zap.String("profile_name", profile.Name))
}
}
@@ -42,6 +42,8 @@ type CreateInstanceRequest struct {
Env map[string]string `json:"env,omitempty"`
AutoStart bool `json:"auto_start,omitempty"`
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
BackendWsURL string `json:"backend_ws_url,omitempty"` // WebSocket URL to Kandev backend for MCP tunneling
SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls
}
// CreateInstanceResponse contains the result of creating a new agent instance.
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
@@ -114,7 +115,31 @@ func (s *Server) handleAgentNewSession(c *gin.Context) {
return
}
sessionID, err := adapter.NewSession(ctx, req.McpServers)
// If MCP server is enabled, prepend the local kandev MCP server to the list.
// This replaces the old external MCP server URL (http://localhost:9090/sse) with
// the local agentctl MCP server (http://localhost:{port}/sse).
mcpServers := req.McpServers
if s.mcpServer != nil {
localKandevMcp := types.McpServer{
Name: "kandev",
Type: "sse",
URL: fmt.Sprintf("http://localhost:%d/sse", s.cfg.Port),
}
// Filter out any existing kandev server from the list (to avoid duplicates)
filtered := make([]types.McpServer, 0, len(mcpServers)+1)
filtered = append(filtered, localKandevMcp)
for _, srv := range mcpServers {
if srv.Name != "kandev" {
filtered = append(filtered, srv)
}
}
mcpServers = filtered
s.logger.Debug("injected local kandev MCP server",
zap.String("url", localKandevMcp.URL),
zap.Int("total_servers", len(mcpServers)))
}
sessionID, err := adapter.NewSession(ctx, mcpServers)
if err != nil {
s.logger.Error("new session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, NewSessionResponse{
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/mcp"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/httpmw"
"github.com/kandev/kandev/internal/common/logger"
@@ -16,23 +17,26 @@ import (
// Server is the HTTP API server for a single agent instance.
type Server struct {
cfg *config.InstanceConfig
procMgr *process.Manager
logger *logger.Logger
router *gin.Engine
cfg *config.InstanceConfig
procMgr *process.Manager
mcpServer *mcp.Server
logger *logger.Logger
router *gin.Engine
upgrader websocket.Upgrader
}
// NewServer creates a new API server for an agent instance.
func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, log *logger.Logger) *Server {
// If mcpServer is provided, MCP routes will be registered.
func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *mcp.Server, log *logger.Logger) *Server {
gin.SetMode(gin.ReleaseMode)
s := &Server{
cfg: cfg,
procMgr: procMgr,
logger: log.WithFields(zap.String("component", "api-server")),
router: gin.New(),
cfg: cfg,
procMgr: procMgr,
mcpServer: mcpServer,
logger: log.WithFields(zap.String("component", "api-server")),
router: gin.New(),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for container-local communication
@@ -107,6 +111,11 @@ func (s *Server) setupRoutes() {
api.POST("/git/create-pr", s.handleGitCreatePR)
api.GET("/git/commit/:sha", s.handleGitShowCommit)
}
// MCP routes (if MCP server is configured)
if s.mcpServer != nil {
s.mcpServer.RegisterRoutes(s.router)
}
}
// Health check response
@@ -136,6 +136,13 @@ type InstanceConfig struct {
// ProcessBufferMaxBytes caps per-process output buffer size
ProcessBufferMaxBytes int64
// BackendWsURL is the WebSocket URL to connect back to the Kandev backend
// for MCP tool requests (e.g., ws://localhost:8080/ws)
BackendWsURL string
// SessionID is the session ID for this agent instance (used in MCP tool calls)
SessionID string
}
// Load loads the configuration from environment variables.
@@ -203,6 +210,12 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst
if len(overrides.McpServers) > 0 {
cfg.McpServers = overrides.McpServers
}
if overrides.BackendWsURL != "" {
cfg.BackendWsURL = overrides.BackendWsURL
}
if overrides.SessionID != "" {
cfg.SessionID = overrides.SessionID
}
}
// Parse agent command into args
@@ -226,6 +239,8 @@ type InstanceOverrides struct {
ApprovalPolicy string
AgentType string
McpServers []McpServerConfig
BackendWsURL string
SessionID string
}
// ParseCommand splits a command string into arguments
@@ -76,6 +76,13 @@ type CreateRequest struct {
// McpServers is a list of MCP servers to configure for the agent.
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
// BackendWsURL is the WebSocket URL to the Kandev backend for MCP tunneling.
// If set, the MCP server will be enabled and forward tool calls to the backend.
BackendWsURL string `json:"backend_ws_url,omitempty"`
// SessionID is the task session ID for MCP tool calls (used by ask_user_question).
SessionID string `json:"session_id,omitempty"`
}
// CreateResponse contains the result of creating a new agent instance.
@@ -132,6 +132,8 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
AutoStart: &autoStart,
Env: config.CollectAgentEnv(req.Env),
McpServers: mcpServers,
BackendWsURL: req.BackendWsURL,
SessionID: req.SessionID,
}
// Create instance config using the unified method
@@ -0,0 +1,221 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// MCP action constants for backend WS requests
const (
ActionMCPListWorkspaces = "mcp.list_workspaces"
ActionMCPListBoards = "mcp.list_boards"
ActionMCPListWorkflowSteps = "mcp.list_workflow_steps"
ActionMCPListTasks = "mcp.list_tasks"
ActionMCPCreateTask = "mcp.create_task"
ActionMCPUpdateTask = "mcp.update_task"
ActionMCPAskUserQuestion = "mcp.ask_user_question"
)
func (s *Server) listWorkspacesHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
var result []map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPListWorkspaces, nil, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) listBoardsHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
workspaceID, err := req.RequireString("workspace_id")
if err != nil {
return mcp.NewToolResultError("workspace_id is required"), nil
}
payload := map[string]string{"workspace_id": workspaceID}
var result []map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPListBoards, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) listWorkflowStepsHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError("board_id is required"), nil
}
payload := map[string]string{"board_id": boardID}
var result []map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPListWorkflowSteps, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) listTasksHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError("board_id is required"), nil
}
payload := map[string]string{"board_id": boardID}
var result []map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPListTasks, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) createTaskHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
workspaceID, err := req.RequireString("workspace_id")
if err != nil {
return mcp.NewToolResultError("workspace_id is required"), nil
}
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError("board_id is required"), nil
}
workflowStepID, err := req.RequireString("workflow_step_id")
if err != nil {
return mcp.NewToolResultError("workflow_step_id is required"), nil
}
title, err := req.RequireString("title")
if err != nil {
return mcp.NewToolResultError("title is required"), nil
}
description := req.GetString("description", "")
payload := map[string]string{
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": title,
"description": description,
}
var result map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPCreateTask, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) updateTaskHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
taskID, err := req.RequireString("task_id")
if err != nil {
return mcp.NewToolResultError("task_id is required"), nil
}
payload := map[string]interface{}{"task_id": taskID}
if title := req.GetString("title", ""); title != "" {
payload["title"] = title
}
if desc := req.GetString("description", ""); desc != "" {
payload["description"] = desc
}
if state := req.GetString("state", ""); state != "" {
payload["state"] = state
}
var result map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPUpdateTask, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
prompt, err := req.RequireString("prompt")
if err != nil {
return mcp.NewToolResultError("prompt is required"), nil
}
args := req.GetArguments()
optionsRaw, ok := args["options"]
if !ok {
return mcp.NewToolResultError("options is required"), nil
}
// Parse options - can be array of strings or array of objects
optionsJSON, err := json.Marshal(optionsRaw)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)), nil
}
// Try to parse as array of strings first (simple format from agents)
var stringOptions []string
if err := json.Unmarshal(optionsJSON, &stringOptions); err == nil {
// Convert string options to the expected format
formattedOptions := make([]map[string]interface{}, len(stringOptions))
for i, opt := range stringOptions {
formattedOptions[i] = map[string]interface{}{
"option_id": fmt.Sprintf("opt_%d", i+1),
"label": opt,
"description": opt,
}
}
optionsJSON, _ = json.Marshal(formattedOptions)
}
var options []map[string]interface{}
if err := json.Unmarshal(optionsJSON, &options); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to parse options: %v", err)), nil
}
questionCtx := req.GetString("context", "")
// Build the question object in the format expected by the backend
question := map[string]interface{}{
"id": "q1",
"title": "Question",
"prompt": prompt,
"options": options,
}
payload := map[string]interface{}{
"session_id": s.sessionID,
"question": question,
"context": questionCtx,
}
var result map[string]interface{}
if err := s.wsClient.RequestPayload(ctx, ActionMCPAskUserQuestion, payload, &result); err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Extract the answer from the response
if answer, ok := result["answer"]; ok {
if answerMap, ok := answer.(map[string]interface{}); ok {
if selectedOptions, ok := answerMap["selected_options"].([]interface{}); ok && len(selectedOptions) > 0 {
return mcp.NewToolResultText(fmt.Sprintf("User selected: %v", selectedOptions[0])), nil
}
if customText, ok := answerMap["custom_text"].(string); ok && customText != "" {
return mcp.NewToolResultText(fmt.Sprintf("User answered: %s", customText)), nil
}
}
}
if rejected, ok := result["rejected"].(bool); ok && rejected {
reason, _ := result["reject_reason"].(string)
return mcp.NewToolResultText(fmt.Sprintf("User rejected the question: %s", reason)), nil
}
data, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(data)), nil
}
}
@@ -0,0 +1,170 @@
// Package mcp provides MCP server functionality for agentctl.
// It exposes MCP tools that forward requests to the Kandev backend via WebSocket.
package mcp
import (
"context"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/wsclient"
"github.com/kandev/kandev/internal/common/logger"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
)
// Server wraps the MCP server with WebSocket client for backend communication.
type Server struct {
wsClient *wsclient.Client
sessionID string
mcpServer *server.MCPServer
sseServer *server.SSEServer
httpServer *server.StreamableHTTPServer
logger *logger.Logger
mu sync.Mutex
running bool
}
// New creates a new MCP server for agentctl.
func New(wsClient *wsclient.Client, sessionID string, log *logger.Logger) *Server {
s := &Server{
wsClient: wsClient,
sessionID: sessionID,
logger: log.WithFields(zap.String("component", "mcp-server")),
}
// Create MCP server
s.mcpServer = server.NewMCPServer(
"kandev-mcp",
"1.0.0",
server.WithToolCapabilities(true),
)
// Register tools
s.registerTools()
// Create SSE server for Claude Desktop, Cursor, etc.
s.sseServer = server.NewSSEServer(s.mcpServer)
// Create Streamable HTTP server for Codex
s.httpServer = server.NewStreamableHTTPServer(s.mcpServer,
server.WithEndpointPath("/mcp"),
)
return s
}
// RegisterRoutes adds MCP routes to the gin router.
func (s *Server) RegisterRoutes(router gin.IRouter) {
// SSE transport routes
router.GET("/sse", gin.WrapH(s.sseServer.SSEHandler()))
router.POST("/message", gin.WrapH(s.sseServer.MessageHandler()))
// Streamable HTTP transport route
router.Any("/mcp", gin.WrapH(s.httpServer))
s.logger.Info("registered MCP routes", zap.String("sse", "/sse"), 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))
}
}
return nil
}
// registerTools registers all MCP tools.
func (s *Server) registerTools() {
s.mcpServer.AddTool(
mcp.NewTool("list_workspaces",
mcp.WithDescription("List all workspaces. Use this first to get workspace IDs."),
),
s.listWorkspacesHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("list_boards",
mcp.WithDescription("List all boards in a workspace."),
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
),
s.listBoardsHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("list_workflow_steps",
mcp.WithDescription("List all workflow steps in a board."),
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
),
s.listWorkflowStepsHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("list_tasks",
mcp.WithDescription("List all tasks on a board."),
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
),
s.listTasksHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("create_task",
mcp.WithDescription("Create a new task on a board."),
mcp.WithString("workspace_id", mcp.Required(), mcp.Description("The workspace ID")),
mcp.WithString("board_id", mcp.Required(), mcp.Description("The board ID")),
mcp.WithString("workflow_step_id", mcp.Required(), mcp.Description("The workflow step ID")),
mcp.WithString("title", mcp.Required(), mcp.Description("The task title")),
mcp.WithString("description", mcp.Description("The task description")),
),
s.createTaskHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("update_task",
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.updateTaskHandler(),
)
s.mcpServer.AddTool(
mcp.NewTool("ask_user_question",
mcp.WithDescription("Ask the user a clarifying question."),
mcp.WithString("prompt", mcp.Required(), mcp.Description("The question to ask")),
mcp.WithArray("options", mcp.Required(), mcp.Description("Options for the user")),
mcp.WithString("context", mcp.Description("Context for the question")),
),
s.askUserQuestionHandler(),
)
s.logger.Info("registered MCP tools", zap.Int("count", 7))
}
// WrapHandler wraps a handler function with error handling for gin.
func WrapHandler(h http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {
h.ServeHTTP(c.Writer, c.Request)
}
}
@@ -0,0 +1,177 @@
// Package wsclient provides a WebSocket client for connecting back to the Kandev backend.
package wsclient
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type Client struct {
url string
conn *websocket.Conn
logger *logger.Logger
pending map[string]chan *ws.Message
pendingMu sync.RWMutex
connected bool
connMu sync.RWMutex
writeMu sync.Mutex
reconnectInterval time.Duration
maxReconnectTries int
}
func New(url string, log *logger.Logger) *Client {
return &Client{
url: url,
logger: log.WithFields(zap.String("component", "wsclient")),
pending: make(map[string]chan *ws.Message),
reconnectInterval: 5 * time.Second,
maxReconnectTries: 10,
}
}
func (c *Client) Connect(ctx context.Context) error {
c.connMu.Lock()
defer c.connMu.Unlock()
if c.connected {
return nil
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.url, nil)
if err != nil {
return fmt.Errorf("failed to connect to backend: %w", err)
}
c.conn = conn
c.connected = true
c.logger.Info("connected to backend WebSocket", zap.String("url", c.url))
go c.readLoop()
return nil
}
func (c *Client) Close() error {
c.connMu.Lock()
defer c.connMu.Unlock()
if !c.connected {
return nil
}
c.connected = false
if c.conn != nil {
return c.conn.Close()
}
return nil
}
func (c *Client) IsConnected() bool {
c.connMu.RLock()
defer c.connMu.RUnlock()
return c.connected
}
func (c *Client) Request(ctx context.Context, action string, payload interface{}) (*ws.Message, error) {
if !c.IsConnected() {
return nil, fmt.Errorf("not connected to backend")
}
id := uuid.New().String()
msg, err := ws.NewRequest(id, action, payload)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
respChan := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pending[id] = respChan
c.pendingMu.Unlock()
c.writeMu.Lock()
err = c.conn.WriteJSON(msg)
c.writeMu.Unlock()
if err != nil {
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, fmt.Errorf("failed to send request: %w", err)
}
c.logger.Debug("sent request", zap.String("action", action), zap.String("id", id))
select {
case resp := <-respChan:
return resp, nil
case <-ctx.Done():
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, ctx.Err()
}
}
func (c *Client) RequestPayload(ctx context.Context, action string, payload, result interface{}) error {
resp, err := c.Request(ctx, action, payload)
if err != nil {
return err
}
if resp.Type == ws.MessageTypeError {
var ep struct{ Code, Message string }
if json.Unmarshal(resp.Payload, &ep) == nil {
return fmt.Errorf("backend error [%s]: %s", ep.Code, ep.Message)
}
return fmt.Errorf("backend error: %s", string(resp.Payload))
}
if result != nil && len(resp.Payload) > 0 {
if err := json.Unmarshal(resp.Payload, result); err != nil {
return fmt.Errorf("failed to unmarshal response: %w", err)
}
}
return nil
}
func (c *Client) readLoop() {
for {
c.connMu.RLock()
conn, connected := c.conn, c.connected
c.connMu.RUnlock()
if !connected || conn == nil {
return
}
var msg ws.Message
if err := conn.ReadJSON(&msg); err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
c.logger.Error("read error", zap.Error(err))
}
c.handleDisconnect()
return
}
c.handleMessage(&msg)
}
}
func (c *Client) handleMessage(msg *ws.Message) {
if msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError {
c.pendingMu.Lock()
ch, ok := c.pending[msg.ID]
delete(c.pending, msg.ID)
c.pendingMu.Unlock()
if ok {
ch <- msg
}
return
}
}
func (c *Client) handleDisconnect() {
c.connMu.Lock()
c.connected = false
c.conn = nil
c.connMu.Unlock()
c.pendingMu.Lock()
for id, ch := range c.pending {
errMsg, _ := ws.NewError(id, "", ws.ErrorCodeInternalError, "connection lost", nil)
ch <- errMsg
delete(c.pending, id)
}
c.pendingMu.Unlock()
}
@@ -103,15 +103,17 @@ type AgentConfig struct {
// StandalonePort is the control port for standalone agentctl (default: 9999)
StandalonePort int `mapstructure:"standalonePort"`
// McpServerEnabled enables the embedded MCP server (default: true)
// McpServerEnabled enables the standalone MCP server (default: false)
// Note: MCP is now embedded in agentctl and tunnels to backend via WebSocket.
// This setting is only for running a separate standalone MCP server process.
McpServerEnabled bool `mapstructure:"mcpServerEnabled"`
// McpServerPort is the port for the embedded MCP server (default: 9090)
// McpServerPort is the port for the standalone MCP server (default: 9090)
McpServerPort int `mapstructure:"mcpServerPort"`
// McpServerURL is the URL of the Kandev MCP server for task management
// If set, agents with supports_mcp=true will be configured with this MCP server
// If McpServerEnabled is true and this is empty, it will be auto-set to http://localhost:{McpServerPort}/sse
// Note: With the new architecture, MCP is embedded in agentctl and this is typically not needed.
McpServerURL string `mapstructure:"mcpServerUrl"`
}
@@ -183,9 +185,9 @@ func setDefaults(v *viper.Viper) {
// Agent defaults (runtime selection is now per-task based on executor type)
v.SetDefault("agent.standaloneHost", "localhost")
v.SetDefault("agent.standalonePort", 9999)
v.SetDefault("agent.mcpServerEnabled", true)
v.SetDefault("agent.mcpServerEnabled", false) // MCP is now embedded in agentctl
v.SetDefault("agent.mcpServerPort", 9090)
v.SetDefault("agent.mcpServerUrl", "") // Empty means auto-set when mcpServerEnabled is true
v.SetDefault("agent.mcpServerUrl", "")
// Auth defaults
v.SetDefault("auth.jwtSecret", "")
@@ -0,0 +1,296 @@
// Package handlers provides WebSocket handlers for MCP tool requests.
// These handlers are called by agentctl via the WS tunnel and execute
// operations against the backend services directly.
package handlers
import (
"context"
"encoding/json"
"github.com/kandev/kandev/internal/clarification"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/controller"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/repository"
workflowctrl "github.com/kandev/kandev/internal/workflow/controller"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
// Action constants for MCP WebSocket messages
const (
ActionMCPListWorkspaces = "mcp.list_workspaces"
ActionMCPListBoards = "mcp.list_boards"
ActionMCPListWorkflowSteps = "mcp.list_workflow_steps"
ActionMCPListTasks = "mcp.list_tasks"
ActionMCPCreateTask = "mcp.create_task"
ActionMCPUpdateTask = "mcp.update_task"
ActionMCPAskUserQuestion = "mcp.ask_user_question"
)
// ClarificationService defines the interface for clarification operations.
type ClarificationService interface {
CreateRequest(req *clarification.Request) string
WaitForResponse(ctx context.Context, pendingID string) (*clarification.Response, error)
}
// MessageCreator creates messages for clarification requests.
type MessageCreator interface {
CreateClarificationRequestMessage(ctx context.Context, taskID, sessionID, pendingID string, question clarification.Question, clarificationContext string) (string, error)
}
// Handlers provides MCP WebSocket handlers.
type Handlers struct {
workspaceCtrl *controller.WorkspaceController
boardCtrl *controller.BoardController
taskCtrl *controller.TaskController
workflowCtrl *workflowctrl.Controller
clarificationSvc ClarificationService
messageCreator MessageCreator
repo repository.Repository
logger *logger.Logger
}
// NewHandlers creates new MCP handlers.
func NewHandlers(
workspaceCtrl *controller.WorkspaceController,
boardCtrl *controller.BoardController,
taskCtrl *controller.TaskController,
workflowCtrl *workflowctrl.Controller,
clarificationSvc ClarificationService,
messageCreator MessageCreator,
repo repository.Repository,
log *logger.Logger,
) *Handlers {
return &Handlers{
workspaceCtrl: workspaceCtrl,
boardCtrl: boardCtrl,
taskCtrl: taskCtrl,
workflowCtrl: workflowCtrl,
clarificationSvc: clarificationSvc,
messageCreator: messageCreator,
repo: repo,
logger: log.WithFields(zap.String("component", "mcp-handlers")),
}
}
// RegisterHandlers registers all MCP handlers with the dispatcher.
func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) {
d.RegisterFunc(ActionMCPListWorkspaces, h.handleListWorkspaces)
d.RegisterFunc(ActionMCPListBoards, h.handleListBoards)
d.RegisterFunc(ActionMCPListWorkflowSteps, h.handleListWorkflowSteps)
d.RegisterFunc(ActionMCPListTasks, h.handleListTasks)
d.RegisterFunc(ActionMCPCreateTask, h.handleCreateTask)
d.RegisterFunc(ActionMCPUpdateTask, h.handleUpdateTask)
d.RegisterFunc(ActionMCPAskUserQuestion, h.handleAskUserQuestion)
h.logger.Info("registered MCP handlers", zap.Int("count", 7))
}
// handleListWorkspaces lists all workspaces.
func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.workspaceCtrl.ListWorkspaces(ctx, dto.ListWorkspacesRequest{})
if err != nil {
h.logger.Error("failed to list workspaces", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workspaces", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleListBoards lists boards for a workspace.
func (h *Handlers) handleListBoards(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
WorkspaceID string `json:"workspace_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
resp, err := h.boardCtrl.ListBoards(ctx, dto.ListBoardsRequest{WorkspaceID: req.WorkspaceID})
if err != nil {
h.logger.Error("failed to list boards", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list boards", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleListWorkflowSteps lists workflow steps for a board.
func (h *Handlers) handleListWorkflowSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
BoardID string `json:"board_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.workflowCtrl.ListStepsByBoard(ctx, workflowctrl.ListStepsRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list workflow steps", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflow steps", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleListTasks lists tasks for a board.
func (h *Handlers) handleListTasks(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
BoardID string `json:"board_id"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.taskCtrl.ListTasks(ctx, dto.ListTasksRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list tasks", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list tasks", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleCreateTask creates a new task.
func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req dto.CreateTaskRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.WorkspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workspace_id is required", nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.WorkflowStepID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
}
resp, err := h.taskCtrl.CreateTask(ctx, req)
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleUpdateTask updates an existing task.
func (h *Handlers) handleUpdateTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req dto.UpdateTaskRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.taskCtrl.UpdateTask(ctx, req)
if err != nil {
h.logger.Error("failed to update task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// handleAskUserQuestion creates a clarification request and waits for response.
func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
Question clarification.Question `json:"question"`
Context string `json:"context"`
}
if err := json.Unmarshal(msg.Payload, &req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
if req.Question.Prompt == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "question.prompt is required", nil)
}
// Generate question ID if missing
if req.Question.ID == "" {
req.Question.ID = "q1"
}
// Generate option IDs if missing
for i := range req.Question.Options {
if req.Question.Options[i].ID == "" {
req.Question.Options[i].ID = generateOptionID(0, i)
}
}
// Look up task ID from session if not provided
taskID := req.TaskID
if taskID == "" && h.repo != nil {
session, err := h.repo.GetTaskSession(ctx, req.SessionID)
if err != nil {
h.logger.Warn("failed to look up session for task ID",
zap.String("session_id", req.SessionID),
zap.Error(err))
} else {
taskID = session.TaskID
}
}
// Create the clarification request
clarificationReq := &clarification.Request{
SessionID: req.SessionID,
TaskID: taskID,
Question: req.Question,
Context: req.Context,
}
pendingID := h.clarificationSvc.CreateRequest(clarificationReq)
// Create the message in the database (triggers WS event to frontend)
if h.messageCreator != nil {
_, err := h.messageCreator.CreateClarificationRequestMessage(
ctx,
taskID,
req.SessionID,
pendingID,
req.Question,
req.Context,
)
if err != nil {
h.logger.Error("failed to create clarification request message",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.Error(err))
}
}
// Wait for user response
resp, err := h.clarificationSvc.WaitForResponse(ctx, pendingID)
if err != nil {
h.logger.Error("failed waiting for clarification response",
zap.String("pending_id", pendingID),
zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get user response: "+err.Error(), nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// generateOptionID generates an option ID for a question.
func generateOptionID(questionIndex, optionIndex int) string {
return "q" + string(rune('1'+questionIndex)) + "_opt" + string(rune('1'+optionIndex))
}
@@ -1,50 +0,0 @@
// Package mcpserver provides the MCP server for Kandev task management.
package mcpserver
import (
"context"
"sync"
"time"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// DefaultConfig returns the default configuration.
func DefaultConfig() Config {
return Config{
Port: 9090,
KandevURL: "http://localhost:8080",
}
}
// NewWithLogger creates a new MCP server with the given configuration and logger.
// This is useful for integration with dependency injection frameworks.
func NewWithLogger(cfg Config, log *logger.Logger) *Server {
srv := New(cfg)
srv.logger = log.WithFields(zap.String("component", "mcp-server"))
return srv
}
// Provide starts the MCP server and returns a cleanup function to stop it.
// This is useful for integration with dependency injection frameworks.
func Provide(ctx context.Context, cfg Config, log *logger.Logger) (*Server, func() error, error) {
srv := NewWithLogger(cfg, log)
if err := srv.Start(ctx); err != nil {
return nil, nil, err
}
var stopOnce sync.Once
cleanup := func() error {
var stopErr error
stopOnce.Do(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stopErr = srv.Stop(stopCtx)
})
return stopErr
}
return srv, cleanup, nil
}
-179
View File
@@ -1,179 +0,0 @@
// Package mcpserver provides a reusable MCP server for Kandev task management.
// It exposes tools for listing, creating, and updating tasks via SSE and Streamable HTTP transports.
package mcpserver
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"github.com/kandev/kandev/internal/common/logger"
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
)
// Config holds the MCP server configuration.
type Config struct {
Port int // Port to listen on
KandevURL string // Kandev API URL (e.g., http://localhost:8080)
}
// Server wraps the SSE and Streamable HTTP servers with lifecycle management.
// It supports both transports for compatibility with different MCP clients:
// - SSE transport (/sse) for Claude Desktop, Cursor, etc.
// - Streamable HTTP transport (/mcp) for Codex
type Server struct {
cfg Config
sseServer *server.SSEServer
streamableHTTPServer *server.StreamableHTTPServer
httpServer *http.Server
mu sync.Mutex
running bool
logger *logger.Logger
}
// New creates a new MCP server with the given configuration.
func New(cfg Config) *Server {
return &Server{
cfg: cfg,
logger: logger.Default().WithFields(),
}
}
// Start starts the MCP server in a goroutine and returns when it's listening.
// It starts both SSE and Streamable HTTP transports on the same port.
// It returns an error if the server fails to start.
func (s *Server) Start(ctx context.Context) error {
s.mu.Lock()
if s.running {
s.mu.Unlock()
return fmt.Errorf("server already running")
}
s.mu.Unlock()
// Create MCP server (shared between both transports)
mcpServer := server.NewMCPServer(
"kandev-mcp",
"1.0.0",
server.WithToolCapabilities(true),
)
// Register tools (using internal logger)
registerTools(mcpServer, s.cfg, s.logger)
// Create SSE server (for Claude Desktop, Cursor, etc.)
s.sseServer = server.NewSSEServer(mcpServer)
// Create Streamable HTTP server (for Codex)
// Use WithEndpointPath to ensure it handles /mcp path
s.streamableHTTPServer = server.NewStreamableHTTPServer(mcpServer,
server.WithEndpointPath("/mcp"),
)
// Create HTTP mux to route requests to appropriate transport
mux := http.NewServeMux()
// SSE transport routes - handle /sse and /message paths
mux.Handle("/sse", s.sseServer.SSEHandler())
mux.Handle("/message", s.sseServer.MessageHandler())
// Streamable HTTP transport route - handle /mcp path
mux.Handle("/mcp", s.streamableHTTPServer)
// Verify the port is available by creating a test listener
addr := fmt.Sprintf(":%d", s.cfg.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
if tcpAddr, ok := listener.Addr().(*net.TCPAddr); ok {
s.cfg.Port = tcpAddr.Port
}
// Create HTTP server with the mux
s.httpServer = &http.Server{
Handler: mux,
}
// Ready channel to signal when server goroutine has started
ready := make(chan struct{})
// Start server in a goroutine
go func() {
s.mu.Lock()
s.running = true
s.mu.Unlock()
// Signal that we're starting
close(ready)
s.logger.Info("MCP server listening",
zap.Int("port", s.cfg.Port),
zap.String("sse_endpoint", "/sse"),
zap.String("streamable_http_endpoint", "/mcp"))
if err := s.httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
s.logger.Error("MCP server error", zap.Error(err))
}
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
// Wait for the goroutine to start or context cancellation
select {
case <-ready:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Stop gracefully shuts down the server.
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if !running {
return nil
}
// Shutdown the HTTP server (this stops both transports)
if s.httpServer != nil {
if err := s.httpServer.Shutdown(ctx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %w", err)
}
}
// Also shutdown the SSE server to clean up any active sessions
if s.sseServer != nil {
if err := s.sseServer.Shutdown(ctx); err != nil {
s.logger.Warn("failed to shutdown SSE server", zap.Error(err))
}
}
// Shutdown the Streamable HTTP server to clean up any active sessions
if s.streamableHTTPServer != nil {
if err := s.streamableHTTPServer.Shutdown(ctx); err != nil {
s.logger.Warn("failed to shutdown Streamable HTTP server", zap.Error(err))
}
}
return nil
}
// SSEEndpoint returns the full SSE URL for clients that use SSE transport
// (e.g., Claude Desktop, Cursor).
func (s *Server) SSEEndpoint() string {
return fmt.Sprintf("http://localhost:%d/sse", s.cfg.Port)
}
// StreamableHTTPEndpoint returns the full Streamable HTTP URL for clients that use
// streamable HTTP transport (e.g., Codex).
func (s *Server) StreamableHTTPEndpoint() string {
return fmt.Sprintf("http://localhost:%d/mcp", s.cfg.Port)
}
-533
View File
@@ -1,533 +0,0 @@
package mcpserver
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/kandev/kandev/internal/common/logger"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
)
func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
// List Workspaces tool
s.AddTool(
mcp.NewTool("list_workspaces",
mcp.WithDescription("List all workspaces. Use this first to get workspace IDs for other operations."),
),
listWorkspacesHandler(cfg, log),
)
// List Boards tool
s.AddTool(
mcp.NewTool("list_boards",
mcp.WithDescription("List all boards in a workspace. Use this to get board IDs for task operations."),
mcp.WithString("workspace_id",
mcp.Required(),
mcp.Description("The workspace ID to list boards from"),
),
),
listBoardsHandler(cfg, log),
)
// List Workflow Steps tool
s.AddTool(
mcp.NewTool("list_workflow_steps",
mcp.WithDescription("List all workflow steps in a board. Use this to get workflow step IDs for creating tasks."),
mcp.WithString("board_id",
mcp.Required(),
mcp.Description("The board ID to list workflow steps from"),
),
),
listWorkflowStepsHandler(cfg, log),
)
// List Tasks tool
s.AddTool(
mcp.NewTool("list_tasks",
mcp.WithDescription("List all tasks on a board"),
mcp.WithString("board_id",
mcp.Required(),
mcp.Description("The board ID to list tasks from"),
),
),
listTasksHandler(cfg, log),
)
// Create Task tool
s.AddTool(
mcp.NewTool("create_task",
mcp.WithDescription("Create a new task on a board"),
mcp.WithString("workspace_id",
mcp.Required(),
mcp.Description("The workspace ID"),
),
mcp.WithString("board_id",
mcp.Required(),
mcp.Description("The board ID"),
),
mcp.WithString("workflow_step_id",
mcp.Required(),
mcp.Description("The workflow step ID to place the task in"),
),
mcp.WithString("title",
mcp.Required(),
mcp.Description("The task title"),
),
mcp.WithString("description",
mcp.Description("The task description (optional)"),
),
),
createTaskHandler(cfg, log),
)
// Update Task tool
s.AddTool(
mcp.NewTool("update_task",
mcp.WithDescription("Update an existing task"),
mcp.WithString("task_id",
mcp.Required(),
mcp.Description("The task ID to update"),
),
mcp.WithString("title",
mcp.Description("New title (optional)"),
),
mcp.WithString("description",
mcp.Description("New description (optional)"),
),
mcp.WithString("state",
mcp.Description("New state: not_started, in_progress, paused, completed, cancelled (optional)"),
),
),
updateTaskHandler(cfg, log),
)
// Ask User Question tool
s.AddTool(
mcp.NewTool("ask_user_question",
mcp.WithDescription(
"Ask the user a clarifying question when you need more information to proceed. "+
"Use this tool when you need to:\n"+
"1. Gather user preferences or requirements\n"+
"2. Clarify ambiguous instructions\n"+
"3. Get decisions on implementation choices\n"+
"4. Offer choices about what direction to take\n\n"+
"The question includes 2-4 multiple-choice options. Users can also provide custom text input.\n"+
"Returns the user's answer.",
),
mcp.WithString("session_id",
mcp.Required(),
mcp.Description("The session ID for this clarification request"),
),
mcp.WithString("prompt",
mcp.Required(),
mcp.Description("The question to ask the user"),
),
mcp.WithArray("options",
mcp.Required(),
mcp.Description("2-4 options for the user to choose from. Each option should have: label (concise 1-5 words) and description (explanation of the option)"),
),
mcp.WithString("context",
mcp.Description("Optional context explaining why this question is being asked"),
),
),
askUserQuestionHandler(cfg, log),
)
log.Info("registered MCP tools", zap.Int("count", 7))
}
func listWorkspacesHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
url := fmt.Sprintf("%s/api/v1/workspaces", cfg.KandevURL)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch workspaces", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch workspaces: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func listBoardsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
workspaceID, err := req.RequireString("workspace_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
url := fmt.Sprintf("%s/api/v1/workspaces/%s/boards", cfg.KandevURL, workspaceID)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch boards", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch boards: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func listWorkflowStepsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
url := fmt.Sprintf("%s/api/v1/boards/%s/workflow/steps", cfg.KandevURL, boardID)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch workflow steps", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch workflow steps: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func listTasksHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
url := fmt.Sprintf("%s/api/v1/boards/%s/tasks", cfg.KandevURL, boardID)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch tasks", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch tasks: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func createTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
workspaceID, err := req.RequireString("workspace_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
workflowStepID, err := req.RequireString("workflow_step_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
title, err := req.RequireString("title")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
payload := map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": title,
}
if desc := req.GetString("description", ""); desc != "" {
payload["description"] = desc
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s/api/v1/tasks", cfg.KandevURL)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
log.Error("failed to create task", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to create task: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
if resp.StatusCode >= 400 {
return mcp.NewToolResultError(fmt.Sprintf("API error (%d): %s", resp.StatusCode, string(result))), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func updateTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
taskID, err := req.RequireString("task_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
payload := make(map[string]interface{})
if title := req.GetString("title", ""); title != "" {
payload["title"] = title
}
if desc := req.GetString("description", ""); desc != "" {
payload["description"] = desc
}
if state := req.GetString("state", ""); state != "" {
payload["state"] = state
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s/api/v1/tasks/%s", cfg.KandevURL, taskID)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(body))
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
log.Error("failed to update task", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to update task: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
var result json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
if resp.StatusCode >= 400 {
return mcp.NewToolResultError(fmt.Sprintf("API error (%d): %s", resp.StatusCode, string(result))), nil
}
formatted, _ := json.MarshalIndent(result, "", " ")
return mcp.NewToolResultText(string(formatted)), nil
}
}
func askUserQuestionHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
sessionID, err := req.RequireString("session_id")
if err != nil || sessionID == "" {
return mcp.NewToolResultError("session_id is required - this tool can only be used within a Kandev task session"), nil
}
prompt, err := req.RequireString("prompt")
if err != nil {
return mcp.NewToolResultError("prompt is required"), nil
}
args := req.GetArguments()
optionsRaw, ok := args["options"]
if !ok {
return mcp.NewToolResultError("options is required"), nil
}
// Parse options from the raw interface
optionsJSON, err := json.Marshal(optionsRaw)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse options: %v", err)), nil
}
var options []map[string]interface{}
if err := json.Unmarshal(optionsJSON, &options); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse options: %v", err)), nil
}
if len(options) < 2 || len(options) > 4 {
return mcp.NewToolResultError("Must provide 2-4 options"), nil
}
// Generate option IDs to ensure consistency between request and response
// The backend also generates these, but we need them here for matching
for i := range options {
options[i]["option_id"] = fmt.Sprintf("q1_opt%d", i+1)
}
context := req.GetString("context", "")
// Build the single question
question := map[string]interface{}{
"id": "q1",
"title": "Question",
"prompt": prompt,
"options": options,
}
// Create the clarification request (backend still expects questions array for now)
payload := map[string]interface{}{
"session_id": sessionID,
"question": question,
"context": context,
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s/api/v1/clarification/request", cfg.KandevURL)
log.Debug("creating clarification request",
zap.String("session_id", sessionID))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to create request: %v", err)), nil
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
log.Error("failed to create clarification request", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to create clarification request: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
var errBody json.RawMessage
_ = json.NewDecoder(resp.Body).Decode(&errBody)
return mcp.NewToolResultError(fmt.Sprintf("API error (%d): %s", resp.StatusCode, string(errBody))), nil
}
var createResp struct {
PendingID string `json:"pending_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse response: %v", err)), nil
}
// Now wait for the user's response
waitURL := fmt.Sprintf("%s/api/v1/clarification/%s/wait", cfg.KandevURL, createResp.PendingID)
log.Debug("waiting for user response",
zap.String("pending_id", createResp.PendingID))
waitReq, err := http.NewRequestWithContext(ctx, http.MethodGet, waitURL, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to create wait request: %v", err)), nil
}
waitResp, err := http.DefaultClient.Do(waitReq)
if err != nil {
log.Error("failed to wait for response", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to wait for response: %v", err)), nil
}
defer func() { _ = waitResp.Body.Close() }()
if waitResp.StatusCode >= 400 {
var errBody json.RawMessage
_ = json.NewDecoder(waitResp.Body).Decode(&errBody)
return mcp.NewToolResultError(fmt.Sprintf("Wait error (%d): %s", waitResp.StatusCode, string(errBody))), nil
}
var userResponse struct {
PendingID string `json:"pending_id"`
Answer struct {
SelectedOptions []string `json:"selected_options"`
CustomText string `json:"custom_text"`
} `json:"answer"`
Rejected bool `json:"rejected"`
RejectReason string `json:"reject_reason"`
}
if err := json.NewDecoder(waitResp.Body).Decode(&userResponse); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to parse user response: %v", err)), nil
}
// Format the response for the agent
result := formatClarificationResponse(prompt, options, userResponse.Answer, userResponse.Rejected, userResponse.RejectReason)
log.Debug("clarification response received",
zap.String("pending_id", createResp.PendingID),
zap.Bool("rejected", userResponse.Rejected))
return mcp.NewToolResultText(result), nil
}
}
func formatClarificationResponse(prompt string, options []map[string]interface{}, answer struct {
SelectedOptions []string `json:"selected_options"`
CustomText string `json:"custom_text"`
}, rejected bool, rejectReason string) string {
if rejected {
if rejectReason != "" {
return fmt.Sprintf("The user declined to answer and said: %s\n\nProceed with your best judgment based on the available information.", rejectReason)
}
return "The user declined to answer this question. Proceed with your best judgment based on the available information."
}
var result strings.Builder
result.WriteString("# User Response\n\n")
result.WriteString(fmt.Sprintf("**Question asked:** %s\n\n", prompt))
hasAnswer := false
if len(answer.SelectedOptions) > 0 {
// Find the label and description for the selected option
for _, opt := range options {
optID, _ := opt["option_id"].(string)
if optID == "" {
optID, _ = opt["id"].(string)
}
for _, selected := range answer.SelectedOptions {
if optID == selected {
label, _ := opt["label"].(string)
description, _ := opt["description"].(string)
result.WriteString(fmt.Sprintf("**User's choice:** %s\n", label))
if description != "" {
result.WriteString(fmt.Sprintf("**Choice description:** %s\n", description))
}
hasAnswer = true
}
}
}
}
if answer.CustomText != "" {
result.WriteString(fmt.Sprintf("\n**Additional user input:** %s\n", answer.CustomText))
hasAnswer = true
}
if !hasAnswer {
result.WriteString("The user did not provide an answer.\n")
}
result.WriteString("\nProceed with the task based on the user's response above.")
return result.String()
}
+9
View File
@@ -198,6 +198,15 @@ const (
ActionUserGet = "user.get"
ActionUserSettingsUpdate = "user.settings.update"
ActionUserSettingsUpdated = "user.settings.updated"
// MCP tool actions (agentctl -> backend via WS tunnel)
ActionMCPListWorkspaces = "mcp.list_workspaces"
ActionMCPListBoards = "mcp.list_boards"
ActionMCPListWorkflowSteps = "mcp.list_workflow_steps"
ActionMCPListTasks = "mcp.list_tasks"
ActionMCPCreateTask = "mcp.create_task"
ActionMCPUpdateTask = "mcp.update_task"
ActionMCPAskUserQuestion = "mcp.ask_user_question"
)
// Error codes
@@ -6,7 +6,6 @@ import { Switch } from '@kandev/ui/switch';
import { Textarea } from '@kandev/ui/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
import { getBackendConfig } from '@/lib/config';
import { useProfileMcpConfig } from './use-profile-mcp-config';
import type { AgentProfileMcpConfig } from '@/lib/types/http';
@@ -64,14 +63,11 @@ export function ProfileMcpConfigCard({
const currentServers = isDraft ? draftState?.servers ?? '' : mcpServers;
const currentError = isDraft ? draftState?.error ?? null : mcpError;
const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
const { mcpServerUrl } = getBackendConfig();
const kandevTools = 'Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task';
// Note: kandev MCP server is automatically injected by agentctl at session creation time.
// Users only need to configure external MCP servers here.
const popularServers: Record<string, Record<string, unknown>> = {
kandev: {
type: 'sse',
url: mcpServerUrl,
},
playwright: {
type: 'stdio',
command: 'npx',
@@ -204,22 +200,24 @@ export function ProfileMcpConfigCard({
/>
<p className="text-xs text-muted-foreground">
MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.</p>
<p className="text-xs font-medium text-muted-foreground">Popular servers</p>
<div className="flex flex-wrap gap-2">
<p className="text-xs font-medium text-muted-foreground">Built-in</p>
<div className="flex flex-wrap gap-2 mb-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => applyPopularServer('kandev')}
<span
className="text-xs rounded-full border border-primary/50 bg-primary/10 px-2 py-1 text-primary"
>
+ Kandev MCP
</button>
✓ Kandev MCP
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[320px] text-xs">
{kandevTools}
<p className="font-medium mb-1">Automatically available</p>
<p>{kandevTools}</p>
</TooltipContent>
</Tooltip>
</div>
<p className="text-xs font-medium text-muted-foreground">Popular servers</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
@@ -148,7 +148,7 @@ export function ChatInputContainer({
{/* Clarification inline (replaces input area when pending) */}
{hasPendingClarification ? (
<div style={{ height: '88px', overflow: 'auto' }}>
<div style={{ overflow: 'auto' }}>
<ClarificationInputOverlay
message={pendingClarification}
onResolved={onClarificationResolved}
@@ -2,7 +2,6 @@
import { useCallback, useState } from 'react';
import { IconX, IconMessageQuestion } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { cn } from '@/lib/utils';
import { getBackendConfig } from '@/lib/config';
import type { Message, ClarificationRequestMetadata, ClarificationAnswer } from '@/lib/types/http';
@@ -119,37 +118,25 @@ export function ClarificationInputOverlay({ message, onResolved }: Clarification
{/* Options with bullets - indented to align with question text */}
<div className="space-y-0.5 mb-1.5 ml-6">
{question.options.map((option) => {
const button = (
<button
key={option.option_id}
type="button"
onClick={() => handleSubmitOption(option.option_id)}
disabled={isSubmitting}
className={cn(
'flex items-start gap-2 w-full text-left text-xs rounded px-1.5 py-0.5 -ml-1.5 transition-colors',
'hover:bg-blue-500/15 hover:text-blue-600 dark:hover:text-blue-400',
isSubmitting ? 'opacity-50 cursor-not-allowed' : 'text-foreground/80'
)}
>
<span className="text-muted-foreground flex-shrink-0">•</span>
<span>{option.label}</span>
</button>
);
if (option.description) {
return (
<Tooltip key={option.option_id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
{option.description}
</TooltipContent>
</Tooltip>
);
}
return button;
})}
{question.options.map((option) => (
<button
key={option.option_id}
type="button"
onClick={() => handleSubmitOption(option.option_id)}
disabled={isSubmitting}
className={cn(
'flex items-start gap-2 w-full text-left text-xs rounded px-1.5 py-0.5 -ml-1.5 transition-colors',
'hover:bg-blue-500/15 hover:text-blue-600 dark:hover:text-blue-400',
isSubmitting ? 'opacity-50 cursor-not-allowed' : 'text-foreground/80'
)}
>
<span className="text-muted-foreground flex-shrink-0">•</span>
<span>{option.label}</span>
{option.description && (
<span className="text-muted-foreground/60">— {option.description}</span>
)}
</button>
))}
</div>
{/* Custom input row - indented to align with options */}
+326
View File
@@ -0,0 +1,326 @@
# MCP Server Migration: Backend → agentctl
This document describes the architectural change to move the MCP server from the Kandev backend to agentctl, enabling remote agents to access MCP tools.
## Problem Statement
Currently, the MCP server runs embedded in the Kandev backend on the user's machine. When agents run remotely (Docker containers, VPS, Kubernetes), they cannot access the MCP server because it's not reachable from their network location.
## Solution
Move the MCP server into agentctl, which always runs co-located with the agent. API calls from MCP tools are tunneled back to the Kandev backend through the existing WebSocket connection.
---
## Current Architecture (MCP Server with Backend)
```mermaid
flowchart TB
subgraph UserMachine["👤 USER'S MACHINE"]
subgraph Backend["KANDEV BACKEND"]
Orchestrator["Orchestrator"]
Lifecycle["Lifecycle Manager"]
Runtime["Runtime<br/>(Docker/Standalone)"]
subgraph MCPServer["EMBEDDED MCP SERVER :9090"]
SSE["/sse endpoint"]
MCP["/mcp endpoint"]
Tools["Tools:<br/>list_workspaces<br/>list_tasks<br/>create_task<br/>update_task"]
end
API["REST API :8080<br/>/api/v1/tasks<br/>/api/v1/boards"]
Orchestrator --> Lifecycle
Lifecycle --> Runtime
MCPServer -->|"HTTP calls"| API
end
end
subgraph Remote["🖥️ REMOTE SERVER / CONTAINER"]
subgraph Agentctl["AGENTCTL"]
InstanceMgr["Instance Manager<br/>Ports: 10000+"]
Agent["Agent Process<br/>(Claude, Codex, etc)<br/>stdin/stdout ACP"]
InstanceMgr --> Agent
end
NoMCP["🚫 NO MCP ACCESS<br/>Port 9090 unreachable"]
Agent -.->|"❌ CANNOT CONNECT"| NoMCP
end
Runtime -->|"WS connection"| Agentctl
NoMCP -.->|"❌ Network barrier"| MCPServer
style NoMCP fill:#ffcccc,stroke:#cc0000
style MCPServer fill:#ffffcc,stroke:#cccc00
```
---
## Proposed Architecture (MCP Server in agentctl)
```mermaid
flowchart TB
subgraph UserMachine["👤 USER'S MACHINE"]
subgraph Backend["KANDEV BACKEND"]
Orchestrator["Orchestrator"]
Lifecycle["Lifecycle Manager"]
Runtime["Runtime<br/>(Docker/Standalone)"]
subgraph WSGateway["WS Gateway :8080"]
Dispatcher["Dispatcher"]
MCPHandlers["MCP Handlers"]
end
Services["Service Layer<br/>(Task, Board, Workspace)"]
Orchestrator --> Lifecycle
Lifecycle --> Runtime
Dispatcher --> MCPHandlers
MCPHandlers --> Services
end
end
subgraph Remote["🖥️ REMOTE SERVER / CONTAINER"]
subgraph Agentctl["AGENTCTL"]
InstanceMgr["Instance Manager<br/>Ports: 10000+"]
subgraph MCPServer["EMBEDDED MCP SERVER"]
SSE["/sse endpoint"]
MCP["/mcp endpoint"]
Tools["Tools:<br/>list_workspaces<br/>list_tasks<br/>create_task<br/>update_task"]
end
WSTunnel["WS Client<br/>(connects to backend)"]
Agent["Agent Process<br/>(Claude, Codex, etc)<br/>stdin/stdout ACP"]
InstanceMgr --> Agent
MCPServer -->|"forward requests"| WSTunnel
Agent -->|"✅ localhost"| MCPServer
end
end
Runtime <-->|"WS connection"| Agentctl
WSTunnel <-->|"mcp.* actions"| Dispatcher
style MCPServer fill:#ccffcc,stroke:#00cc00
style WSTunnel fill:#cce5ff,stroke:#0066cc
style MCPHandlers fill:#ccffcc,stroke:#00cc00
```
---
## Sequence Diagram: Agent Using MCP (Proposed)
```mermaid
sequenceDiagram
participant Agent as Agent Process
participant MCP as MCP Server<br/>(in agentctl)
participant Tunnel as WS Tunnel<br/>(in agentctl)
participant Dispatcher as WS Dispatcher<br/>(in Backend)
participant Handler as MCP Handler<br/>(in Backend)
participant Service as Service Layer<br/>(in Backend)
participant DB as Database
Note over Agent,DB: Agent wants to create a task
Agent->>MCP: POST /mcp<br/>tool: create_task
MCP->>MCP: Parse MCP request
MCP->>Tunnel: Forward request
Tunnel->>Dispatcher: WS message<br/>{action: "mcp.create_task", ...}
Dispatcher->>Handler: Dispatch to registered handler
Handler->>Service: taskService.Create(...)
Service->>DB: Insert task
DB-->>Service: Task created
Service-->>Handler: Task entity
Handler-->>Dispatcher: Response message
Dispatcher-->>Tunnel: WS message<br/>{action: "mcp.create_task", result: {...}}
Tunnel-->>MCP: Response
MCP-->>Agent: MCP tool result<br/>{task_id: "..."}
Note over Agent,DB: ✅ Uses existing WS Dispatcher pattern
```
---
## Component Comparison
```mermaid
flowchart LR
subgraph Current["❌ CURRENT"]
direction TB
B1["Backend"]
M1["MCP Server"]
A1["agentctl"]
AG1["Agent"]
B1 --- M1
B1 -.->|"WS"| A1
A1 --- AG1
AG1 -.->|"❌ unreachable"| M1
end
subgraph Proposed["✅ PROPOSED"]
direction TB
B2["Backend"]
A2["agentctl"]
M2["MCP Server"]
AG2["Agent"]
B2 <-->|"WS + Tunnel"| A2
A2 --- M2
A2 --- AG2
AG2 -->|"✅ localhost"| M2
end
Current -->|"Migration"| Proposed
style Current fill:#ffeeee
style Proposed fill:#eeffee
```
---
## Summary
| Aspect | Current | Proposed |
|--------|---------|----------|
| **MCP Location** | Backend (user's machine) | agentctl (co-located with agent) |
| **Remote Agent Access** | ❌ Cannot reach MCP | ✅ Always localhost |
| **Network Dependency** | Requires direct network path | Works over existing WS tunnel |
| **Deployment Flexibility** | Limited to local agents | Works anywhere (Docker, VPS, K8s) |
| **API Communication** | MCP → localhost API | MCP → WS Tunnel → Backend API |
---
## Implementation Notes
### Changes Required
**agentctl:**
1. Add embedded MCP server (adapt `internal/mcpserver` package)
2. Add WS client that connects back to Kandev backend
3. MCP tool handlers forward requests via WS client instead of HTTP
**Backend:**
1. Add MCP handlers package (`internal/mcp/handlers/`)
2. Register `mcp.*` actions with WS Dispatcher
3. Handlers call existing services (TaskService, BoardService, etc.)
4. Remove embedded MCP server from `cmd/kandev`
**Lifecycle Manager:**
1. Pass backend WS URL to agentctl during instance creation
2. agentctl connects back to backend on startup
### Backend Internal Architecture
The backend uses the existing **WS Dispatcher pattern** (not the event bus) for MCP requests. This is consistent with how other WS handlers work (tasks, messages, workflows, etc.):
```mermaid
flowchart TB
subgraph Backend["KANDEV BACKEND"]
subgraph Gateway["WS Gateway"]
Hub["Hub<br/>(client connections)"]
Dispatcher["Dispatcher<br/>(action routing)"]
end
subgraph Handlers["Registered Handlers"]
TaskH["Task Handlers<br/>task.list, task.create..."]
MsgH["Message Handlers<br/>message.add, message.list..."]
MCPH["MCP Handlers ⭐ NEW<br/>mcp.create_task, mcp.list_tasks..."]
end
subgraph Services["Service Layer"]
TaskSvc["TaskService"]
BoardSvc["BoardService"]
WorkspaceSvc["WorkspaceService"]
end
Hub --> Dispatcher
Dispatcher --> TaskH
Dispatcher --> MsgH
Dispatcher --> MCPH
TaskH --> TaskSvc
MsgH --> TaskSvc
MCPH --> TaskSvc
MCPH --> BoardSvc
MCPH --> WorkspaceSvc
end
style MCPH fill:#ccffcc,stroke:#00cc00
```
**Why WS Dispatcher instead of Event Bus?**
| Aspect | WS Dispatcher | Event Bus |
|--------|---------------|-----------|
| **Pattern** | Request/Response | Pub/Sub (fire-and-forget) |
| **Use case** | Synchronous operations | Async notifications |
| **Existing usage** | All WS handlers (tasks, messages, etc.) | Agent lifecycle events, notifications |
| **MCP fit** | ✅ MCP tools need responses | ❌ Would need request-reply wrapper |
### WS Message Format
MCP requests use the standard Kandev WS message format with `mcp.*` action prefix:
```json
{
"id": "req-123",
"action": "mcp.create_task",
"payload": {
"workspace_id": "ws-1",
"board_id": "board-1",
"title": "New task"
},
"timestamp": "2024-01-15T10:30:00Z"
}
```
Response:
```json
{
"id": "req-123",
"action": "mcp.create_task",
"payload": {
"task_id": "task-456",
"title": "New task"
},
"timestamp": "2024-01-15T10:30:01Z"
}
```
Error response:
```json
{
"id": "req-123",
"action": "mcp.create_task",
"error": {
"code": "NOT_FOUND",
"message": "Board not found"
},
"timestamp": "2024-01-15T10:30:01Z"
}
```
### MCP Configuration for Agents
When configuring agents that support MCP, agentctl will provide the local MCP endpoint:
```json
{
"mcpServers": {
"kandev": {
"url": "http://localhost:10001/mcp"
}
}
}
```
Where `10001` is the instance's allocated port.