From 93251b48ced6a8c12e5c077f20985a4c9c0bffbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 21 Jan 2026 01:13:20 +0000 Subject: [PATCH] feat: mcp servers + executors (#60) * feat: mcp servers * feat: mcp executors * feat: mcp per agent profile * feat: improve side bar * feat: add reactive executors crud --- apps/backend/cmd/kandev/agents.go | 4 +- .../agent/handlers/shell_handlers_test.go | 2 +- .../internal/agent/lifecycle/manager.go | 100 +++++- .../internal/agent/lifecycle/manager_test.go | 2 +- .../agent/lifecycle/profile_resolver_test.go | 8 + .../internal/agent/lifecycle/runtime.go | 3 +- .../agent/lifecycle/runtime_docker.go | 7 +- .../agent/lifecycle/runtime_standalone.go | 5 +- .../internal/agent/lifecycle/session.go | 16 +- .../backend/internal/agent/lifecycle/types.go | 6 + apps/backend/internal/agent/mcpconfig/acp.go | 20 ++ .../agent/mcpconfig/executor_policy.go | 102 ++++++ .../internal/agent/mcpconfig/policy.go | 33 ++ .../internal/agent/mcpconfig/resolve.go | 144 ++++++++ .../internal/agent/mcpconfig/service.go | 274 +++++++++++++++ .../backend/internal/agent/mcpconfig/types.go | 63 ++++ .../internal/agent/registry/agents.json | 13 +- .../backend/internal/agent/runtime/runtime.go | 12 + .../agent/settings/controller/controller.go | 67 ++++ .../internal/agent/settings/dto/dto.go | 13 +- .../agent/settings/handlers/handlers.go | 92 +++++ .../internal/agent/settings/models/mcp.go | 13 + .../internal/agent/settings/store/sqlite.go | 72 ++++ .../internal/agent/settings/store/store.go | 3 + apps/backend/internal/agentctl/client/acp.go | 11 +- .../agentctl/server/adapter/acp_adapter.go | 22 +- .../agentctl/server/adapter/adapter.go | 19 +- .../agentctl/server/adapter/codex_adapter.go | 2 +- .../internal/agentctl/server/api/acp.go | 8 +- apps/backend/internal/agentctl/types/types.go | 7 + .../orchestrator/executor/executor.go | 55 ++- .../task/handlers/executor_handlers.go | 15 + apps/backend/internal/task/service/service.go | 47 ++- apps/web/app/actions/agents.ts | 16 + .../app/settings/agents/[agentId]/page.tsx | 180 ++++++++-- .../[agentId]/profile-mcp-config-card.tsx | 245 +++++++++++++ .../[agentId]/profiles/[profileId]/page.tsx | 323 +++--------------- .../[profileId]/use-agent-profile-settings.ts | 35 ++ .../[agentId]/use-profile-mcp-config.ts | 190 +++++++++++ apps/web/app/settings/executor/[id]/page.tsx | 210 ++++++++++-- apps/web/app/settings/executor/new/page.tsx | 18 +- apps/web/app/settings/executors/page.tsx | 38 +-- apps/web/app/settings/layout.tsx | 24 +- .../settings/agent-profile-page.tsx | 303 ++++++++++++++++ .../components/settings/prompts-settings.tsx | 4 +- .../settings/settings-app-sidebar.tsx | 106 +++--- .../components/settings/unsaved-indicator.tsx | 4 +- apps/web/lib/http/index.ts | 8 + apps/web/lib/types/http.ts | 22 ++ 49 files changed, 2498 insertions(+), 488 deletions(-) create mode 100644 apps/backend/internal/agent/mcpconfig/acp.go create mode 100644 apps/backend/internal/agent/mcpconfig/executor_policy.go create mode 100644 apps/backend/internal/agent/mcpconfig/policy.go create mode 100644 apps/backend/internal/agent/mcpconfig/resolve.go create mode 100644 apps/backend/internal/agent/mcpconfig/service.go create mode 100644 apps/backend/internal/agent/mcpconfig/types.go create mode 100644 apps/backend/internal/agent/runtime/runtime.go create mode 100644 apps/backend/internal/agent/settings/models/mcp.go create mode 100644 apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx create mode 100644 apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts create mode 100644 apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts create mode 100644 apps/web/components/settings/agent-profile-page.tsx 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(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'DELETE' }); } + +export async function getAgentProfileMcpConfigAction(profileId: string): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${profileId}/mcp-config`); +} + +export async function updateAgentProfileMcpConfigAction( + profileId: string, + payload: { enabled: boolean; mcpServers: Record; meta?: Record } +): Promise { + return fetchJson(`${apiBaseUrl}/api/v1/agent-profiles/${profileId}/mcp-config`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index f3a3e53532..0300a074dc 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import { IconPlus } from '@tabler/icons-react'; @@ -26,14 +26,31 @@ import { deleteAgentProfileAction, updateAgentAction, updateAgentProfileAction, + updateAgentProfileMcpConfigAction, } from '@/app/actions/agents'; -import type { Agent, AgentDiscovery, AgentProfile } from '@/lib/types/http'; +import type { Agent, AgentDiscovery, AgentProfile, McpServerDef } from '@/lib/types/http'; import { generateUUID } from '@/lib/utils'; import { useAppStore } from '@/components/state-provider'; import { useAvailableAgents } from '@/hooks/use-available-agents'; +import { ProfileMcpConfigCard } from './profile-mcp-config-card'; -type DraftAgent = Agent & { isNew?: boolean }; -type DraftProfile = AgentProfile & { isNew?: boolean }; +type DraftProfile = AgentProfile & { + isNew?: boolean; + mcp_config?: { + enabled: boolean; + servers: string; + dirty: boolean; + error: string | null; + }; +}; +type DraftAgent = Omit & { profiles: DraftProfile[]; isNew?: boolean }; + +const defaultMcpConfig: NonNullable = { + enabled: false, + servers: '{\n "mcpServers": {}\n}', + dirty: false, + error: null, +}; type AgentSetupFormProps = { initialAgent: DraftAgent; @@ -54,13 +71,14 @@ const createDraftProfile = (agentId: string, agentDisplayName: string): DraftPro created_at: new Date().toISOString(), updated_at: new Date().toISOString(), isNew: true, + mcp_config: { ...defaultMcpConfig }, }); const cloneAgent = (agent: Agent): DraftAgent => ({ ...agent, workspace_id: agent.workspace_id ?? null, mcp_config_path: agent.mcp_config_path ?? '', - profiles: agent.profiles.map((profile) => ({ ...profile })), + profiles: agent.profiles.map((profile) => ({ ...profile })) as DraftProfile[], }); const ensureProfiles = (agent: DraftAgent, agentDisplayName: string): DraftAgent => { @@ -86,8 +104,12 @@ function AgentSetupForm({ const availableAgents = useAvailableAgents().items; const [draftAgent, setDraftAgent] = useState(initialAgent); const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [newProfileId, setNewProfileId] = useState(null); const resolveDisplayName = (name: string) => availableAgents.find((item) => item.name === name)?.display_name ?? ''; + const hasInvalidMcpConfig = useMemo(() => { + return draftAgent.profiles.some((profile) => Boolean(profile.mcp_config?.error)); + }, [draftAgent.profiles]); // Get model config for the current agent const currentAgentModelConfig = useMemo(() => { @@ -142,10 +164,15 @@ function AgentSetupForm({ }; const handleAddProfile = () => { + const draftId = `draft-${generateUUID()}`; setDraftAgent((current) => ({ ...current, - profiles: [...current.profiles, createDraftProfile(current.id, resolveDisplayName(current.name))], + profiles: [ + ...current.profiles, + { ...createDraftProfile(current.id, resolveDisplayName(current.name)), id: draftId }, + ], })); + setNewProfileId(draftId); }; const handleRemoveProfile = (profileId: string) => { @@ -159,6 +186,9 @@ function AgentSetupForm({ : [createDraftProfile(current.id, resolveDisplayName(current.name))], }; }); + if (newProfileId === profileId) { + setNewProfileId(null); + } }; const handleProfileChange = (profileId: string, patch: Partial) => { @@ -170,11 +200,54 @@ function AgentSetupForm({ })); }; - const handleAgentFieldChange = (patch: Partial) => { - setDraftAgent((current) => ({ ...current, ...patch })); + const handleProfileMcpChange = ( + profileId: string, + patch: Partial> + ) => { + setDraftAgent((current) => ({ + ...current, + profiles: current.profiles.map((profile) => + profile.id === profileId + ? { ...profile, mcp_config: { ...(profile.mcp_config ?? defaultMcpConfig), ...patch } } + : profile + ), + })); }; + const parseProfileMcpServers = (raw: string) => { + if (!raw.trim()) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('MCP servers config must be a JSON object'); + } + if ('mcpServers' in parsed) { + const nested = (parsed as { mcpServers?: unknown }).mcpServers; + if (!nested || typeof nested !== 'object' || Array.isArray(nested)) { + throw new Error('mcpServers must be a JSON object'); + } + return nested as Record; + } + return parsed as Record; + }; + + useEffect(() => { + if (!newProfileId) return; + const target = document.getElementById(`profile-card-${newProfileId}`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const timeout = setTimeout(() => setNewProfileId(null), 1200); + return () => clearTimeout(timeout); + }, [newProfileId]); + const handleSave = async () => { + if (draftAgent.profiles.some((profile) => !profile.name.trim())) { + onToastError(new Error('Profile name is required.')); + return; + } + if (hasInvalidMcpConfig) { + onToastError(new Error('Fix invalid MCP JSON before saving.')); + return; + } setSaveStatus('loading'); try { if (!savedAgent) { @@ -189,6 +262,50 @@ function AgentSetupForm({ plan: profile.plan, })), }); + if (created.profiles.length === draftAgent.profiles.length) { + for (let index = 0; index < draftAgent.profiles.length; index += 1) { + const draftProfile = draftAgent.profiles[index]; + const createdProfile = created.profiles[index]; + if ( + draftProfile.mcp_config && + draftProfile.mcp_config.dirty && + draftProfile.mcp_config.servers.trim() + ) { + try { + const servers = parseProfileMcpServers(draftProfile.mcp_config.servers); + await updateAgentProfileMcpConfigAction(createdProfile.id, { + enabled: draftProfile.mcp_config.enabled, + mcpServers: servers, + }); + } catch (error) { + onToastError(error); + } + } + } + } else { + for (const draftProfile of draftAgent.profiles) { + if ( + !draftProfile.mcp_config || + !draftProfile.mcp_config.dirty || + !draftProfile.mcp_config.servers.trim() + ) { + continue; + } + const createdProfile = created.profiles.find( + (profile) => profile.name === draftProfile.name + ); + if (!createdProfile) continue; + try { + const servers = parseProfileMcpServers(draftProfile.mcp_config.servers); + await updateAgentProfileMcpConfigAction(createdProfile.id, { + enabled: draftProfile.mcp_config.enabled, + mcpServers: servers, + }); + } catch (error) { + onToastError(error); + } + } + } if ((draftAgent.mcp_config_path ?? '') !== (created.mcp_config_path ?? '')) { created = await updateAgentAction(created.id, { mcp_config_path: draftAgent.mcp_config_path ?? '', @@ -221,6 +338,17 @@ function AgentSetupForm({ dangerously_skip_permissions: profile.dangerously_skip_permissions, plan: profile.plan, }); + if (profile.mcp_config && profile.mcp_config.dirty && profile.mcp_config.servers.trim()) { + try { + const servers = parseProfileMcpServers(profile.mcp_config.servers); + await updateAgentProfileMcpConfigAction(createdProfile.id, { + enabled: profile.mcp_config.enabled, + mcpServers: servers, + }); + } catch (error) { + onToastError(error); + } + } nextProfiles.push(createdProfile); continue; } @@ -286,25 +414,20 @@ function AgentSetupForm({ {draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name} Profiles {isAgentDirty && } - - {draftAgent.supports_mcp && ( -
- - handleAgentFieldChange({ mcp_config_path: event.target.value })} - placeholder="~/.copilot/mcp-config.json" - /> -
- )} - {draftAgent.profiles.map((profile) => ( - + {draftAgent.profiles.map((profile) => { + const isNew = profile.id === newProfileId; + return ( +
@@ -396,9 +519,18 @@ function AgentSetupForm({ />
+ + handleProfileMcpChange(profile.id, patch)} + onToastError={onToastError} + />
- ))} + ); + })}
+ ); } 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 new file mode 100644 index 0000000000..3cc347c083 --- /dev/null +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; +import { Label } from '@kandev/ui/label'; +import { Switch } from '@kandev/ui/switch'; +import { Textarea } from '@kandev/ui/textarea'; +import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; +import { useProfileMcpConfig } from './use-profile-mcp-config'; +import type { AgentProfileMcpConfig } from '@/lib/types/http'; + +type ProfileMcpConfigCardProps = { + profileId: string; + supportsMcp: boolean; + initialConfig?: AgentProfileMcpConfig | null; + draftState?: { + enabled: boolean; + servers: string; + dirty: boolean; + error: string | null; + }; + onDraftStateChange?: (next: { + enabled?: boolean; + servers?: string; + dirty?: boolean; + error?: string | null; + }) => void; + onToastError: (error: unknown) => void; +}; + +export function ProfileMcpConfigCard({ + profileId, + supportsMcp, + initialConfig, + draftState, + onDraftStateChange, + onToastError, +}: ProfileMcpConfigCardProps) { + const { + mcpEnabled, + mcpServers, + mcpError, + mcpDirty, + mcpStatus, + setMcpEnabled, + handleMcpServersChange, + handleSaveMcp, + } = useProfileMcpConfig({ + profileId, + supportsMcp, + initialConfig, + onToastError, + }); + + if (!supportsMcp) { + return null; + } + + const isDraft = Boolean(draftState); + const isEditableProfile = !isDraft && Boolean(profileId) && !profileId.startsWith('draft-'); + + const currentEnabled = isDraft ? draftState?.enabled ?? false : mcpEnabled; + const currentServers = isDraft ? draftState?.servers ?? '' : mcpServers; + const currentError = isDraft ? draftState?.error ?? null : mcpError; + const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty; + + const popularServers: Record> = { + playwright: { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-playwright'], + }, + 'chrome-devtools': { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-chrome-devtools'], + }, + context7: { + type: 'stdio', + command: 'npx', + args: ['-y', '@context7/mcp'], + env: { + CONTEXT7_API_KEY: 'your_api_key_here', + }, + }, + github: { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_TOKEN: 'your_token_here', + }, + }, + }; + + const applyPopularServer = (label: string) => { + const base = currentServers.trim() || '{\n "mcpServers": {}\n}'; + let parsed: Record = {}; + try { + parsed = JSON.parse(base) as Record; + } catch { + return; + } + const root = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : { mcpServers: {} }; + const servers = (root.mcpServers && + typeof root.mcpServers === 'object' && + !Array.isArray(root.mcpServers) + ? root.mcpServers + : {}) as Record; + + if (servers[label]) return; + servers[label] = popularServers[label] ?? { type: 'stdio', command: 'npx', args: ['-y'] }; + root.mcpServers = servers; + const nextValue = JSON.stringify(root, null, 2); + + if (isDraft) { + onDraftStateChange?.({ servers: nextValue, dirty: true, error: null }); + return; + } + handleMcpServersChange(nextValue); + }; + + const handleDraftServersChange = (value: string) => { + if (!onDraftStateChange) return; + let error: string | null = null; + if (value.trim()) { + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + error = 'MCP servers config must be a JSON object'; + } else if ('mcpServers' in parsed) { + const nested = (parsed as { mcpServers?: unknown }).mcpServers; + if (!nested || typeof nested !== 'object' || Array.isArray(nested)) { + error = 'mcpServers must be a JSON object'; + } + } + } catch { + error = 'Invalid JSON'; + } + } + onDraftStateChange({ servers: value, dirty: true, error }); + }; + + return ( + + +
+ MCP Configuration + {currentDirty && } +
+
+ + {!isEditableProfile && ( +

+ {isDraft + ? 'MCP config will be applied after the profile is saved.' + : 'Save this profile to configure MCP servers.'} +

+ )} +
+
+ +

+ Allow this profile to use MCP servers during sessions. +

+
+ { + if (isDraft) { + onDraftStateChange?.({ enabled: checked, dirty: true }); + return; + } + setMcpEnabled(checked); + }} + disabled={!isEditableProfile && !isDraft} + /> +
+ +
+ +