diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go
index 217ad3cce6..0d11cdcaf2 100644
--- a/apps/backend/cmd/kandev/agents.go
+++ b/apps/backend/cmd/kandev/agents.go
@@ -9,6 +9,7 @@ import (
"github.com/kandev/kandev/internal/agent/credentials"
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/lifecycle"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
@@ -74,7 +75,8 @@ func provideLifecycleManager(
}
}
- lifecycleMgr := lifecycle.NewManager(agentRegistry, eventBus, agentRuntime, containerMgr, credsMgr, profileResolver, log)
+ mcpService := mcpconfig.NewService(agentSettingsRepo)
+ lifecycleMgr := lifecycle.NewManager(agentRegistry, eventBus, agentRuntime, containerMgr, credsMgr, profileResolver, mcpService, log)
if err := lifecycleMgr.Start(ctx); err != nil {
return nil, nil, err
}
diff --git a/apps/backend/internal/agent/handlers/shell_handlers_test.go b/apps/backend/internal/agent/handlers/shell_handlers_test.go
index 9eb9baef34..2c5013afca 100644
--- a/apps/backend/internal/agent/handlers/shell_handlers_test.go
+++ b/apps/backend/internal/agent/handlers/shell_handlers_test.go
@@ -183,7 +183,7 @@ func newTestManager() *lifecycle.Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime and containerManager - tests don't need them
- return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, log)
+ return lifecycle.NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, log)
}
func TestWsShellStatus_NoInstanceFound(t *testing.T) {
diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go
index d343cdb6ee..4fe160051c 100644
--- a/apps/backend/internal/agent/lifecycle/manager.go
+++ b/apps/backend/internal/agent/lifecycle/manager.go
@@ -14,9 +14,12 @@ import (
"github.com/google/uuid"
"go.uber.org/zap"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/agent/runtime"
"github.com/kandev/kandev/internal/agent/worktree"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
+ agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
@@ -31,6 +34,7 @@ type Manager struct {
credsMgr CredentialsManager
profileResolver ProfileResolver
worktreeMgr *worktree.Manager
+ mcpProvider McpConfigProvider
logger *logger.Logger
// Agent runtime abstraction (Docker, Standalone, K8s, SSH, etc.)
@@ -63,6 +67,7 @@ func NewManager(
containerManager *ContainerManager,
credsMgr CredentialsManager,
profileResolver ProfileResolver,
+ mcpProvider McpConfigProvider,
log *logger.Logger,
) *Manager {
componentLogger := log.WithFields(zap.String("component", "lifecycle-manager"))
@@ -85,6 +90,7 @@ func NewManager(
runtime: runtime,
credsMgr: credsMgr,
profileResolver: profileResolver,
+ mcpProvider: mcpProvider,
logger: componentLogger,
executionStore: executionStore,
commandBuilder: commandBuilder,
@@ -108,7 +114,7 @@ func NewManager(
sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore)
if runtime != nil {
- mgr.logger.Info("initialized with runtime", zap.String("runtime", runtime.Name()))
+ mgr.logger.Info("initialized with runtime", zap.String("runtime", string(runtime.Name())))
}
return mgr
@@ -204,7 +210,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.String("workspace_path", info.WorkspacePath),
- zap.String("runtime", m.runtime.Name()))
+ zap.String("runtime", string(m.runtime.Name())))
return execution, nil
}
@@ -213,7 +219,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work
func (m *Manager) Start(ctx context.Context) error {
runtimeName := "none"
if m.runtime != nil {
- runtimeName = m.runtime.Name()
+ runtimeName = string(m.runtime.Name())
}
m.logger.Info("starting lifecycle manager", zap.String("runtime", runtimeName))
@@ -491,7 +497,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti
runtimeName := "unknown"
if m.runtime != nil {
- runtimeName = m.runtime.Name()
+ runtimeName = string(m.runtime.Name())
}
m.logger.Info("agentctl execution created (agent not started)",
zap.String("execution_id", executionID),
@@ -563,8 +569,14 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err
return fmt.Errorf("failed to get agent config: %w", err)
}
+ mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig)
+ if err != nil {
+ m.updateExecutionError(executionID, "failed to resolve MCP config: "+err.Error())
+ return fmt.Errorf("failed to resolve MCP config: %w", err)
+ }
+
// Initialize ACP session
- if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription); err != nil {
+ if err := m.initializeACPSession(ctx, execution, agentConfig, taskDescription, mcpServers); err != nil {
m.updateExecutionError(executionID, "failed to initialize ACP: "+err.Error())
return fmt.Errorf("failed to initialize ACP: %w", err)
}
@@ -700,9 +712,81 @@ func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*regist
return agentConfig, nil
}
+// resolveMcpServers centralizes MCP resolution for a session:
+// - loads per-agent MCP config,
+// - 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 m.mcpProvider == nil || agentConfig == nil {
+ return nil, nil
+ }
+
+ profileID := ""
+ if execution != nil {
+ profileID = strings.TrimSpace(execution.AgentProfileID)
+ }
+ if profileID == "" {
+ return nil, nil
+ }
+
+ config, err := m.mcpProvider.GetConfigByProfileID(ctx, profileID)
+ if err != nil {
+ if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) || errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
+ return nil, nil
+ }
+ m.logger.Warn("failed to load MCP config",
+ zap.String("profile_id", profileID),
+ zap.Error(err))
+ return nil, err
+ }
+ if config == nil || !config.Enabled {
+ return nil, nil
+ }
+
+ policy := mcpconfig.DefaultPolicyForRuntime(runtimeName(m.runtime))
+ executorID := ""
+ if execution != nil && execution.Metadata != nil {
+ if value, ok := execution.Metadata["executor_id"].(string); ok {
+ executorID = value
+ }
+ if value, ok := execution.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)
+ }
+ policy = updated
+ for _, warning := range policyWarnings {
+ m.logger.Warn("mcp policy warning",
+ zap.String("profile_id", profileID),
+ zap.String("executor_id", executorID),
+ zap.String("warning", warning))
+ }
+ }
+ }
+ resolved, warnings, err := mcpconfig.Resolve(config, policy)
+ if err != nil {
+ return nil, err
+ }
+ for _, warning := range warnings {
+ m.logger.Warn("mcp config warning",
+ zap.String("profile_id", profileID),
+ zap.String("executor_id", executorID),
+ zap.String("warning", warning))
+ }
+
+ return mcpconfig.ToACPServers(resolved), nil
+}
+
+func runtimeName(rt Runtime) runtime.Name {
+ if rt == nil {
+ return runtime.NameUnknown
+ }
+ return rt.Name()
+}
+
// initializeACPSession delegates to SessionManager for full ACP session initialization and prompting
-func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig, taskDescription string) error {
- return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, m.MarkReady)
+func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig, taskDescription string, mcpServers []agentctltypes.McpServer) error {
+ return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady)
}
// emitSessionStatusEvent emits a session_status event indicating whether the session was resumed or new.
@@ -949,7 +1033,7 @@ func (m *Manager) StopAgent(ctx context.Context, executionID string, force bool)
runtimeName := "unknown"
if m.runtime != nil {
- runtimeName = m.runtime.Name()
+ runtimeName = string(m.runtime.Name())
}
m.logger.Info("stopping agent",
zap.String("execution_id", executionID),
diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go
index 58b9311203..425187f461 100644
--- a/apps/backend/internal/agent/lifecycle/manager_test.go
+++ b/apps/backend/internal/agent/lifecycle/manager_test.go
@@ -153,7 +153,7 @@ func newTestManager() *Manager {
credsMgr := &MockCredentialsManager{}
profileResolver := &MockProfileResolver{}
// Pass nil for runtime and containerManager - tests don't need them
- return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, log)
+ return NewManager(reg, eventBus, nil, nil, credsMgr, profileResolver, nil, log)
}
func TestNewManager(t *testing.T) {
diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go
index a059d9c194..fe0e43092d 100644
--- a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go
+++ b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go
@@ -53,6 +53,14 @@ func (m *MockRepository) ListAgents(ctx context.Context) ([]*models.Agent, error
return []*models.Agent{}, nil
}
+func (m *MockRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (m *MockRepository) UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error {
+ return nil
+}
+
func (m *MockRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
return nil
}
diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go
index b93d27bc9a..d8558eaca0 100644
--- a/apps/backend/internal/agent/lifecycle/runtime.go
+++ b/apps/backend/internal/agent/lifecycle/runtime.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/agent/runtime"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -15,7 +16,7 @@ import (
// Agent subprocess launching is handled separately via agentctl client methods.
type Runtime interface {
// Name returns the runtime identifier (e.g., "docker", "standalone", "k8s")
- Name() string
+ Name() runtime.Name
// HealthCheck verifies the runtime is available and operational
HealthCheck(ctx context.Context) error
diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go
index 45b423b10f..1257e19817 100644
--- a/apps/backend/internal/agent/lifecycle/runtime_docker.go
+++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go
@@ -7,8 +7,9 @@ import (
"go.uber.org/zap"
- agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agent/docker"
+ "github.com/kandev/kandev/internal/agent/runtime"
+ agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -28,8 +29,8 @@ func NewDockerRuntime(dockerClient *docker.Client, log *logger.Logger) *DockerRu
}
}
-func (r *DockerRuntime) Name() string {
- return "docker"
+func (r *DockerRuntime) Name() runtime.Name {
+ return runtime.NameDocker
}
func (r *DockerRuntime) HealthCheck(ctx context.Context) error {
diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go
index 546e6ab075..1921ad0fd5 100644
--- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go
+++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go
@@ -7,6 +7,7 @@ import (
"go.uber.org/zap"
+ "github.com/kandev/kandev/internal/agent/runtime"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -30,8 +31,8 @@ func NewStandaloneRuntime(ctl *agentctl.ControlClient, host string, port int, lo
}
}
-func (r *StandaloneRuntime) Name() string {
- return "standalone"
+func (r *StandaloneRuntime) Name() runtime.Name {
+ return runtime.NameStandalone
}
func (r *StandaloneRuntime) HealthCheck(ctx context.Context) error {
diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go
index 21d0d03fee..879c9ab309 100644
--- a/apps/backend/internal/agent/lifecycle/session.go
+++ b/apps/backend/internal/agent/lifecycle/session.go
@@ -8,9 +8,10 @@ import (
"go.uber.org/zap"
- agentctl "github.com/kandev/kandev/internal/agentctl/client"
- "github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/agent/registry"
+ agentctl "github.com/kandev/kandev/internal/agentctl/client"
+ agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
+ "github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -58,6 +59,7 @@ func (sm *SessionManager) InitializeSession(
agentConfig *registry.AgentTypeConfig,
existingSessionID string,
workspacePath string,
+ mcpServers []agentctltypes.McpServer,
) (*InitializeResult, error) {
sm.logger.Info("initializing ACP session",
zap.String("agent_type", agentConfig.ID),
@@ -92,7 +94,7 @@ func (sm *SessionManager) InitializeSession(
zap.String("agent_version", result.AgentVersion))
// Step 2: Create or resume ACP session based on configuration
- sessionID, err := sm.createOrLoadSession(ctx, client, agentConfig, existingSessionID, workspacePath)
+ sessionID, err := sm.createOrLoadSession(ctx, client, agentConfig, existingSessionID, workspacePath, mcpServers)
if err != nil {
return nil, err
}
@@ -108,11 +110,12 @@ func (sm *SessionManager) createOrLoadSession(
agentConfig *registry.AgentTypeConfig,
existingSessionID string,
workspacePath string,
+ mcpServers []agentctltypes.McpServer,
) (string, error) {
if agentConfig.SessionConfig.ResumeViaACP && existingSessionID != "" {
return sm.loadSession(ctx, client, agentConfig, existingSessionID)
}
- return sm.createNewSession(ctx, client, agentConfig, workspacePath)
+ return sm.createNewSession(ctx, client, agentConfig, workspacePath, mcpServers)
}
// loadSession loads an existing session via ACP session/load
@@ -147,12 +150,13 @@ func (sm *SessionManager) createNewSession(
client *agentctl.Client,
agentConfig *registry.AgentTypeConfig,
workspacePath string,
+ mcpServers []agentctltypes.McpServer,
) (string, error) {
sm.logger.Info("sending ACP session/new request",
zap.String("agent_type", agentConfig.ID),
zap.String("workspace_path", workspacePath))
- sessionID, err := client.NewSession(ctx, workspacePath)
+ sessionID, err := client.NewSession(ctx, workspacePath, mcpServers)
if err != nil {
sm.logger.Error("ACP session/new failed",
zap.String("agent_type", agentConfig.ID),
@@ -182,6 +186,7 @@ func (sm *SessionManager) InitializeAndPrompt(
execution *AgentExecution,
agentConfig *registry.AgentTypeConfig,
taskDescription string,
+ mcpServers []agentctltypes.McpServer,
markReady func(executionID string) error,
) error {
sm.logger.Info("initializing ACP session",
@@ -198,6 +203,7 @@ func (sm *SessionManager) InitializeAndPrompt(
agentConfig,
execution.ACPSessionID,
execution.WorkspacePath,
+ mcpServers,
)
if err != nil {
sm.logger.Error("session initialization failed",
diff --git a/apps/backend/internal/agent/lifecycle/types.go b/apps/backend/internal/agent/lifecycle/types.go
index 06a90ec569..6839a74a20 100644
--- a/apps/backend/internal/agent/lifecycle/types.go
+++ b/apps/backend/internal/agent/lifecycle/types.go
@@ -8,6 +8,7 @@ import (
"sync"
"time"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -113,6 +114,11 @@ type ProfileResolver interface {
ResolveProfile(ctx context.Context, profileID string) (*AgentProfileInfo, error)
}
+// McpConfigProvider returns MCP configuration for a given agent profile ID.
+type McpConfigProvider interface {
+ GetConfigByProfileID(ctx context.Context, profileID string) (*mcpconfig.ProfileConfig, error)
+}
+
// WorkspaceInfo contains information about a task's workspace for on-demand execution creation
type WorkspaceInfo struct {
TaskID string
diff --git a/apps/backend/internal/agent/mcpconfig/acp.go b/apps/backend/internal/agent/mcpconfig/acp.go
new file mode 100644
index 0000000000..dfe4e1b4f5
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/acp.go
@@ -0,0 +1,20 @@
+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.
+func ToACPServers(resolved []ResolvedServer) []types.McpServer {
+ servers := make([]types.McpServer, 0, len(resolved))
+ for _, server := range resolved {
+ if server.Type != ServerTypeStdio {
+ continue
+ }
+ 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/mcpconfig/executor_policy.go b/apps/backend/internal/agent/mcpconfig/executor_policy.go
new file mode 100644
index 0000000000..e9f151ace9
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/executor_policy.go
@@ -0,0 +1,102 @@
+package mcpconfig
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// ExecutorPolicyConfig is a JSON-serializable policy override stored in executor config or metadata.
+type ExecutorPolicyConfig struct {
+ AllowStdio *bool `json:"allow_stdio,omitempty"`
+ AllowHTTP *bool `json:"allow_http,omitempty"`
+ AllowSSE *bool `json:"allow_sse,omitempty"`
+ AllowStreamableHTTP *bool `json:"allow_streamable_http,omitempty"`
+ URLRewrite map[string]string `json:"url_rewrite,omitempty"`
+ EnvInjection map[string]string `json:"env_injection,omitempty"`
+ AllowlistServers []string `json:"allowlist_servers,omitempty"`
+ DenylistServers []string `json:"denylist_servers,omitempty"`
+}
+
+// ApplyExecutorPolicy overlays a policy override onto the base policy.
+// The override can be a JSON string, map, or structured config.
+func ApplyExecutorPolicy(base Policy, value any) (Policy, []string, error) {
+ if value == nil {
+ return base, nil, nil
+ }
+
+ override, err := parseExecutorPolicy(value)
+ if err != nil {
+ return base, nil, err
+ }
+ if override == nil {
+ return base, nil, nil
+ }
+
+ warnings := []string{}
+ if override.AllowStdio != nil {
+ base.AllowStdio = *override.AllowStdio
+ }
+ if override.AllowHTTP != nil {
+ base.AllowHTTP = *override.AllowHTTP
+ }
+ if override.AllowSSE != nil {
+ base.AllowSSE = *override.AllowSSE
+ }
+ if override.AllowStreamableHTTP != nil {
+ base.AllowStreamableHTTP = *override.AllowStreamableHTTP
+ }
+ if override.URLRewrite != nil {
+ base.URLRewrite = override.URLRewrite
+ }
+ if override.EnvInjection != nil {
+ base.EnvInjection = override.EnvInjection
+ }
+ if len(override.AllowlistServers) > 0 {
+ base.AllowlistServers = append([]string{}, override.AllowlistServers...)
+ }
+ if len(override.DenylistServers) > 0 {
+ base.DenylistServers = append([]string{}, override.DenylistServers...)
+ }
+ if len(base.AllowlistServers) > 0 && len(base.DenylistServers) > 0 {
+ warnings = append(warnings, "mcp policy: allowlist and denylist both set; allowlist takes precedence")
+ }
+
+ return base, warnings, nil
+}
+
+func parseExecutorPolicy(value any) (*ExecutorPolicyConfig, error) {
+ switch v := value.(type) {
+ case ExecutorPolicyConfig:
+ return &v, nil
+ case *ExecutorPolicyConfig:
+ return v, nil
+ case json.RawMessage:
+ return parseExecutorPolicyJSON(v)
+ case string:
+ if strings.TrimSpace(v) == "" {
+ return nil, nil
+ }
+ return parseExecutorPolicyJSON([]byte(v))
+ case map[string]interface{}:
+ return parseExecutorPolicyMap(v)
+ default:
+ return nil, fmt.Errorf("unsupported mcp policy value type %T", value)
+ }
+}
+
+func parseExecutorPolicyJSON(payload []byte) (*ExecutorPolicyConfig, error) {
+ var cfg ExecutorPolicyConfig
+ if err := json.Unmarshal(payload, &cfg); err != nil {
+ return nil, fmt.Errorf("invalid mcp policy JSON: %w", err)
+ }
+ return &cfg, nil
+}
+
+func parseExecutorPolicyMap(payload map[string]any) (*ExecutorPolicyConfig, error) {
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("invalid mcp policy map: %w", err)
+ }
+ return parseExecutorPolicyJSON(encoded)
+}
diff --git a/apps/backend/internal/agent/mcpconfig/policy.go b/apps/backend/internal/agent/mcpconfig/policy.go
new file mode 100644
index 0000000000..fe096ecb6c
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/policy.go
@@ -0,0 +1,33 @@
+package mcpconfig
+
+import "github.com/kandev/kandev/internal/agent/runtime"
+
+// DefaultPolicyForRuntime returns the baseline MCP policy for a runtime.
+// We only enable local runtimes today; non-local runtimes default to deny-all
+// until we add explicit executor policies for their networks.
+func DefaultPolicyForRuntime(runtimeName runtime.Name) Policy {
+ switch runtimeName {
+ case runtime.NameLocal, runtime.NameStandalone, runtime.NameDocker:
+ return Policy{
+ AllowStdio: true,
+ AllowHTTP: true,
+ AllowSSE: true,
+ AllowStreamableHTTP: true,
+ URLRewrite: map[string]string{},
+ EnvInjection: map[string]string{},
+ AllowlistServers: nil,
+ DenylistServers: nil,
+ }
+ default:
+ return Policy{
+ AllowStdio: false,
+ AllowHTTP: false,
+ AllowSSE: false,
+ AllowStreamableHTTP: false,
+ URLRewrite: map[string]string{},
+ EnvInjection: map[string]string{},
+ AllowlistServers: nil,
+ DenylistServers: nil,
+ }
+ }
+}
diff --git a/apps/backend/internal/agent/mcpconfig/resolve.go b/apps/backend/internal/agent/mcpconfig/resolve.go
new file mode 100644
index 0000000000..d148d1df7e
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/resolve.go
@@ -0,0 +1,144 @@
+package mcpconfig
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Resolve converts the stored config into a list of runtime-resolved servers based on policy.
+// It returns warnings for skipped servers and a fatal error for invalid configurations.
+func Resolve(config *ProfileConfig, policy Policy) ([]ResolvedServer, []string, error) {
+ if config == nil || !config.Enabled {
+ return nil, nil, nil
+ }
+
+ warnings := []string{}
+ resolved := make([]ResolvedServer, 0, len(config.Servers))
+
+ for name, server := range config.Servers {
+ if !policyAllowsServerName(policy, name) {
+ warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: server not allowed by policy", name))
+ continue
+ }
+
+ serverType := normalizeServerType(server)
+ if serverType == "" {
+ warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: missing type", name))
+ continue
+ }
+
+ mode := server.Mode
+ if mode == "" || mode == ServerModeAuto {
+ if serverType == ServerTypeStdio {
+ mode = ServerModePerSession
+ } else {
+ mode = ServerModeShared
+ }
+ }
+
+ if serverType == ServerTypeStdio && mode == ServerModeShared {
+ return nil, warnings, fmt.Errorf("mcp server %q: shared mode requires HTTP/SSE/streamable HTTP transport (stdio is per-session only)", name)
+ }
+
+ if !policyAllows(policy, serverType) {
+ warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: transport %q not allowed", name, serverType))
+ continue
+ }
+
+ if serverType == ServerTypeStdio && strings.TrimSpace(server.Command) == "" {
+ warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: stdio server missing command", name))
+ continue
+ }
+
+ if serverType != ServerTypeStdio && strings.TrimSpace(server.URL) == "" {
+ warnings = append(warnings, fmt.Sprintf("mcp server %q skipped: http server missing url", name))
+ continue
+ }
+
+ env := map[string]string{}
+ for k, v := range policy.EnvInjection {
+ env[k] = v
+ }
+ for k, v := range server.Env {
+ env[k] = v
+ }
+
+ url := server.URL
+ if url != "" {
+ if rewritten, ok := policy.URLRewrite[url]; ok {
+ url = rewritten
+ }
+ }
+
+ resolved = append(resolved, ResolvedServer{
+ Name: name,
+ Type: serverType,
+ Mode: mode,
+ Command: server.Command,
+ Args: append([]string{}, server.Args...),
+ Env: env,
+ URL: url,
+ Headers: cloneStringMap(server.Headers),
+ })
+ }
+
+ return resolved, warnings, nil
+}
+
+func normalizeServerType(server ServerDef) ServerType {
+ if server.Type != "" {
+ return server.Type
+ }
+ if strings.TrimSpace(server.Command) != "" {
+ return ServerTypeStdio
+ }
+ if strings.TrimSpace(server.URL) != "" {
+ return ServerTypeHTTP
+ }
+ return ""
+}
+
+func policyAllows(policy Policy, serverType ServerType) bool {
+ switch serverType {
+ case ServerTypeStdio:
+ return policy.AllowStdio
+ case ServerTypeHTTP:
+ return policy.AllowHTTP
+ case ServerTypeSSE:
+ return policy.AllowSSE
+ case ServerTypeStreamableHTTP:
+ return policy.AllowStreamableHTTP
+ default:
+ return false
+ }
+}
+
+func policyAllowsServerName(policy Policy, name string) bool {
+ if len(policy.AllowlistServers) > 0 && !containsString(policy.AllowlistServers, name) {
+ return false
+ }
+ if len(policy.DenylistServers) > 0 && containsString(policy.DenylistServers, name) {
+ return false
+ }
+ return true
+}
+
+func containsString(list []string, value string) bool {
+ for _, item := range list {
+ if item == value {
+ return true
+ }
+ }
+ return false
+}
+
+func cloneStringMap(src map[string]string) map[string]string {
+ if src == nil {
+ return nil
+ }
+ out := make(map[string]string, len(src))
+ for k, v := range src {
+ out[k] = v
+ }
+ return out
+}
diff --git a/apps/backend/internal/agent/mcpconfig/service.go b/apps/backend/internal/agent/mcpconfig/service.go
new file mode 100644
index 0000000000..0a844eb62e
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/service.go
@@ -0,0 +1,274 @@
+package mcpconfig
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/kandev/kandev/internal/agent/settings/models"
+)
+
+// Repository defines the storage requirements for MCP config access.
+type Repository interface {
+ GetAgent(ctx context.Context, id string) (*models.Agent, error)
+ GetAgentProfile(ctx context.Context, id string) (*models.AgentProfile, error)
+ GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error)
+ UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error
+}
+
+var (
+ ErrAgentNotFound = errors.New("agent not found")
+ ErrAgentProfileNotFound = errors.New("agent profile not found")
+ ErrAgentMcpUnsupported = errors.New("mcp not supported by agent")
+)
+
+// Service provides MCP configuration access and validation.
+type Service struct {
+ repo Repository
+}
+
+func NewService(repo Repository) *Service {
+ return &Service{repo: repo}
+}
+
+func (s *Service) GetConfigByProfileID(ctx context.Context, profileID string) (*ProfileConfig, error) {
+ profile, err := s.repo.GetAgentProfile(ctx, profileID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAgentProfileNotFound
+ }
+ return nil, err
+ }
+ if profile == nil {
+ return nil, ErrAgentProfileNotFound
+ }
+
+ agent, err := s.repo.GetAgent(ctx, profile.AgentID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAgentNotFound
+ }
+ return nil, err
+ }
+ if agent == nil {
+ return nil, ErrAgentNotFound
+ }
+ if !agent.SupportsMCP {
+ return nil, ErrAgentMcpUnsupported
+ }
+
+ config, err := s.repo.GetAgentProfileMcpConfig(ctx, profile.ID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return &ProfileConfig{
+ ProfileID: profile.ID,
+ ProfileName: profile.Name,
+ AgentID: agent.ID,
+ AgentName: agent.Name,
+ Enabled: false,
+ Servers: map[string]ServerDef{},
+ Meta: map[string]any{},
+ }, nil
+ }
+ return nil, err
+ }
+
+ return &ProfileConfig{
+ ProfileID: profile.ID,
+ ProfileName: profile.Name,
+ AgentID: agent.ID,
+ AgentName: agent.Name,
+ Enabled: config.Enabled,
+ Servers: castServerDefs(config.Servers),
+ Meta: config.Meta,
+ }, nil
+}
+
+func (s *Service) UpsertConfigByProfileID(ctx context.Context, profileID string, config *ProfileConfig) (*ProfileConfig, error) {
+ profile, err := s.repo.GetAgentProfile(ctx, profileID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAgentProfileNotFound
+ }
+ return nil, err
+ }
+ if profile == nil {
+ return nil, ErrAgentProfileNotFound
+ }
+
+ agent, err := s.repo.GetAgent(ctx, profile.AgentID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAgentNotFound
+ }
+ return nil, err
+ }
+ if agent == nil {
+ return nil, ErrAgentNotFound
+ }
+ if !agent.SupportsMCP {
+ return nil, ErrAgentMcpUnsupported
+ }
+ if config == nil {
+ config = &ProfileConfig{}
+ }
+
+ record := &models.AgentProfileMcpConfig{
+ ProfileID: profile.ID,
+ Enabled: config.Enabled,
+ Servers: castServerInterfaces(config.Servers),
+ Meta: castMetaInterfaces(config.Meta),
+ }
+ if err := s.repo.UpsertAgentProfileMcpConfig(ctx, record); err != nil {
+ return nil, err
+ }
+
+ return &ProfileConfig{
+ ProfileID: profile.ID,
+ ProfileName: profile.Name,
+ AgentID: agent.ID,
+ AgentName: agent.Name,
+ Enabled: record.Enabled,
+ Servers: config.Servers,
+ Meta: config.Meta,
+ }, nil
+}
+
+func castServerDefs(raw map[string]interface{}) map[string]ServerDef {
+ if raw == nil {
+ return map[string]ServerDef{}
+ }
+ servers := make(map[string]ServerDef, len(raw))
+ for key, value := range raw {
+ switch v := value.(type) {
+ case ServerDef:
+ servers[key] = v
+ case map[string]interface{}:
+ servers[key] = mapToServerDef(v)
+ default:
+ // ignore invalid types
+ }
+ }
+ return servers
+}
+
+func castServerInterfaces(raw map[string]ServerDef) map[string]interface{} {
+ if raw == nil {
+ return map[string]interface{}{}
+ }
+ servers := make(map[string]interface{}, len(raw))
+ for key, value := range raw {
+ servers[key] = serverDefToMap(value)
+ }
+ return servers
+}
+
+func castMetaInterfaces(raw map[string]any) map[string]interface{} {
+ if raw == nil {
+ return map[string]interface{}{}
+ }
+ meta := make(map[string]interface{}, len(raw))
+ for k, v := range raw {
+ meta[k] = v
+ }
+ return meta
+}
+
+func mapToServerDef(value map[string]interface{}) ServerDef {
+ def := ServerDef{}
+ if v, ok := value["type"].(string); ok {
+ def.Type = ServerType(v)
+ }
+ if v, ok := value["command"].(string); ok {
+ def.Command = v
+ }
+ if v, ok := value["args"].([]interface{}); ok {
+ def.Args = interfaceSliceToStrings(v)
+ }
+ if v, ok := value["args"].([]string); ok {
+ def.Args = append([]string{}, v...)
+ }
+ if v, ok := value["url"].(string); ok {
+ def.URL = v
+ }
+ if v, ok := value["mode"].(string); ok {
+ def.Mode = ServerMode(v)
+ }
+ if v, ok := value["env"].(map[string]interface{}); ok {
+ def.Env = interfaceMapToStringMap(v)
+ }
+ if v, ok := value["headers"].(map[string]interface{}); ok {
+ def.Headers = interfaceMapToStringMap(v)
+ }
+ if v, ok := value["meta"].(map[string]interface{}); ok {
+ def.Meta = v
+ }
+ if v, ok := value["extra"].(map[string]interface{}); ok {
+ def.Extra = v
+ }
+ return def
+}
+
+func serverDefToMap(def ServerDef) map[string]interface{} {
+ out := map[string]interface{}{}
+ if def.Type != "" {
+ out["type"] = string(def.Type)
+ }
+ if def.Command != "" {
+ out["command"] = def.Command
+ }
+ if len(def.Args) > 0 {
+ args := make([]interface{}, 0, len(def.Args))
+ for _, arg := range def.Args {
+ args = append(args, arg)
+ }
+ out["args"] = args
+ }
+ if def.URL != "" {
+ out["url"] = def.URL
+ }
+ if def.Mode != "" {
+ out["mode"] = string(def.Mode)
+ }
+ if len(def.Env) > 0 {
+ out["env"] = stringMapToInterfaceMap(def.Env)
+ }
+ if len(def.Headers) > 0 {
+ out["headers"] = stringMapToInterfaceMap(def.Headers)
+ }
+ if len(def.Meta) > 0 {
+ out["meta"] = def.Meta
+ }
+ if len(def.Extra) > 0 {
+ out["extra"] = def.Extra
+ }
+ return out
+}
+
+func interfaceSliceToStrings(raw []interface{}) []string {
+ result := make([]string, 0, len(raw))
+ for _, item := range raw {
+ if s, ok := item.(string); ok {
+ result = append(result, s)
+ }
+ }
+ return result
+}
+
+func interfaceMapToStringMap(raw map[string]interface{}) map[string]string {
+ result := make(map[string]string, len(raw))
+ for k, v := range raw {
+ if s, ok := v.(string); ok {
+ result[k] = s
+ }
+ }
+ return result
+}
+
+func stringMapToInterfaceMap(raw map[string]string) map[string]interface{} {
+ result := make(map[string]interface{}, len(raw))
+ for k, v := range raw {
+ result[k] = v
+ }
+ return result
+}
diff --git a/apps/backend/internal/agent/mcpconfig/types.go b/apps/backend/internal/agent/mcpconfig/types.go
new file mode 100644
index 0000000000..9b0454f693
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/types.go
@@ -0,0 +1,63 @@
+package mcpconfig
+
+type ServerType string
+
+type ServerMode string
+
+const (
+ ServerTypeStdio ServerType = "stdio"
+ ServerTypeHTTP ServerType = "http"
+ ServerTypeSSE ServerType = "sse"
+ ServerTypeStreamableHTTP ServerType = "streamable_http"
+)
+
+const (
+ ServerModeAuto ServerMode = "auto"
+ ServerModeShared ServerMode = "shared"
+ ServerModePerSession ServerMode = "per_session"
+)
+
+type ServerDef struct {
+ Type ServerType `json:"type,omitempty"`
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+ URL string `json:"url,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Mode ServerMode `json:"mode,omitempty"`
+ Meta map[string]any `json:"meta,omitempty"`
+ Extra map[string]any `json:"extra,omitempty"`
+}
+
+type ProfileConfig struct {
+ ProfileID string `json:"profile_id"`
+ ProfileName string `json:"profile_name,omitempty"`
+ AgentID string `json:"agent_id,omitempty"`
+ AgentName string `json:"agent_name,omitempty"`
+ Enabled bool `json:"enabled"`
+ Servers map[string]ServerDef `json:"servers"`
+ Meta map[string]any `json:"meta,omitempty"`
+}
+
+type ResolvedServer struct {
+ Name string
+ Type ServerType
+ Mode ServerMode
+ Command string
+ Args []string
+ Env map[string]string
+ URL string
+ Headers map[string]string
+}
+
+// Policy controls which MCP transports are allowed and how they should be rewritten.
+type Policy struct {
+ AllowStdio bool
+ AllowHTTP bool
+ AllowSSE bool
+ AllowStreamableHTTP bool
+ URLRewrite map[string]string
+ EnvInjection map[string]string
+ AllowlistServers []string
+ DenylistServers []string
+}
diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json
index 7c7a80ea41..c5e1945f16 100644
--- a/apps/backend/internal/agent/registry/agents.json
+++ b/apps/backend/internal/agent/registry/agents.json
@@ -44,12 +44,7 @@
]
},
"discovery": {
- "supports_mcp": false,
- "mcp_config_path": {
- "linux": [],
- "windows": [],
- "macos": []
- },
+ "supports_mcp": true,
"installation_path": {
"linux": ["~/.augment/.auggie.json"],
"windows": ["~/.augment/.auggie.json"],
@@ -147,11 +142,6 @@
},
"discovery": {
"supports_mcp": false,
- "mcp_config_path": {
- "linux": [],
- "windows": [],
- "macos": []
- },
"installation_path": {
"linux": ["~/.codex/auth.json"],
"windows": ["~/.codex/auth.json"],
@@ -199,4 +189,3 @@
}
]
}
-
diff --git a/apps/backend/internal/agent/runtime/runtime.go b/apps/backend/internal/agent/runtime/runtime.go
new file mode 100644
index 0000000000..f6b7bc9490
--- /dev/null
+++ b/apps/backend/internal/agent/runtime/runtime.go
@@ -0,0 +1,12 @@
+// Package runtime defines the agent runtime types shared across lifecycle and policy logic.
+package runtime
+
+// Name identifies the execution runtime.
+type Name string
+
+const (
+ NameUnknown Name = ""
+ NameDocker Name = "docker"
+ NameStandalone Name = "standalone"
+ NameLocal Name = "local"
+)
diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go
index e9728287af..b5cdc5e145 100644
--- a/apps/backend/internal/agent/settings/controller/controller.go
+++ b/apps/backend/internal/agent/settings/controller/controller.go
@@ -9,6 +9,7 @@ import (
"time"
"github.com/kandev/kandev/internal/agent/discovery"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/agent/settings/store"
@@ -20,6 +21,7 @@ var (
ErrAgentNotFound = errors.New("agent not found")
ErrAgentProfileNotFound = errors.New("agent profile not found")
ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session")
+ ErrAgentMcpUnsupported = errors.New("mcp not supported by agent")
)
const defaultAgentProfileName = "default"
@@ -28,6 +30,7 @@ type Controller struct {
repo store.Repository
discovery *discovery.Registry
sessionChecker SessionChecker
+ mcpService *mcpconfig.Service
logger *logger.Logger
}
@@ -40,6 +43,7 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry,
repo: repo,
discovery: discoveryRegistry,
sessionChecker: sessionChecker,
+ mcpService: mcpconfig.NewService(repo),
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
}
}
@@ -167,6 +171,21 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
if err := c.repo.CreateAgent(ctx, agent); err != nil {
return err
}
+ } else {
+ updated := false
+ if agent.SupportsMCP != result.SupportsMCP {
+ agent.SupportsMCP = result.SupportsMCP
+ updated = true
+ }
+ if agent.MCPConfigPath != result.MCPConfigPath {
+ agent.MCPConfigPath = result.MCPConfigPath
+ updated = true
+ }
+ if updated {
+ if err := c.repo.UpdateAgent(ctx, agent); err != nil {
+ return err
+ }
+ }
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
@@ -205,6 +224,54 @@ func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, er
return &result, nil
}
+type UpdateAgentProfileMcpConfigRequest struct {
+ Enabled bool
+ Servers map[string]mcpconfig.ServerDef
+ Meta map[string]any
+}
+
+func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) {
+ config, err := c.mcpService.GetConfigByProfileID(ctx, profileID)
+ if err != nil {
+ if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
+ return nil, ErrAgentProfileNotFound
+ }
+ if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
+ return nil, ErrAgentMcpUnsupported
+ }
+ return nil, err
+ }
+ return &dto.AgentProfileMcpConfigDTO{
+ ProfileID: config.ProfileID,
+ Enabled: config.Enabled,
+ Servers: config.Servers,
+ Meta: config.Meta,
+ }, nil
+}
+
+func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigRequest) (*dto.AgentProfileMcpConfigDTO, error) {
+ config, err := c.mcpService.UpsertConfigByProfileID(ctx, profileID, &mcpconfig.ProfileConfig{
+ Enabled: req.Enabled,
+ Servers: req.Servers,
+ Meta: req.Meta,
+ })
+ if err != nil {
+ if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
+ return nil, ErrAgentProfileNotFound
+ }
+ if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
+ return nil, ErrAgentMcpUnsupported
+ }
+ return nil, err
+ }
+ return &dto.AgentProfileMcpConfigDTO{
+ ProfileID: config.ProfileID,
+ Enabled: config.Enabled,
+ Servers: config.Servers,
+ Meta: config.Meta,
+ }, nil
+}
+
type CreateAgentRequest struct {
Name string
WorkspaceID *string
diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go
index 719cce495d..72cb988692 100644
--- a/apps/backend/internal/agent/settings/dto/dto.go
+++ b/apps/backend/internal/agent/settings/dto/dto.go
@@ -1,6 +1,10 @@
package dto
-import "time"
+import (
+ "time"
+
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
+)
type AgentProfileDTO struct {
ID string `json:"id"`
@@ -81,3 +85,10 @@ type ListAvailableAgentsResponse struct {
Agents []AvailableAgentDTO `json:"agents"`
Total int `json:"total"`
}
+
+type AgentProfileMcpConfigDTO struct {
+ ProfileID string `json:"profile_id"`
+ Enabled bool `json:"enabled"`
+ Servers map[string]mcpconfig.ServerDef `json:"servers"`
+ Meta map[string]any `json:"meta,omitempty"`
+}
diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go
index cd0b83fdeb..aefea9f138 100644
--- a/apps/backend/internal/agent/settings/handlers/handlers.go
+++ b/apps/backend/internal/agent/settings/handlers/handlers.go
@@ -2,9 +2,11 @@ package handlers
import (
"net/http"
+ "strings"
"github.com/gin-gonic/gin"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/controller"
"github.com/kandev/kandev/internal/common/logger"
ws "github.com/kandev/kandev/pkg/websocket"
@@ -46,6 +48,8 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api.POST("/agents/:id/profiles", h.httpCreateProfile)
api.PATCH("/agent-profiles/:id", h.httpUpdateProfile)
api.DELETE("/agent-profiles/:id", h.httpDeleteProfile)
+ api.GET("/agent-profiles/:id/mcp-config", h.httpGetProfileMcpConfig)
+ api.POST("/agent-profiles/:id/mcp-config", h.httpUpdateProfileMcpConfig)
}
func (h *Handlers) httpDiscoverAgents(c *gin.Context) {
@@ -115,6 +119,10 @@ func (h *Handlers) httpCreateAgent(c *gin.Context) {
}
profiles := make([]controller.CreateAgentProfileRequest, 0, len(body.Profiles))
for _, profile := range body.Profiles {
+ if strings.TrimSpace(profile.Name) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
+ return
+ }
profiles = append(profiles, controller.CreateAgentProfileRequest{
Name: profile.Name,
Model: profile.Model,
@@ -193,6 +201,82 @@ func (h *Handlers) httpDeleteAgent(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
}
+type updateProfileMcpConfigRequest struct {
+ Enabled *bool `json:"enabled"`
+ Servers map[string]mcpconfig.ServerDef `json:"servers"`
+ MCPServers map[string]mcpconfig.ServerDef `json:"mcpServers"`
+ Meta map[string]any `json:"meta,omitempty"`
+}
+
+func (h *Handlers) httpGetProfileMcpConfig(c *gin.Context) {
+ profileID := c.Param("id")
+ if profileID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
+ return
+ }
+
+ resp, err := h.controller.GetAgentProfileMcpConfig(c.Request.Context(), profileID)
+ if err != nil {
+ if err == controller.ErrAgentProfileNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
+ return
+ }
+ if err == controller.ErrAgentMcpUnsupported {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "mcp not supported by agent"})
+ return
+ }
+ h.logger.Error("failed to get mcp config", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get mcp config"})
+ return
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
+func (h *Handlers) httpUpdateProfileMcpConfig(c *gin.Context) {
+ profileID := c.Param("id")
+ if profileID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "profile id is required"})
+ return
+ }
+
+ var body updateProfileMcpConfigRequest
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
+ return
+ }
+ if body.Enabled == nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"})
+ return
+ }
+ servers := body.Servers
+ if len(servers) == 0 && len(body.MCPServers) > 0 {
+ servers = body.MCPServers
+ }
+ if servers == nil {
+ servers = map[string]mcpconfig.ServerDef{}
+ }
+
+ resp, err := h.controller.UpdateAgentProfileMcpConfig(c.Request.Context(), profileID, controller.UpdateAgentProfileMcpConfigRequest{
+ Enabled: *body.Enabled,
+ Servers: servers,
+ Meta: body.Meta,
+ })
+ if err != nil {
+ if err == controller.ErrAgentProfileNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "agent profile not found"})
+ return
+ }
+ if err == controller.ErrAgentMcpUnsupported {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "mcp not supported by agent"})
+ return
+ }
+ h.logger.Error("failed to update mcp config", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update mcp config"})
+ return
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
type createProfileRequest struct {
Name string `json:"name"`
Model string `json:"model"`
@@ -207,6 +291,10 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
+ if strings.TrimSpace(body.Name) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
+ return
+ }
resp, err := h.controller.CreateProfile(c.Request.Context(), controller.CreateProfileRequest{
AgentID: c.Param("id"),
Name: body.Name,
@@ -243,6 +331,10 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
+ if body.Name != nil && strings.TrimSpace(*body.Name) == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "profile name is required"})
+ return
+ }
resp, err := h.controller.UpdateProfile(c.Request.Context(), controller.UpdateProfileRequest{
ID: c.Param("id"),
Name: body.Name,
diff --git a/apps/backend/internal/agent/settings/models/mcp.go b/apps/backend/internal/agent/settings/models/mcp.go
new file mode 100644
index 0000000000..a44dc3976d
--- /dev/null
+++ b/apps/backend/internal/agent/settings/models/mcp.go
@@ -0,0 +1,13 @@
+package models
+
+import "time"
+
+// AgentProfileMcpConfig stores MCP configuration for a specific agent profile.
+type AgentProfileMcpConfig struct {
+ ProfileID string `json:"profile_id"`
+ Enabled bool `json:"enabled"`
+ Servers map[string]interface{} `json:"servers"`
+ Meta map[string]interface{} `json:"meta,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go
index 61dfd6d0ba..64bc7f4a4b 100644
--- a/apps/backend/internal/agent/settings/store/sqlite.go
+++ b/apps/backend/internal/agent/settings/store/sqlite.go
@@ -3,6 +3,7 @@ package store
import (
"context"
"database/sql"
+ "encoding/json"
"fmt"
"time"
@@ -62,6 +63,16 @@ func (r *sqliteRepository) initSchema() error {
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
);
+ CREATE TABLE IF NOT EXISTS agent_profile_mcp_configs (
+ profile_id TEXT PRIMARY KEY,
+ enabled INTEGER NOT NULL DEFAULT 0,
+ servers_json TEXT NOT NULL DEFAULT '{}',
+ meta_json TEXT NOT NULL DEFAULT '{}',
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ FOREIGN KEY (profile_id) REFERENCES agent_profiles(id) ON DELETE CASCADE
+ );
+
DROP INDEX IF EXISTS idx_agents_name;
CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_name ON agents(name);
CREATE INDEX IF NOT EXISTS idx_agent_profiles_agent_id ON agent_profiles(agent_id);
@@ -167,6 +178,67 @@ func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.Agent, err
return result, rows.Err()
}
+func (r *sqliteRepository) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error) {
+ row := r.db.QueryRowContext(ctx, `
+ SELECT profile_id, enabled, servers_json, meta_json, created_at, updated_at
+ FROM agent_profile_mcp_configs
+ WHERE profile_id = ?
+ `, profileID)
+
+ var config models.AgentProfileMcpConfig
+ var enabled int
+ var serversJSON string
+ var metaJSON string
+ if err := row.Scan(&config.ProfileID, &enabled, &serversJSON, &metaJSON, &config.CreatedAt, &config.UpdatedAt); err != nil {
+ return nil, err
+ }
+ config.Enabled = enabled == 1
+ if err := json.Unmarshal([]byte(serversJSON), &config.Servers); err != nil {
+ return nil, fmt.Errorf("failed to parse MCP servers JSON: %w", err)
+ }
+ if err := json.Unmarshal([]byte(metaJSON), &config.Meta); err != nil {
+ return nil, fmt.Errorf("failed to parse MCP meta JSON: %w", err)
+ }
+ return &config, nil
+}
+
+func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error {
+ if config.ProfileID == "" {
+ return fmt.Errorf("profile ID is required")
+ }
+ if config.Servers == nil {
+ config.Servers = map[string]interface{}{}
+ }
+ if config.Meta == nil {
+ config.Meta = map[string]interface{}{}
+ }
+ now := time.Now().UTC()
+ if config.CreatedAt.IsZero() {
+ config.CreatedAt = now
+ }
+ config.UpdatedAt = now
+
+ serversJSON, err := json.Marshal(config.Servers)
+ if err != nil {
+ return fmt.Errorf("failed to serialize MCP servers: %w", err)
+ }
+ metaJSON, err := json.Marshal(config.Meta)
+ if err != nil {
+ return fmt.Errorf("failed to serialize MCP meta: %w", err)
+ }
+
+ _, err = r.db.ExecContext(ctx, `
+ INSERT INTO agent_profile_mcp_configs (profile_id, enabled, servers_json, meta_json, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)
+ ON CONFLICT(profile_id) DO UPDATE SET
+ enabled = excluded.enabled,
+ servers_json = excluded.servers_json,
+ meta_json = excluded.meta_json,
+ updated_at = excluded.updated_at
+ `, config.ProfileID, boolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
+ return err
+}
+
func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error {
if profile.ID == "" {
profile.ID = uuid.New().String()
diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go
index 62584e0e00..e9791aaef3 100644
--- a/apps/backend/internal/agent/settings/store/store.go
+++ b/apps/backend/internal/agent/settings/store/store.go
@@ -14,6 +14,9 @@ type Repository interface {
DeleteAgent(ctx context.Context, id string) error
ListAgents(ctx context.Context) ([]*models.Agent, error)
+ GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*models.AgentProfileMcpConfig, error)
+ UpsertAgentProfileMcpConfig(ctx context.Context, config *models.AgentProfileMcpConfig) error
+
CreateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
UpdateAgentProfile(ctx context.Context, profile *models.AgentProfile) error
DeleteAgentProfile(ctx context.Context, id string) error
diff --git a/apps/backend/internal/agentctl/client/acp.go b/apps/backend/internal/agentctl/client/acp.go
index f9c5ecc94c..01755b8558 100644
--- a/apps/backend/internal/agentctl/client/acp.go
+++ b/apps/backend/internal/agentctl/client/acp.go
@@ -10,6 +10,7 @@ import (
"github.com/coder/acp-go-sdk"
"github.com/gorilla/websocket"
+ "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
)
@@ -94,10 +95,11 @@ type NewSessionResponse struct {
}
// NewSession creates a new ACP session
-func (c *Client) NewSession(ctx context.Context, cwd string) (string, error) {
+func (c *Client) NewSession(ctx context.Context, cwd string, mcpServers []types.McpServer) (string, error) {
reqBody := struct {
- Cwd string `json:"cwd"`
- }{Cwd: cwd}
+ Cwd string `json:"cwd"`
+ McpServers []types.McpServer `json:"mcp_servers,omitempty"`
+ }{Cwd: cwd, McpServers: mcpServers}
body, err := json.Marshal(reqBody)
if err != nil {
@@ -356,7 +358,6 @@ func (c *Client) Cancel(ctx context.Context) error {
}
return nil
}
-
// RespondToPermission sends a response to a permission request
func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID string, cancelled bool) error {
reqBody := PermissionRespondRequest{
@@ -404,5 +405,3 @@ func (c *Client) RespondToPermission(ctx context.Context, pendingID, optionID st
}
return nil
}
-
-
diff --git a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go
index 04886676c7..6f8412c94d 100644
--- a/apps/backend/internal/agentctl/server/adapter/acp_adapter.go
+++ b/apps/backend/internal/agentctl/server/adapter/acp_adapter.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/acp-go-sdk"
acpclient "github.com/kandev/kandev/internal/agentctl/server/acp"
+ "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
@@ -108,7 +109,7 @@ func (a *ACPAdapter) GetAgentInfo() *AgentInfo {
}
// NewSession creates a new agent session.
-func (a *ACPAdapter) NewSession(ctx context.Context) (string, error) {
+func (a *ACPAdapter) NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -118,7 +119,7 @@ func (a *ACPAdapter) NewSession(ctx context.Context) (string, error) {
resp, err := a.acpConn.NewSession(ctx, acp.NewSessionRequest{
Cwd: a.cfg.WorkDir,
- McpServers: []acp.McpServer{},
+ McpServers: toACPMcpServers(mcpServers),
})
if err != nil {
return "", fmt.Errorf("failed to create session: %w", err)
@@ -130,6 +131,23 @@ func (a *ACPAdapter) NewSession(ctx context.Context) (string, error) {
return a.sessionID, nil
}
+func toACPMcpServers(servers []types.McpServer) []acp.McpServer {
+ if len(servers) == 0 {
+ return []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...),
+ },
+ })
+ }
+ return out
+}
+
// LoadSession resumes an existing session.
func (a *ACPAdapter) LoadSession(ctx context.Context, sessionID string) error {
a.mu.Lock()
diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go
index eede0a154a..ab4225878f 100644
--- a/apps/backend/internal/agentctl/server/adapter/adapter.go
+++ b/apps/backend/internal/agentctl/server/adapter/adapter.go
@@ -27,14 +27,14 @@ type (
// Re-export agent event type constants from streams package.
const (
- EventTypeMessageChunk = streams.EventTypeMessageChunk
- EventTypeReasoning = streams.EventTypeReasoning
- EventTypeToolCall = streams.EventTypeToolCall
- EventTypeToolUpdate = streams.EventTypeToolUpdate
- EventTypePlan = streams.EventTypePlan
- EventTypeComplete = streams.EventTypeComplete
- EventTypeError = streams.EventTypeError
- EventTypePermissionRequest = streams.EventTypePermissionRequest
+ EventTypeMessageChunk = streams.EventTypeMessageChunk
+ EventTypeReasoning = streams.EventTypeReasoning
+ EventTypeToolCall = streams.EventTypeToolCall
+ EventTypeToolUpdate = streams.EventTypeToolUpdate
+ EventTypePlan = streams.EventTypePlan
+ EventTypeComplete = streams.EventTypeComplete
+ EventTypeError = streams.EventTypeError
+ EventTypePermissionRequest = streams.EventTypePermissionRequest
)
// AgentInfo contains information about the connected agent.
@@ -57,7 +57,7 @@ type AgentAdapter interface {
GetAgentInfo() *AgentInfo
// NewSession creates a new agent session and returns the session ID.
- NewSession(ctx context.Context) (string, error)
+ NewSession(ctx context.Context, mcpServers []types.McpServer) (string, error)
// LoadSession resumes an existing session by ID.
LoadSession(ctx context.Context, sessionID string) error
@@ -110,4 +110,3 @@ type Config struct {
// Protocol-specific configuration
Extra map[string]string
}
-
diff --git a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
index 221858b4aa..31dcba3799 100644
--- a/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
+++ b/apps/backend/internal/agentctl/server/adapter/codex_adapter.go
@@ -139,7 +139,7 @@ func (a *CodexAdapter) GetAgentInfo() *AgentInfo {
}
// NewSession creates a new Codex thread (session).
-func (a *CodexAdapter) NewSession(ctx context.Context) (string, error) {
+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
a.mu.RLock()
diff --git a/apps/backend/internal/agentctl/server/api/acp.go b/apps/backend/internal/agentctl/server/api/acp.go
index e20e1ac691..d132c7ee5d 100644
--- a/apps/backend/internal/agentctl/server/api/acp.go
+++ b/apps/backend/internal/agentctl/server/api/acp.go
@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
+ "github.com/kandev/kandev/internal/agentctl/types"
"go.uber.org/zap"
)
@@ -79,7 +80,8 @@ func (s *Server) handleACPInitialize(c *gin.Context) {
// NewSessionRequest is a request to create a new ACP session
type NewSessionRequest struct {
- Cwd string `json:"cwd"` // Working directory for the session
+ Cwd string `json:"cwd"` // Working directory for the session
+ McpServers []types.McpServer `json:"mcp_servers,omitempty"`
}
// NewSessionResponse is the response to a new session call
@@ -111,7 +113,7 @@ func (s *Server) handleACPNewSession(c *gin.Context) {
return
}
- sessionID, err := adapter.NewSession(ctx)
+ sessionID, err := adapter.NewSession(ctx, req.McpServers)
if err != nil {
s.logger.Error("new session failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, NewSessionResponse{
@@ -318,7 +320,6 @@ func (s *Server) handlePermissionRespond(c *gin.Context) {
Success: true,
})
}
-
// CancelResponse is the response from a cancel request.
type CancelResponse struct {
Success bool `json:"success"`
@@ -351,4 +352,3 @@ func (s *Server) handleACPCancel(c *gin.Context) {
s.logger.Info("cancel completed")
c.JSON(http.StatusOK, CancelResponse{Success: true})
}
-
diff --git a/apps/backend/internal/agentctl/types/types.go b/apps/backend/internal/agentctl/types/types.go
index 4a4684b93d..0b1ebe2772 100644
--- a/apps/backend/internal/agentctl/types/types.go
+++ b/apps/backend/internal/agentctl/types/types.go
@@ -115,6 +115,13 @@ 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.
+type McpServer struct {
+ Name string `json:"name"`
+ Command string `json:"command"`
+ Args []string `json:"args,omitempty"`
+}
+
const (
// Workspace stream message types
WorkspaceMessageTypeShellOutput WorkspaceMessageType = "shell_output"
diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go
index 7e18ccec86..7e82c38b7c 100644
--- a/apps/backend/internal/orchestrator/executor/executor.go
+++ b/apps/backend/internal/orchestrator/executor/executor.go
@@ -232,8 +232,8 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
AgentProfileID: agentProfileID,
TaskDescription: prompt, // Use the provided prompt
Priority: task.Priority,
- Metadata: task.Metadata,
}
+ metadata := cloneMetadata(task.Metadata)
var repositoryPath string
var repositoryID string
var baseBranch string
@@ -300,6 +300,10 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
StartedAt: now,
UpdatedAt: now,
}
+ if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" {
+ session.ExecutorID = executorID
+ metadata = e.applyExecutorMetadata(ctx, metadata, executorID)
+ }
if err := e.repo.CreateTaskSession(ctx, session); err != nil {
e.logger.Error("failed to persist agent session before launch",
@@ -309,6 +313,9 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
}
req.SessionID = sessionID
+ if len(metadata) > 0 {
+ req.Metadata = metadata
+ }
e.logger.Info("launching agent for task",
zap.String("task_id", task.ID),
@@ -486,6 +493,12 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreeID != "" {
metadata["worktree_id"] = session.Worktrees[0].WorktreeID
}
+ if session.ExecutorID == "" {
+ if executorID := e.defaultExecutorID(ctx, task.WorkspaceID); executorID != "" {
+ session.ExecutorID = executorID
+ }
+ }
+ metadata = e.applyExecutorMetadata(ctx, metadata, session.ExecutorID)
if len(metadata) > 0 {
req.Metadata = metadata
}
@@ -924,6 +937,46 @@ func (e *Executor) MarkCompletedBySession(ctx context.Context, sessionID string,
}
}
+func (e *Executor) defaultExecutorID(ctx context.Context, workspaceID string) string {
+ if workspaceID == "" {
+ return ""
+ }
+ workspace, err := e.repo.GetWorkspace(ctx, workspaceID)
+ if err != nil || workspace == nil || workspace.DefaultExecutorID == nil {
+ return ""
+ }
+ return strings.TrimSpace(*workspace.DefaultExecutorID)
+}
+
+func (e *Executor) applyExecutorMetadata(ctx context.Context, metadata map[string]interface{}, executorID string) map[string]interface{} {
+ if executorID == "" {
+ return metadata
+ }
+ if metadata == nil {
+ metadata = map[string]interface{}{}
+ }
+ metadata["executor_id"] = executorID
+ executor, err := e.repo.GetExecutor(ctx, executorID)
+ if err != nil || executor == nil {
+ return metadata
+ }
+ if policyJSON := strings.TrimSpace(executor.Config["mcp_policy"]); policyJSON != "" {
+ metadata["executor_mcp_policy"] = policyJSON
+ }
+ return metadata
+}
+
+func cloneMetadata(src map[string]interface{}) map[string]interface{} {
+ if len(src) == 0 {
+ return nil
+ }
+ out := make(map[string]interface{}, len(src))
+ for key, value := range src {
+ out[key] = value
+ }
+ return out
+}
+
// MockAgentManagerClient is a placeholder implementation
type MockAgentManagerClient struct {
logger *logger.Logger
diff --git a/apps/backend/internal/task/handlers/executor_handlers.go b/apps/backend/internal/task/handlers/executor_handlers.go
index 947d71c7c8..3c13ac1df3 100644
--- a/apps/backend/internal/task/handlers/executor_handlers.go
+++ b/apps/backend/internal/task/handlers/executor_handlers.go
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/task/controller"
"github.com/kandev/kandev/internal/task/dto"
"github.com/kandev/kandev/internal/task/models"
+ "github.com/kandev/kandev/internal/task/service"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -93,6 +94,10 @@ func (h *ExecutorHandlers) httpCreateExecutor(c *gin.Context) {
Config: body.Config,
})
if err != nil {
+ if errors.Is(err, service.ErrInvalidExecutorConfig) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
h.logger.Error("failed to create executor", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "executor not created"})
return
@@ -143,6 +148,10 @@ func (h *ExecutorHandlers) httpUpdateExecutor(c *gin.Context) {
Config: body.Config,
})
if err != nil {
+ if errors.Is(err, service.ErrInvalidExecutorConfig) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
h.logger.Error("failed to update executor", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "executor not updated"})
return
@@ -207,6 +216,9 @@ func (h *ExecutorHandlers) wsCreateExecutor(ctx context.Context, msg *ws.Message
Config: req.Config,
})
if err != nil {
+ if errors.Is(err, service.ErrInvalidExecutorConfig) {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
+ }
h.logger.Error("failed to create executor", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create executor", nil)
}
@@ -268,6 +280,9 @@ func (h *ExecutorHandlers) wsUpdateExecutor(ctx context.Context, msg *ws.Message
Config: req.Config,
})
if err != nil {
+ if errors.Is(err, service.ErrInvalidExecutorConfig) {
+ return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil)
+ }
h.logger.Error("failed to update executor", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update executor", nil)
}
diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go
index 368d733f92..795a831e2b 100644
--- a/apps/backend/internal/task/service/service.go
+++ b/apps/backend/internal/task/service/service.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"path/filepath"
@@ -31,8 +32,29 @@ type TaskExecutionStopper interface {
StopTask(ctx context.Context, taskID, reason string, force bool) error
}
-var ErrActiveTaskSessions = errors.New("active agent sessions exist")
-var ErrInvalidRepositorySettings = errors.New("invalid repository settings")
+var (
+ ErrActiveTaskSessions = errors.New("active agent sessions exist")
+ ErrInvalidRepositorySettings = errors.New("invalid repository settings")
+ ErrInvalidExecutorConfig = errors.New("invalid executor config")
+)
+
+func validateExecutorConfig(config map[string]string) error {
+ if config == nil {
+ return nil
+ }
+ policy := strings.TrimSpace(config["mcp_policy"])
+ if policy == "" {
+ return nil
+ }
+ var decoded any
+ if err := json.Unmarshal([]byte(policy), &decoded); err != nil {
+ return fmt.Errorf("%w: mcp_policy must be valid JSON", ErrInvalidExecutorConfig)
+ }
+ if _, ok := decoded.(map[string]any); !ok {
+ return fmt.Errorf("%w: mcp_policy must be a JSON object", ErrInvalidExecutorConfig)
+ }
+ return nil
+}
// Service provides task business logic
type Service struct {
@@ -1118,6 +1140,9 @@ func (s *Service) ListRepositoryScripts(ctx context.Context, repositoryID string
// Executor operations
func (s *Service) CreateExecutor(ctx context.Context, req *CreateExecutorRequest) (*models.Executor, error) {
+ if err := validateExecutorConfig(req.Config); err != nil {
+ return nil, err
+ }
executor := &models.Executor{
ID: uuid.New().String(),
Name: req.Name,
@@ -1144,8 +1169,24 @@ func (s *Service) UpdateExecutor(ctx context.Context, id string, req *UpdateExec
if err != nil {
return nil, err
}
+ if req.Config != nil {
+ if err := validateExecutorConfig(req.Config); err != nil {
+ return nil, err
+ }
+ }
if executor.IsSystem {
- return nil, fmt.Errorf("system executors cannot be modified")
+ if req.Name != nil && *req.Name != executor.Name {
+ return nil, fmt.Errorf("system executors cannot be modified")
+ }
+ if req.Type != nil && *req.Type != executor.Type {
+ return nil, fmt.Errorf("system executors cannot be modified")
+ }
+ if req.Status != nil && *req.Status != executor.Status {
+ return nil, fmt.Errorf("system executors cannot be modified")
+ }
+ if req.Resumable != nil && *req.Resumable != executor.Resumable {
+ return nil, fmt.Errorf("system executors cannot be modified")
+ }
}
if req.Name != nil {
executor.Name = *req.Name
diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts
index 1ca4003bae..9d34de24c1 100644
--- a/apps/web/app/actions/agents.ts
+++ b/apps/web/app/actions/agents.ts
@@ -4,6 +4,8 @@ import { getBackendConfig } from '@/lib/config';
import type {
Agent,
AgentProfile,
+ AgentProfileMcpConfig,
+ McpServerDef,
ListAgentsResponse,
ListAgentDiscoveryResponse,
} from '@/lib/types/http';
@@ -100,3 +102,17 @@ export async function updateAgentProfileAction(
export async function deleteAgentProfileAction(id: string) {
await fetchJson
- Remote Docker executors will support SSH and cloud runtimes in a future update.
+
+ Examples: block stdio on remote runners, allowlist approved servers, or rewrite
+ localhost URLs for Docker/K8s networking.
+ {profile.agent_display_name} profile settings +
+Automatically approve tool calls.
++ Skip permission checks when running tools. +
+Remove this profile
+This action cannot be undone.
+Profile not found.
+