feat: migrate MCP server from backend to agentctl (#124)
This commit is contained in:
+7
-14
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user