From c57a82f81d55f58a84c19bc0320491ec0eeba427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 21 Jan 2026 12:03:46 +0000 Subject: [PATCH] feat(mcp): add embedded MCP server with dual transport support (#67) --- apps/.npmrc | 3 + apps/backend/Makefile | 21 +- apps/backend/cmd/kandev/main.go | 18 +- apps/backend/cmd/kandev/mcpserver.go | 31 ++ apps/backend/cmd/mcp-server/main.go | 120 +++++++ apps/backend/go.mod | 9 +- apps/backend/go.sum | 23 +- .../internal/agent/lifecycle/container.go | 14 + .../internal/agent/lifecycle/manager.go | 40 ++- .../internal/agent/lifecycle/runtime.go | 10 + .../agent/lifecycle/runtime_docker.go | 1 + .../agent/lifecycle/runtime_standalone.go | 13 + apps/backend/internal/agent/mcpconfig/acp.go | 25 +- .../internal/agent/registry/agents.json | 17 +- .../internal/agentctl/client/control.go | 10 + .../agentctl/server/adapter/acp_adapter.go | 56 +++- .../agentctl/server/adapter/adapter.go | 37 +++ .../agentctl/server/adapter/adapter_test.go | 58 ++++ .../agentctl/server/adapter/codex_adapter.go | 177 +++++++++- .../internal/agentctl/server/config/config.go | 21 ++ .../agentctl/server/instance/instance.go | 12 + .../agentctl/server/instance/manager.go | 14 + .../agentctl/server/process/manager.go | 52 ++- apps/backend/internal/agentctl/types/types.go | 9 +- apps/backend/internal/common/config/config.go | 14 + apps/backend/internal/mcpserver/provider.go | 50 +++ apps/backend/internal/mcpserver/server.go | 177 ++++++++++ apps/backend/internal/mcpserver/tools.go | 313 ++++++++++++++++++ apps/backend/pkg/acp/jsonrpc/types.go | 9 +- apps/pnpm-lock.yaml | 7 +- .../[agentId]/profile-mcp-config-card.tsx | 11 + apps/web/package.json | 1 + docs/cancel-turn-implementation.md | 247 ++++++++++++++ 33 files changed, 1549 insertions(+), 71 deletions(-) create mode 100644 apps/.npmrc create mode 100644 apps/backend/cmd/kandev/mcpserver.go create mode 100644 apps/backend/cmd/mcp-server/main.go create mode 100644 apps/backend/internal/mcpserver/provider.go create mode 100644 apps/backend/internal/mcpserver/server.go create mode 100644 apps/backend/internal/mcpserver/tools.go create mode 100644 docs/cancel-turn-implementation.md diff --git a/apps/.npmrc b/apps/.npmrc new file mode 100644 index 0000000000..203c804146 --- /dev/null +++ b/apps/.npmrc @@ -0,0 +1,3 @@ +# Hoist @lezer/highlight for codemirror theme packages that expect it as a peer dependency +public-hoist-pattern[]=@lezer/* + diff --git a/apps/backend/Makefile b/apps/backend/Makefile index 3c8e24f1f8..ea42b49f88 100644 --- a/apps/backend/Makefile +++ b/apps/backend/Makefile @@ -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" diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 856ea0ae0a..ae209da8f2 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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, diff --git a/apps/backend/cmd/kandev/mcpserver.go b/apps/backend/cmd/kandev/mcpserver.go new file mode 100644 index 0000000000..904d1337fc --- /dev/null +++ b/apps/backend/cmd/kandev/mcpserver.go @@ -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 +} + diff --git a/apps/backend/cmd/mcp-server/main.go b/apps/backend/cmd/mcp-server/main.go new file mode 100644 index 0000000000..4fbdb40dd3 --- /dev/null +++ b/apps/backend/cmd/mcp-server/main.go @@ -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 +} + diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 0d4cf92198..85131ef771 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -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 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 99ef3d9625..48d862e2d3 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -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= diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index 666934f47f..1bd15d1f41 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -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) diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 85530f4f8c..652fcbec70 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -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) diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index d8558eaca0..13a90d53a7 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -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 diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go index 1257e19817..e58b3d83d5 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -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 diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 1921ad0fd5..d37c503d1a 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -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) diff --git a/apps/backend/internal/agent/mcpconfig/acp.go b/apps/backend/internal/agent/mcpconfig/acp.go index dfe4e1b4f5..98a42a0754 100644 --- a/apps/backend/internal/agent/mcpconfig/acp.go +++ b/apps/backend/internal/agent/mcpconfig/acp.go @@ -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 } diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json index c5e1945f16..4b59eb04a9 100644 --- a/apps/backend/internal/agent/registry/agents.json +++ b/apps/backend/internal/agent/registry/agents.json @@ -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 @@ } ] } + diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index 2d366eb962..d7b46ff7e6 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -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. diff --git a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go index 6f8412c94d..0e16b07e3e 100644 --- a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go @@ -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 } diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 094558faec..cd93662dcc 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/adapter/adapter_test.go b/apps/backend/internal/agentctl/server/adapter/adapter_test.go index 60ea8428a3..8c656ce54b 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter_test.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter_test.go @@ -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{ diff --git a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go index 76bc9fcffa..a120b6487c 100644 --- a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index ca1bb9c61e..22df7ba983 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index fe59890ca8..9854cb194c 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -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. diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index dee142c0ef..5b2be404b9 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -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 diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index bf90cf8c6d..0e6062cd0b 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -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) } diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go index 0b1ebe2772..add0d2b226 100644 --- a/apps/backend/internal/agentctl/types/types.go +++ b/apps/backend/internal/agentctl/types/types.go @@ -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 ( diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index cae849ebcf..7077193c5e 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -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", "") diff --git a/apps/backend/internal/mcpserver/provider.go b/apps/backend/internal/mcpserver/provider.go new file mode 100644 index 0000000000..2948f95361 --- /dev/null +++ b/apps/backend/internal/mcpserver/provider.go @@ -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 +} + diff --git a/apps/backend/internal/mcpserver/server.go b/apps/backend/internal/mcpserver/server.go new file mode 100644 index 0000000000..5126bd5dca --- /dev/null +++ b/apps/backend/internal/mcpserver/server.go @@ -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) +} + diff --git a/apps/backend/internal/mcpserver/tools.go b/apps/backend/internal/mcpserver/tools.go new file mode 100644 index 0000000000..5c15936c48 --- /dev/null +++ b/apps/backend/internal/mcpserver/tools.go @@ -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 + } +} + diff --git a/apps/backend/pkg/acp/jsonrpc/types.go b/apps/backend/pkg/acp/jsonrpc/types.go index 911d98dbc9..6c429e2c6a 100644 --- a/apps/backend/pkg/acp/jsonrpc/types.go +++ b/apps/backend/pkg/acp/jsonrpc/types.go @@ -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 diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 78e747e93f..c6f5fb07f1 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -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 diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index 3cc347c083..8febbc0bba 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -64,6 +64,10 @@ export function ProfileMcpConfigCard({ const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty; const popularServers: Record> = { + 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.

Popular servers

+