feat(mcp): add embedded MCP server with dual transport support (#67)

This commit is contained in:
João Salavisa
2026-01-21 12:03:46 +00:00
committed by GitHub
parent fa8364307f
commit c57a82f81d
33 changed files with 1549 additions and 71 deletions
+3
View File
@@ -0,0 +1,3 @@
# Hoist @lezer/highlight for codemirror theme packages that expect it as a peer dependency
public-hoist-pattern[]=@lezer/*
+14 -7
View File
@@ -14,19 +14,19 @@ 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 clean run dev test lint fmt vet help
.PHONY: all build build-all build-agentctl build-mcp-server clean run dev test lint fmt vet help
## Default target
all: build
## Build the unified kandev binary
build: build-agentctl
build: build-agentctl build-mcp-server
@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)
build-all: build build-agentctl
## Build all binaries (unified + individual services + agentctl + mcp-server)
build-all: build build-agentctl build-mcp-server
@echo "Building individual services..."
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/agent-manager ./cmd/agent-manager
@@ -36,6 +36,12 @@ build-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..."
@@ -105,9 +111,10 @@ help:
@echo " make [target]"
@echo ""
@echo "Targets:"
@echo " build Build the unified kandev binary (default)"
@echo " build-all Build all binaries (unified + individual services + agentctl)"
@echo " build-agentctl Build the agentctl sidecar binary"
@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 " clean Remove build artifacts"
@echo " run Build and run the application"
@echo " dev Run in development mode with a local database"
+17 -1
View File
@@ -218,6 +218,22 @@ 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
}
}
// ============================================
// AGENT MANAGER
// ============================================
@@ -255,7 +271,7 @@ func main() {
// WEBSOCKET GATEWAY (All communication via WebSocket)
// ============================================
log.Info("Initializing WebSocket Gateway...")
gateway, notificationSvc, _, err := provideGateway(
gateway, _, _, err := provideGateway(
ctx,
log,
eventBus,
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"context"
"fmt"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/mcpserver"
)
// provideMcpServer starts the embedded MCP server if enabled.
// Returns the SSE endpoint URL and a cleanup function.
func provideMcpServer(ctx context.Context, cfg *config.Config, log *logger.Logger) (string, func() error, error) {
if !cfg.Agent.McpServerEnabled {
return "", nil, nil
}
mcpCfg := mcpserver.Config{
Port: cfg.Agent.McpServerPort,
KandevURL: fmt.Sprintf("http://localhost:%d", cfg.Server.Port),
}
srv, cleanup, err := mcpserver.Provide(ctx, mcpCfg, log)
if err != nil {
return "", nil, fmt.Errorf("failed to start MCP server: %w", err)
}
return srv.SSEEndpoint(), cleanup, nil
}
+120
View File
@@ -0,0 +1,120 @@
// 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
}
+8 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-sqlite3 v1.14.33
github.com/nats-io/nats.go v1.31.0
github.com/spf13/viper v1.16.0
@@ -21,6 +22,8 @@ require (
require (
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
@@ -41,11 +44,13 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -62,12 +67,14 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
+19 -4
View File
@@ -42,6 +42,10 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@@ -89,8 +93,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@@ -186,6 +190,9 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -208,6 +215,10 @@ github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
@@ -255,8 +266,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@@ -284,6 +295,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -28,6 +28,7 @@ type ContainerConfig struct {
ProfileInfo *AgentProfileInfo
InstanceID string
MainRepoGitDir string // Path to main repo's .git directory (for worktrees)
McpServers []McpServerConfig
}
// ContainerManager handles Docker container lifecycle operations
@@ -87,6 +88,18 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
return "", nil, fmt.Errorf("agentctl health check failed: %w", err)
}
// Convert MCP server configs
var mcpServers []agentctl.McpServerConfig
for _, mcp := range config.McpServers {
mcpServers = append(mcpServers, agentctl.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
})
}
// Create an instance via the control API (same flow as standalone mode)
createReq := &agentctl.CreateInstanceRequest{
ID: config.InstanceID,
@@ -94,6 +107,7 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe
AgentCommand: "", // Agent command set via Configure endpoint later
Env: config.Credentials,
AutoStart: false,
McpServers: mcpServers,
}
resp, err := ctl.CreateInstance(ctx, createReq)
@@ -441,6 +441,25 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
// Build environment variables
env := m.buildEnvForRuntime(executionID, &reqWithWorktree, agentConfig)
// Resolve MCP servers early so they're available for protocols that need them at startup (e.g., Codex)
acpMcpServers, err := m.resolveMcpServersWithParams(ctx, reqWithWorktree.AgentProfileID, reqWithWorktree.Metadata, agentConfig)
if err != nil {
m.logger.Warn("failed to resolve MCP servers for launch", zap.Error(err))
// Continue without MCP servers - not a fatal error
}
// Convert ACP MCP servers to runtime config format
var mcpServers []McpServerConfig
for _, srv := range acpMcpServers {
mcpServers = append(mcpServers, McpServerConfig{
Name: srv.Name,
URL: srv.URL,
Type: srv.Type,
Command: srv.Command,
Args: srv.Args,
})
}
// Create runtime request (agent command not included - started explicitly later)
runtimeReq := &RuntimeCreateRequest{
InstanceID: executionID,
@@ -455,6 +474,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
WorktreeBranch: worktreeBranch,
MainRepoGitDir: mainRepoGitDir,
AgentConfig: agentConfig,
McpServers: mcpServers,
}
runtimeInstance, err := m.runtime.CreateInstance(ctx, runtimeReq)
@@ -717,14 +737,20 @@ func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*regist
// - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection,
// - converts to ACP stdio server definitions used during session initialization.
func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig) ([]agentctltypes.McpServer, error) {
if execution == nil {
return nil, nil
}
return m.resolveMcpServersWithParams(ctx, execution.AgentProfileID, execution.Metadata, agentConfig)
}
// resolveMcpServersWithParams resolves MCP servers with explicit parameters.
// This is used by Launch() before the execution object exists.
func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig *registry.AgentTypeConfig) ([]agentctltypes.McpServer, error) {
if m.mcpProvider == nil || agentConfig == nil {
return nil, nil
}
profileID := ""
if execution != nil {
profileID = strings.TrimSpace(execution.AgentProfileID)
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return nil, nil
}
@@ -745,11 +771,11 @@ func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecuti
policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(m.runtime))
executorID := ""
if execution != nil && execution.Metadata != nil {
if value, ok := execution.Metadata["executor_id"].(string); ok {
if metadata != nil {
if value, ok := metadata["executor_id"].(string); ok {
executorID = value
}
if value, ok := execution.Metadata["executor_mcp_policy"]; ok {
if value, ok := metadata["executor_mcp_policy"]; ok {
updated, policyWarnings, err := mcpconfig.ApplyExecutorPolicy(policy, value)
if err != nil {
return nil, fmt.Errorf("invalid executor MCP policy: %w", err)
@@ -34,6 +34,15 @@ type Runtime interface {
RecoverInstances(ctx context.Context) ([]*RuntimeInstance, error)
}
// McpServerConfig holds configuration for an MCP server.
type McpServerConfig struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
}
// RuntimeCreateRequest contains parameters for creating an agentctl instance.
type RuntimeCreateRequest struct {
InstanceID string
@@ -44,6 +53,7 @@ type RuntimeCreateRequest struct {
Protocol string
Env map[string]string
Metadata map[string]interface{}
McpServers []McpServerConfig
// Docker-specific
AgentConfig *registry.AgentTypeConfig
MainRepoGitDir string
@@ -52,6 +52,7 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe
InstanceID: req.InstanceID,
MainRepoGitDir: req.MainRepoGitDir,
Credentials: req.Env, // Env contains credentials from the caller
McpServers: req.McpServers,
}
// Use ContainerManager to launch container
@@ -79,6 +79,18 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea
env["KANDEV_TASK_ID"] = req.TaskID
env["KANDEV_SESSION_ID"] = req.SessionID
// Convert MCP server configs
var mcpServers []agentctl.McpServerConfig
for _, mcp := range req.McpServers {
mcpServers = append(mcpServers, agentctl.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
})
}
// Create instance via control API
// Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client.
createReq := &agentctl.CreateInstanceRequest{
@@ -88,6 +100,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea
Protocol: req.Protocol,
Env: env,
AutoStart: false,
McpServers: mcpServers,
}
resp, err := r.ctl.CreateInstance(ctx, createReq)
+16 -9
View File
@@ -2,19 +2,26 @@ package mcpconfig
import "github.com/kandev/kandev/internal/agentctl/types"
// ToACPServers converts resolved MCP servers into ACP stdio server list because
// ACP currently accepts only stdio MCP definitions in session/new.
// ToACPServers converts resolved MCP servers into ACP server list.
// Supports both stdio and SSE transports.
func ToACPServers(resolved []ResolvedServer) []types.McpServer {
servers := make([]types.McpServer, 0, len(resolved))
for _, server := range resolved {
if server.Type != ServerTypeStdio {
continue
switch server.Type {
case ServerTypeStdio:
servers = append(servers, types.McpServer{
Name: server.Name,
Type: "stdio",
Command: server.Command,
Args: append([]string{}, server.Args...),
})
case ServerTypeSSE:
servers = append(servers, types.McpServer{
Name: server.Name,
Type: "sse",
URL: server.URL,
})
}
servers = append(servers, types.McpServer{
Name: server.Name,
Command: server.Command,
Args: append([]string{}, server.Args...),
})
}
return servers
}
@@ -6,7 +6,7 @@
"id": "auggie-agent",
"name": "Augment Coding Agent",
"display_name": "Auggie",
"description": "Auggie CLI-powered autonomous coding agent. Requires AUGMENT_SESSION_AUTH for authentication.",
"description": "Auggie CLI-powered autonomous coding agent.",
"enabled": true,
"image": "kandev/augment-agent",
"tag": "latest",
@@ -29,7 +29,7 @@
"workspace_flag": "--workspace-root",
"session_config": {
"resume_via_acp": false,
"resume_flag": "",
"resume_flag": "--resume",
"can_recover": false,
"session_dir_template": "{home}/.augment/sessions",
"session_dir_target": "/root/.augment/sessions"
@@ -45,6 +45,11 @@
},
"discovery": {
"supports_mcp": true,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.augment/.auggie.json"],
"windows": ["~/.augment/.auggie.json"],
@@ -141,7 +146,12 @@
"tools_requiring_permission": []
},
"discovery": {
"supports_mcp": false,
"supports_mcp": true,
"mcp_config_path": {
"linux": [],
"windows": [],
"macos": []
},
"installation_path": {
"linux": ["~/.codex/auth.json"],
"windows": ["~/.codex/auth.json"],
@@ -189,3 +199,4 @@
}
]
}
@@ -23,6 +23,15 @@ type ControlClient struct {
logger *logger.Logger
}
// McpServerConfig holds configuration for an MCP server.
type McpServerConfig struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
}
// CreateInstanceRequest contains the parameters for creating a new agent instance.
type CreateInstanceRequest struct {
ID string `json:"id,omitempty"`
@@ -32,6 +41,7 @@ type CreateInstanceRequest struct {
WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root")
Env map[string]string `json:"env,omitempty"`
AutoStart bool `json:"auto_start,omitempty"`
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
}
// CreateInstanceResponse contains the result of creating a new agent instance.
@@ -17,12 +17,12 @@ import (
// ACPAdapter implements AgentAdapter for agents using the ACP protocol.
// ACP (Agent Communication Protocol) uses JSON-RPC 2.0 over stdin/stdout.
// The subprocess is managed externally (by process.Manager) and stdin/stdout
// are passed to the adapter.
// are connected via the Connect method after the process starts.
type ACPAdapter struct {
cfg *Config
logger *logger.Logger
// Subprocess stdin/stdout (managed externally)
// Subprocess stdin/stdout (set via Connect)
stdin io.Writer
stdout io.Reader
@@ -46,17 +46,35 @@ type ACPAdapter struct {
}
// NewACPAdapter creates a new ACP protocol adapter.
// stdin and stdout are the subprocess's stdin/stdout pipes (managed by process.Manager).
func NewACPAdapter(stdin io.Writer, stdout io.Reader, cfg *Config, log *logger.Logger) *ACPAdapter {
// Call Connect() after starting the subprocess to wire up stdin/stdout.
func NewACPAdapter(cfg *Config, log *logger.Logger) *ACPAdapter {
return &ACPAdapter{
cfg: cfg,
logger: log.WithFields(zap.String("adapter", "acp")),
stdin: stdin,
stdout: stdout,
updatesCh: make(chan AgentEvent, 100),
}
}
// PrepareEnvironment is a no-op for ACP.
// ACP passes MCP servers through the protocol during session creation.
func (a *ACPAdapter) PrepareEnvironment() error {
return nil
}
// Connect wires up the stdin/stdout pipes from the running agent subprocess.
func (a *ACPAdapter) Connect(stdin io.Writer, stdout io.Reader) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.stdin != nil || a.stdout != nil {
return fmt.Errorf("adapter already connected")
}
a.stdin = stdin
a.stdout = stdout
return nil
}
// Initialize establishes the ACP connection with the agent subprocess.
// The subprocess should already be running (started by process.Manager).
func (a *ACPAdapter) Initialize(ctx context.Context) error {
@@ -137,13 +155,25 @@ func toACPMcpServers(servers []types.McpServer) []acp.McpServer {
}
out := make([]acp.McpServer, 0, len(servers))
for _, server := range servers {
out = append(out, acp.McpServer{
Stdio: &acp.McpServerStdio{
Name: server.Name,
Command: server.Command,
Args: append([]string{}, server.Args...),
},
})
switch server.Type {
case "sse":
out = append(out, acp.McpServer{
Sse: &acp.McpServerSse{
Name: server.Name,
Url: server.URL,
Type: "sse",
Headers: []acp.HttpHeader{},
},
})
default: // stdio
out = append(out, acp.McpServer{
Stdio: &acp.McpServerStdio{
Name: server.Name,
Command: server.Command,
Args: append([]string{}, server.Args...),
},
})
}
}
return out
}
@@ -4,6 +4,7 @@ package adapter
import (
"context"
"io"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
@@ -47,7 +48,26 @@ type AgentInfo struct {
// AgentAdapter defines the interface for protocol adapters.
// Each adapter translates a specific protocol (ACP, REST, MCP, etc.) into the
// normalized AgentEvent format that agentctl exposes via its HTTP API.
//
// Lifecycle:
// 1. Create adapter with New*Adapter(cfg, logger)
// 2. Call PrepareEnvironment() before starting the agent process
// 3. Start the agent process and obtain stdin/stdout pipes
// 4. Call Connect(stdin, stdout) to wire up communication
// 5. Call Initialize() to establish protocol handshake
// 6. Use NewSession/LoadSession/Prompt/Cancel for agent interactions
// 7. Call Close() when done
type AgentAdapter interface {
// PrepareEnvironment performs protocol-specific setup before the agent process starts.
// For Codex, this writes MCP servers to ~/.codex/config.toml.
// For ACP, this is a no-op (MCP servers are passed through the protocol).
// Must be called before the agent subprocess is started.
PrepareEnvironment() error
// Connect wires up the stdin/stdout pipes from the running agent subprocess.
// Must be called after the subprocess is started and before Initialize.
Connect(stdin io.Writer, stdout io.Reader) error
// Initialize establishes the connection with the agent and exchanges capabilities.
// For subprocess-based agents (ACP), this sends the initialize request.
// For HTTP-based agents (REST), this might do a health check.
@@ -89,6 +109,20 @@ type AgentAdapter interface {
Close() error
}
// McpServerConfig holds configuration for an MCP server.
type McpServerConfig struct {
// Name is the human-readable name of the MCP server
Name string `json:"name"`
// URL is the URL for HTTP/SSE transport
URL string `json:"url,omitempty"`
// Type is the transport type: "sse" or "http"
Type string `json:"type,omitempty"`
// Command is the command for stdio transport
Command string `json:"command,omitempty"`
// Args are the arguments for stdio transport
Args []string `json:"args,omitempty"`
}
// Config holds configuration for creating adapters
type Config struct {
// WorkDir is the working directory for the agent
@@ -102,6 +136,9 @@ type Config struct {
// Defaults to "on-request" if empty.
ApprovalPolicy string
// McpServers is a list of MCP servers to configure for the agent
McpServers []McpServerConfig
// For HTTP-based adapters (REST)
BaseURL string // Base URL of the agent's HTTP API
AuthHeader string // Optional auth header name
@@ -286,6 +286,64 @@ func TestPlanEntry_StatusValues(t *testing.T) {
}
}
func TestConvertSSEToStreamableHTTP(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{
name: "standard SSE endpoint",
input: "http://localhost:9090/sse",
expected: "http://localhost:9090/mcp",
},
{
name: "SSE endpoint with different port",
input: "http://example.com:8080/sse",
expected: "http://example.com:8080/mcp",
},
{
name: "trailing slash not converted",
input: "http://localhost:9090/sse/",
expected: "http://localhost:9090/sse/",
},
{
name: "non-SSE endpoint unchanged",
input: "http://localhost:9090/other",
expected: "http://localhost:9090/other",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "already streamable HTTP endpoint",
input: "http://localhost:9090/mcp",
expected: "http://localhost:9090/mcp",
},
{
name: "sse in path but not at end",
input: "http://localhost:9090/sse/something",
expected: "http://localhost:9090/sse/something",
},
{
name: "https endpoint",
input: "https://secure.example.com/sse",
expected: "https://secure.example.com/mcp",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := convertSSEToStreamableHTTP(tc.input)
if result != tc.expected {
t.Errorf("convertSSEToStreamableHTTP(%q) = %q, want %q", tc.input, result, tc.expected)
}
})
}
}
func TestSessionUpdate_WithComplexToolArgs(t *testing.T) {
// Test with complex nested ToolArgs
update := AgentEvent{
@@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/pkg/codex"
"github.com/pelletier/go-toml/v2"
"go.uber.org/zap"
)
@@ -20,7 +23,7 @@ type CodexAdapter struct {
cfg *Config
logger *logger.Logger
// Subprocess stdin/stdout (managed externally)
// Subprocess stdin/stdout (set via Connect)
stdin io.Writer
stdout io.Reader
@@ -64,20 +67,181 @@ type turnCompleteResult struct {
}
// NewCodexAdapter creates a new Codex protocol adapter.
// stdin and stdout are the subprocess's stdin/stdout pipes (managed by process.Manager).
func NewCodexAdapter(stdin io.Writer, stdout io.Reader, cfg *Config, log *logger.Logger) *CodexAdapter {
// Call Connect() after starting the subprocess to wire up stdin/stdout.
func NewCodexAdapter(cfg *Config, log *logger.Logger) *CodexAdapter {
ctx, cancel := context.WithCancel(context.Background())
return &CodexAdapter{
cfg: cfg,
logger: log.WithFields(zap.String("adapter", "codex")),
stdin: stdin,
stdout: stdout,
ctx: ctx,
cancel: cancel,
updatesCh: make(chan AgentEvent, 100),
}
}
// PrepareEnvironment writes MCP servers to the Codex config file.
// Codex reads MCP configuration from ~/.codex/config.toml at startup time.
func (a *CodexAdapter) PrepareEnvironment() error {
a.logger.Info("PrepareEnvironment called",
zap.Int("mcp_server_count", len(a.cfg.McpServers)))
for i, srv := range a.cfg.McpServers {
a.logger.Info("MCP server config",
zap.Int("index", i),
zap.String("name", srv.Name),
zap.String("url", srv.URL),
zap.String("type", srv.Type),
zap.String("command", srv.Command))
}
return WriteCodexMcpConfig(a.cfg.McpServers, "", a.logger)
}
// Connect wires up the stdin/stdout pipes from the running agent subprocess.
func (a *CodexAdapter) Connect(stdin io.Writer, stdout io.Reader) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.stdin != nil || a.stdout != nil {
return fmt.Errorf("adapter already connected")
}
a.stdin = stdin
a.stdout = stdout
return nil
}
// WriteCodexMcpConfig writes MCP server configuration to Codex's config.toml.
// It merges with existing config, preserving other settings and existing MCP servers.
// Codex reads MCP servers from ~/.codex/config.toml at startup time, not through the protocol.
// This function should be called before starting the Codex process.
// If homeDir is empty, it uses os.UserHomeDir().
func WriteCodexMcpConfig(mcpServers []McpServerConfig, homeDir string, log *logger.Logger) error {
if len(mcpServers) == 0 {
return nil // Nothing to configure
}
// Determine config directory
if homeDir == "" {
var err error
homeDir, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
}
configDir := filepath.Join(homeDir, ".codex")
configPath := filepath.Join(configDir, "config.toml")
// Create config directory if it doesn't exist
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create codex config directory: %w", err)
}
// Read existing config if it exists
existingData, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read existing codex config: %w", err)
}
// Parse existing config into a generic map to preserve all fields
var rawConfig map[string]interface{}
if len(existingData) > 0 {
if err := toml.Unmarshal(existingData, &rawConfig); err != nil {
if log != nil {
log.Warn("failed to parse existing codex config, will create new",
zap.String("path", configPath),
zap.Error(err))
}
rawConfig = make(map[string]interface{})
}
} else {
rawConfig = make(map[string]interface{})
}
// Get or create mcp_servers section
mcpServersSection, ok := rawConfig["mcp_servers"].(map[string]interface{})
if !ok {
mcpServersSection = make(map[string]interface{})
}
// Add/update our MCP servers
for _, server := range mcpServers {
safeName := sanitizeCodexServerName(server.Name)
serverConfig := make(map[string]interface{})
if server.Type == "sse" || server.Type == "http" {
// HTTP/SSE transport - use url field
// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests.
// Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility.
url := server.URL
if url != "" {
url = convertSSEToStreamableHTTP(url)
serverConfig["url"] = url
}
} else {
// STDIO transport - use command and args
if server.Command != "" {
serverConfig["command"] = server.Command
}
if len(server.Args) > 0 {
serverConfig["args"] = server.Args
}
}
mcpServersSection[safeName] = serverConfig
}
// Update the mcp_servers section in the config
rawConfig["mcp_servers"] = mcpServersSection
// Marshal back to TOML
output, err := toml.Marshal(rawConfig)
if err != nil {
return fmt.Errorf("failed to marshal codex config: %w", err)
}
// Write config file
if err := os.WriteFile(configPath, output, 0644); err != nil {
return fmt.Errorf("failed to write codex config: %w", err)
}
if log != nil {
log.Info("wrote Codex MCP config",
zap.String("path", configPath),
zap.Int("server_count", len(mcpServers)),
zap.Int("total_mcp_servers", len(mcpServersSection)))
}
return nil
}
// sanitizeCodexServerName converts a server name to a valid TOML table name.
// Replaces spaces and special characters with underscores.
func sanitizeCodexServerName(name string) string {
var result strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
result.WriteRune(r)
} else {
result.WriteRune('_')
}
}
sanitized := result.String()
if sanitized == "" {
return "server"
}
return sanitized
}
// convertSSEToStreamableHTTP converts an SSE endpoint URL to a streamable HTTP endpoint URL.
// Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests.
// This converts URLs ending in /sse to /mcp for Kandev MCP server compatibility.
// Example: http://localhost:9090/sse -> http://localhost:9090/mcp
func convertSSEToStreamableHTTP(url string) string {
if strings.HasSuffix(url, "/sse") {
return strings.TrimSuffix(url, "/sse") + "/mcp"
}
return url
}
// Initialize establishes the Codex connection with the agent subprocess.
func (a *CodexAdapter) Initialize(ctx context.Context) error {
a.logger.Info("initializing Codex adapter",
@@ -139,6 +303,9 @@ func (a *CodexAdapter) GetAgentInfo() *AgentInfo {
}
// NewSession creates a new Codex thread (session).
// Note: The mcpServers parameter is ignored because Codex reads MCP configuration from
// ~/.codex/config.toml at startup time, not through the protocol. MCP servers are written
// to the config file by PrepareEnvironment() before the Codex process starts.
func (a *CodexAdapter) NewSession(ctx context.Context, _ []types.McpServer) (string, error) {
// Check client under lock, but don't hold lock during Call() to avoid deadlock
// with handleNotification which also needs the lock
@@ -69,6 +69,20 @@ type InstanceDefaults struct {
HealthCheckInterval int
}
// McpServerConfig holds configuration for an MCP server.
type McpServerConfig struct {
// Name is the human-readable name of the MCP server
Name string `json:"name"`
// URL is the URL for HTTP/SSE transport
URL string `json:"url,omitempty"`
// Type is the transport type: "sse" or "http"
Type string `json:"type,omitempty"`
// Command is the command for stdio transport
Command string `json:"command,omitempty"`
// Args are the arguments for stdio transport
Args []string `json:"args,omitempty"`
}
// InstanceConfig holds configuration for a single agent instance.
// This is passed to the process manager and API server.
type InstanceConfig struct {
@@ -113,6 +127,9 @@ type InstanceConfig struct {
// AgentType identifies the agent (e.g., "auggie", "codex", "claude")
// Used for agent-specific adapter selection
AgentType string
// McpServers is a list of MCP servers to configure for the agent
McpServers []McpServerConfig
}
// Load loads the configuration from environment variables.
@@ -175,6 +192,9 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst
if overrides.AgentType != "" {
cfg.AgentType = overrides.AgentType
}
if len(overrides.McpServers) > 0 {
cfg.McpServers = overrides.McpServers
}
}
// Parse agent command into args
@@ -197,6 +217,7 @@ type InstanceOverrides struct {
Env []string
ApprovalPolicy string
AgentType string
McpServers []McpServerConfig
}
// ParseCommand splits a command string into arguments
@@ -41,6 +41,15 @@ type Instance struct {
server *http.Server
}
// McpServerConfig holds configuration for an MCP server.
type McpServerConfig struct {
Name string `json:"name"`
URL string `json:"url,omitempty"`
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
}
// CreateRequest contains the parameters for creating a new agent instance.
type CreateRequest struct {
// ID is an optional identifier for the instance. If empty, one will be generated.
@@ -64,6 +73,9 @@ type CreateRequest struct {
// AutoStart indicates whether to start the agent automatically after creation.
AutoStart bool `json:"auto_start,omitempty"`
// McpServers is a list of MCP servers to configure for the agent.
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
}
// CreateResponse contains the result of creating a new agent instance.
@@ -83,12 +83,26 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
// Build overrides from request
autoStart := req.AutoStart
// Convert MCP server configs
var mcpServers []config.McpServerConfig
for _, mcp := range req.McpServers {
mcpServers = append(mcpServers, config.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
})
}
overrides := &config.InstanceOverrides{
Protocol: agent.Protocol(req.Protocol),
AgentCommand: agentCmd,
WorkDir: req.WorkspacePath,
AutoStart: &autoStart,
Env: config.CollectAgentEnv(req.Env),
McpServers: mcpServers,
}
// Create instance config using the unified method
@@ -79,7 +79,8 @@ type Manager struct {
shell *shell.Session
// Protocol adapter for agent communication
adapter adapter.AgentAdapter
adapter adapter.AgentAdapter
adapterCfg *adapter.Config
// Agent event notifications (protocol-agnostic)
updatesCh chan adapter.AgentEvent
@@ -201,6 +202,35 @@ func (m *Manager) Start(ctx context.Context) error {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
// Build adapter config
mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers))
for i, mcp := range m.cfg.McpServers {
mcpServers[i] = adapter.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
}
}
m.adapterCfg = &adapter.Config{
WorkDir: m.cfg.WorkDir,
AutoApprove: m.cfg.AutoApprovePermissions,
ApprovalPolicy: m.cfg.ApprovalPolicy,
McpServers: mcpServers,
}
// Create adapter before starting the process so we can call PrepareEnvironment
if err := m.createAdapter(); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create adapter: %w", err)
}
// Prepare protocol-specific environment before starting the process
if err := m.adapter.PrepareEnvironment(); err != nil {
m.logger.Warn("failed to prepare protocol environment", zap.Error(err))
}
// Start the process
m.logger.Info("starting agent process",
zap.Strings("args", m.cfg.AgentArgs),
@@ -214,10 +244,10 @@ func (m *Manager) Start(ctx context.Context) error {
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
// Create protocol adapter based on configuration
if err := m.createAdapter(); err != nil {
// Connect adapter to the process stdin/stdout pipes
if err := m.adapter.Connect(m.stdin, m.stdout); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to create adapter: %w", err)
return fmt.Errorf("failed to connect adapter: %w", err)
}
// Start stderr reader and exit waiter
@@ -307,24 +337,20 @@ func (m *Manager) Configure(command string, env map[string]string) error {
return nil
}
// createAdapter creates the appropriate protocol adapter based on configuration
// createAdapter creates the appropriate protocol adapter based on configuration.
// This should be called before starting the process so PrepareEnvironment can run.
func (m *Manager) createAdapter() error {
protocol := m.cfg.Protocol
if protocol == "" {
protocol = agent.ProtocolACP // Default to ACP
}
adapterCfg := &adapter.Config{
WorkDir: m.cfg.WorkDir,
AutoApprove: m.cfg.AutoApprovePermissions,
ApprovalPolicy: m.cfg.ApprovalPolicy,
}
// Use the adapter config built in Start()
switch protocol {
case agent.ProtocolACP:
m.adapter = adapter.NewACPAdapter(m.stdin, m.stdout, adapterCfg, m.logger)
m.adapter = adapter.NewACPAdapter(m.adapterCfg, m.logger)
case agent.ProtocolCodex:
m.adapter = adapter.NewCodexAdapter(m.stdin, m.stdout, adapterCfg, m.logger)
m.adapter = adapter.NewCodexAdapter(m.adapterCfg, m.logger)
default:
return fmt.Errorf("unsupported protocol: %s", protocol)
}
@@ -115,11 +115,14 @@ type PermissionHandler func(ctx context.Context, req *PermissionRequest) (*Permi
// WorkspaceMessageType represents the type of workspace stream message
type WorkspaceMessageType string
// McpServer represents a stdio MCP server configuration.
// McpServer represents an MCP server configuration.
// Supports both stdio (command+args) and SSE (url) transports.
type McpServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Command string `json:"command,omitempty"` // For stdio transport
Args []string `json:"args,omitempty"` // For stdio transport
URL string `json:"url,omitempty"` // For SSE transport
Type string `json:"type,omitempty"` // "stdio" or "sse"
}
const (
@@ -100,6 +100,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 in standalone mode)
McpServerEnabled bool `mapstructure:"mcpServerEnabled"`
// McpServerPort is the port for the embedded 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
McpServerURL string `mapstructure:"mcpServerUrl"`
}
// ReadTimeoutDuration returns the read timeout as a time.Duration.
@@ -152,6 +163,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("agent.runtime", "standalone")
v.SetDefault("agent.standaloneHost", "localhost")
v.SetDefault("agent.standalonePort", 9999)
v.SetDefault("agent.mcpServerEnabled", true)
v.SetDefault("agent.mcpServerPort", 9090)
v.SetDefault("agent.mcpServerUrl", "") // Empty means auto-set when mcpServerEnabled is true
// Auth defaults
v.SetDefault("auth.jwtSecret", "")
@@ -0,0 +1,50 @@
// 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
}
+177
View File
@@ -0,0 +1,177 @@
// 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)
}
// 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)
}
+313
View File
@@ -0,0 +1,313 @@
package mcpserver
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"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 Columns tool
s.AddTool(
mcp.NewTool("list_columns",
mcp.WithDescription("List all columns in a board. Use this to get column IDs for creating tasks."),
mcp.WithString("board_id",
mcp.Required(),
mcp.Description("The board ID to list columns from"),
),
),
listColumnsHandler(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("column_id",
mcp.Required(),
mcp.Description("The column 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),
)
log.Info("registered MCP tools", zap.Int("count", 6))
}
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 listColumnsHandler(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/columns", cfg.KandevURL, boardID)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch columns", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch columns: %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
}
columnID, err := req.RequireString("column_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,
"column_id": columnID,
"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
}
}
+6 -3
View File
@@ -102,10 +102,13 @@ type SessionNewParams struct {
}
// McpServer configuration for MCP servers
// Supports both stdio (command+args) and remote (url+type) transports
type McpServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Name string `json:"name"`
Command string `json:"command,omitempty"` // For stdio transport
Args []string `json:"args,omitempty"` // For stdio transport
URL string `json:"url,omitempty"` // For HTTP/SSE transport
Type string `json:"type,omitempty"` // "sse" or "http" for remote transport
}
// SessionNewResult from session/new method
+5 -2
View File
@@ -96,7 +96,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
tailwindcss:
specifier: ^4.0.0
version: 4.1.18
@@ -293,6 +293,9 @@ importers:
'@kandev/ui':
specifier: workspace:*
version: link:../packages/ui
'@lezer/highlight':
specifier: ^1.2.3
version: 1.2.3
'@tabler/icons-react':
specifier: ^3.36.1
version: 3.36.1(react@19.2.3)
@@ -401,7 +404,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
happy-dom:
specifier: ^20.1.0
version: 20.1.0
@@ -64,6 +64,10 @@ export function ProfileMcpConfigCard({
const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
const popularServers: Record<string, Record<string, unknown>> = {
kandev: {
type: 'sse',
url: 'http://localhost:9090/sse',
},
playwright: {
type: 'stdio',
command: 'npx',
@@ -198,6 +202,13 @@ export function ProfileMcpConfigCard({
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">
<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')}
>
+ Kandev MCP
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
+1
View File
@@ -33,6 +33,7 @@
"@kandev/theme": "workspace:*",
"@kandev/types": "workspace:*",
"@kandev/ui": "workspace:*",
"@lezer/highlight": "^1.2.3",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/codemirror-theme-vscode": "^4.25.4",
+247
View File
@@ -0,0 +1,247 @@
# Cancel/Stop Agent Turn Implementation
This document describes how to implement end-to-end functionality for cancelling the current agent turn for both Codex and Auggie agents.
## Overview
Users need the ability to stop an agent mid-execution without killing the entire agent process. This allows for quick iteration—cancelling a wrong path and sending a new prompt—without waiting for the agent to finish or restarting the session.
## Protocol Summary
### Auggie (ACP Protocol)
- **Method**: `session/cancel` (notification)
- **Transport**: JSON-RPC 2.0 over stdin/stdout
- **Message format**:
```json
{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"<session-id>"}}
```
- **Key characteristics**:
- It's a notification (no `id` field, no response expected)
- Only requires `sessionId`
- The pending `session/prompt` request returns with `{"stopReason":"cancelled"}`
- Works immediately—agent stops current operation
### Codex (App-Server Protocol)
- **Method**: `turn/interrupt` (request)
- **Transport**: JSON-RPC variant over stdin/stdout (omits `jsonrpc` field)
- **Message format**:
```json
{"id":N,"method":"turn/interrupt","params":{"threadId":"<thread-id>","turnId":"<turn-id>"}}
```
- **Key characteristics**:
- It's a request (has `id` field, expects response)
- Requires both `threadId` AND `turnId`
- `threadId` = session ID (from `thread/start` response)
- `turnId` = operation ID (from `turn/start` response)
## Current State
### Already Implemented ✅
1. **AgentAdapter interface** has `Cancel(ctx context.Context) error` method
2. **ACPAdapter.Cancel()** is fully implemented using `acpConn.Cancel()`
3. **CodexAdapter.Cancel()** is fully implemented using `turn/interrupt`
4. **AgentAdapter.GetOperationID()** returns `turnID` for Codex (needed for cancel)
5. **WebSocket action constant** `ActionAgentCancel = "agent.cancel"` exists
### Not Implemented ❌
1. **agentctl HTTP endpoint** for cancel (`POST /api/v1/acp/cancel`)
2. **agentctl Client method** to call the cancel endpoint
3. **Backend orchestrator/lifecycle integration** to route cancel requests
4. **WebSocket handler** for `agent.cancel` action
5. **Frontend UI** cancel button and hook
## High-Level Flow
```
Frontend (Cancel Button)
|
| agent.cancel (WS request)
v
Backend (WS Gateway → Dispatcher)
|
| orchestrator handler
v
Backend (Executor → AgentManager)
|
| CancelAgent(ctx, sessionID)
v
Lifecycle Manager
|
| HTTP POST /api/v1/acp/cancel
v
agentctl (api server)
|
| adapter.Cancel(ctx)
v
Agent subprocess (ACP or Codex protocol)
|
v
Agent stops current turn
|
| Returns stopReason=cancelled (ACP) or turn/completed with cancelled (Codex)
v
Prompt() returns → lifecycle emits session.cancelled event → UI updates
```
## Implementation Plan
### Layer 1: agentctl Server (HTTP API)
**File**: `apps/backend/internal/agentctl/server/api/acp.go`
Add a new endpoint:
- `POST /api/v1/acp/cancel` → `handleACPCancel`
Handler implementation:
1. Get the adapter from process manager
2. Call `adapter.Cancel(ctx)`
3. Return success/error response
**File**: `apps/backend/internal/agentctl/server/api/server.go`
Register the new route in the router group.
### Layer 2: agentctl Client
**File**: `apps/backend/internal/agentctl/client/acp.go`
Add `Cancel(ctx context.Context) error` method:
1. Make HTTP POST to `/api/v1/acp/cancel`
2. Parse response and return error if failed
### Layer 3: Lifecycle Manager
**File**: `apps/backend/internal/agent/lifecycle/manager.go`
Add `CancelAgent(ctx context.Context, executionID string) error`:
1. Look up execution by ID
2. Validate execution is in running state
3. Call `execution.agentctl.Cancel(ctx)`
4. The running Prompt() call will return with cancelled stop reason
**File**: `apps/backend/internal/agent/lifecycle/session.go`
Handle cancelled stop reason in `SendPrompt()`:
- When prompt returns with `stopReason=cancelled`, don't treat as error
- Emit appropriate event for UI notification
### Layer 4: Executor & Orchestrator
**File**: `apps/backend/internal/orchestrator/executor/executor.go`
Add interface method to `AgentManagerClient`:
```go
CancelAgent(ctx context.Context, sessionID string) error
```
Add `Cancel(ctx, taskID, sessionID string) error`:
1. Look up session by ID
2. Get agent execution ID
3. Call `agentManager.CancelAgent(ctx, executionID)`
**File**: `apps/backend/cmd/kandev/adapters.go`
Implement `CancelAgent` on `lifecycleAdapter`:
- Delegate to `mgr.CancelAgent()`
### Layer 5: WebSocket Handler
**File**: `apps/backend/internal/orchestrator/handlers/handlers.go`
Add handler for `agent.cancel`:
```go
func (h *Handlers) wsCancelAgent(ctx context.Context, msg *ws.Message) (*ws.Message, error)
```
Request payload:
```go
type wsCancelAgentRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
}
```
Register in `RegisterHandlers()`:
```go
d.RegisterFunc(ws.ActionAgentCancel, h.wsCancelAgent)
```
**File**: `apps/backend/internal/orchestrator/controller/controller.go`
Add controller method:
```go
func (c *Controller) CancelAgent(ctx context.Context, req dto.CancelAgentRequest) (dto.CancelAgentResponse, error)
```
**File**: `apps/backend/internal/orchestrator/controller/dto/dto.go`
Add request/response types:
```go
type CancelAgentRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
}
type CancelAgentResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
```
### Layer 6: Frontend
**File**: `apps/web/hooks/use-session-agent.ts`
Add `handleCancelTurn` callback:
```typescript
const handleCancelTurn = useCallback(async () => {
if (!task?.id || !agentSessionId) return;
const client = getWebSocketClient();
if (!client) return;
await client.request('agent.cancel', {
task_id: task.id,
session_id: agentSessionId
});
}, [task?.id, agentSessionId]);
```
Return from hook: `{ ..., handleCancelTurn }`
**UI Integration** (location TBD—likely task chat panel or top bar):
- Add "Stop" button visible when agent is running
- Wire to `handleCancelTurn()`
- Show loading state during cancel
- Button disabled when no active turn
## Event Flow After Cancel
1. `adapter.Cancel()` sends protocol message
2. Agent stops and responds with cancelled status
3. `Prompt()` returns with `stopReason = "cancelled"`
4. Lifecycle manager:
- Updates execution status to READY (not COMPLETED)
- Emits `session.agent.cancelled` or similar event
5. Frontend receives event and updates UI:
- Stops spinner
- Shows "Cancelled" indicator
- Re-enables input for new prompt
## Error Handling
| Scenario | Behavior |
|----------|----------|
| No active turn | Return success (no-op) |
| Agent not running | Return error "agent not running" |
| Session not found | Return error "session not found" |
| Cancel timeout | Return error, but cancel still in flight |
| Cancel during permission request | Cancel the permission, return cancelled |
## Testing
1. **Unit tests** for each layer (adapter, client, handler)
2. **Integration test**: Start agent, send prompt, cancel mid-execution
3. **E2E test**: UI cancel button flow
4. **Protocol tests**: Verify correct JSON-RPC messages for ACP and Codex