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 && }
-
+
Add profile
- {draftAgent.supports_mcp && (
-
- MCP Config Path
- 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.'}
+
+ )}
+
+
+
Enable MCP
+
+ Allow this profile to use MCP servers during sessions.
+
+
+
{
+ if (isDraft) {
+ onDraftStateChange?.({ enabled: checked, dirty: true });
+ return;
+ }
+ setMcpEnabled(checked);
+ }}
+ disabled={!isEditableProfile && !isDraft}
+ />
+
+
+
+
+
+ {isEditableProfile ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx
index bf00de4efa..c9fc5e2f30 100644
--- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx
+++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx
@@ -1,288 +1,49 @@
-'use client';
+import { StateHydrator } from '@/components/state-hydrator';
+import { listAvailableAgents, getAgentProfileMcpConfig } from '@/lib/http';
+import type { AgentProfileMcpConfig } from '@/lib/types/http';
+import { AgentProfilePage } from '@/components/settings/agent-profile-page';
-import { useMemo, useState } from 'react';
-import Link from 'next/link';
-import { useParams } from 'next/navigation';
-import { IconTrash } from '@tabler/icons-react';
-import { Badge } from '@kandev/ui/badge';
-import { Button } from '@kandev/ui/button';
-import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
-import { Input } from '@kandev/ui/input';
-import { Label } from '@kandev/ui/label';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
-import { Separator } from '@kandev/ui/separator';
-import { Switch } from '@kandev/ui/switch';
-import { Textarea } from '@kandev/ui/textarea';
-import { useToast } from '@/components/toast-provider';
-import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
-import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents';
-import type { Agent, AgentProfile, ModelConfig } from '@/lib/types/http';
-import { useAppStore } from '@/components/state-provider';
-import { useAvailableAgents } from '@/hooks/use-available-agents';
+export default async function AgentProfileRoute({
+ params,
+}: {
+ params: Promise<{ agentId: string; profileId: string }>;
+}) {
+ const { profileId } = await params;
+ let initialState = {};
+ let initialMcpConfig: AgentProfileMcpConfig | null | undefined = undefined;
-type ProfileEditorProps = {
- agent: Agent;
- profile: AgentProfile;
- modelConfig: ModelConfig;
-};
-
-function ProfileEditor({ agent, profile, modelConfig }: ProfileEditorProps) {
- const { toast } = useToast();
- const settingsAgents = useAppStore((state) => state.settingsAgents.items);
- const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
- const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
- const [draft, setDraft] = useState({ ...profile });
- const [savedProfile, setSavedProfile] = useState(profile);
- const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
- const syncAgentsToStore = (nextAgents: Agent[]) => {
- setSettingsAgents(nextAgents);
- setAgentProfiles(
- nextAgents.flatMap((agentItem) =>
- agentItem.profiles.map((agentProfile) => ({
- id: agentProfile.id,
- label: `${agentProfile.agent_display_name} • ${agentProfile.name}`,
- agent_id: agentItem.id,
- }))
- )
- );
- };
-
- const isDirty = useMemo(() => {
- return (
- draft.name !== savedProfile.name ||
- draft.model !== savedProfile.model ||
- draft.auto_approve !== savedProfile.auto_approve ||
- draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions ||
- draft.plan !== savedProfile.plan
- );
- }, [draft, savedProfile]);
-
- const handleSave = async () => {
- setSaveStatus('loading');
- try {
- const updated = await updateAgentProfileAction(draft.id, {
- name: draft.name,
- model: draft.model,
- auto_approve: draft.auto_approve,
- dangerously_skip_permissions: draft.dangerously_skip_permissions,
- plan: draft.plan,
- });
- setSavedProfile(updated);
- setDraft(updated);
- const nextAgents = settingsAgents.map((agentItem) =>
- agentItem.id === agent.id
- ? {
- ...agentItem,
- profiles: agentItem.profiles.map((profileItem) =>
- profileItem.id === updated.id ? updated : profileItem
- ),
- }
- : agentItem
- );
- syncAgentsToStore(nextAgents);
- setSaveStatus('success');
- } catch (error) {
- setSaveStatus('error');
- toast({
- title: 'Failed to save profile',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
- }
- };
-
- const handleDeleteProfile = async () => {
- try {
- await deleteAgentProfileAction(draft.id);
- const nextAgents = settingsAgents.map((agentItem) =>
- agentItem.id === agent.id
- ? {
- ...agentItem,
- profiles: agentItem.profiles.filter((profileItem) => profileItem.id !== draft.id),
- }
- : agentItem
- );
- syncAgentsToStore(nextAgents);
- window.location.assign('/settings/agents');
- } catch (error) {
- toast({
- title: 'Failed to delete profile',
- description: error instanceof Error ? error.message : 'Request failed',
- variant: 'error',
- });
- }
- };
-
- return (
-
-
-
-
{profile.agent_display_name} • {savedProfile.name}
-
- {profile.agent_display_name} profile settings
-
-
-
- {isDirty && }
-
-
-
-
-
-
-
-
-
- Profile settings
- {agent.supports_mcp && MCP }
-
-
-
-
- Profile name
- setDraft({ ...draft, name: event.target.value })}
- placeholder="Default profile"
- />
-
-
-
- Model
- {modelConfig.available_models.length > 0 ? (
- setDraft({ ...draft, model: value })}
- >
-
-
-
-
- {modelConfig.available_models.map((model) => (
-
- {model.name}
- {model.is_default && (
- (default)
- )}
-
- ))}
-
-
- ) : (
- setDraft({ ...draft, model: event.target.value })}
- placeholder="model-id"
- />
- )}
-
-
-
- Append Prompt
-
-
-
-
-
-
Auto-approve
-
Automatically approve tool calls.
-
-
- setDraft({ ...draft, auto_approve: checked })
- }
- />
-
-
-
-
-
Skip permissions
-
- Skip permission checks when running tools.
-
-
-
- setDraft({ ...draft, dangerously_skip_permissions: checked })
- }
- />
-
-
-
-
-
-
-
- Delete profile
-
-
-
-
Remove this profile
-
This action cannot be undone.
-
-
-
- Delete
-
-
-
-
- );
-}
-
-export default function AgentProfilePage() {
- const params = useParams();
- const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
- const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
- const agentKey = decodeURIComponent(agentParam ?? '');
- const profileId = profileParam ?? '';
- const settingsAgents = useAppStore((state) => state.settingsAgents.items);
- const availableAgents = useAvailableAgents().items;
-
- const agent = useMemo(() => {
- return settingsAgents.find((item) => item.name === agentKey) ?? null;
- }, [agentKey, settingsAgents]);
-
- const profile = useMemo(() => {
- return agent?.profiles.find((item) => item.id === profileId) ?? null;
- }, [agent?.profiles, profileId]);
-
- const modelConfig = useMemo(() => {
- const availableAgent = availableAgents.find((item) => item.name === agent?.name);
- return availableAgent?.model_config ?? { default_model: '', available_models: [] };
- }, [availableAgents, agent?.name]);
-
- if (!agent || !profile) {
- return (
-
-
- Profile not found.
-
- Back to Agents
-
-
-
- );
+ try {
+ const [availableAgentsResult, mcpConfigResult] = await Promise.allSettled([
+ listAvailableAgents({
+ cache: 'no-store',
+ }),
+ getAgentProfileMcpConfig(profileId, {
+ cache: 'no-store',
+ }),
+ ]);
+ const availableAgents =
+ availableAgentsResult.status === 'fulfilled'
+ ? availableAgentsResult.value
+ : { agents: [] };
+ const mcpConfig =
+ mcpConfigResult.status === 'fulfilled' ? mcpConfigResult.value : null;
+ initialState = {
+ availableAgents: {
+ items: availableAgents.agents ?? [],
+ loaded: availableAgentsResult.status === 'fulfilled',
+ loading: false,
+ },
+ };
+ initialMcpConfig = mcpConfig;
+ } catch {
+ initialState = {};
+ initialMcpConfig = undefined;
}
return (
-
+ <>
+ {Object.keys(initialState).length ? : null}
+
+ >
);
}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts
new file mode 100644
index 0000000000..256c0c8158
--- /dev/null
+++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts
@@ -0,0 +1,35 @@
+'use client';
+
+import { useMemo } from 'react';
+import { useAvailableAgents } from '@/hooks/use-available-agents';
+import { useAppStore } from '@/components/state-provider';
+import type { Agent, AgentProfile, ModelConfig } from '@/lib/types/http';
+
+type AgentProfileSettingsResult = {
+ agent: Agent | null;
+ profile: AgentProfile | null;
+ modelConfig: ModelConfig;
+};
+
+export function useAgentProfileSettings(
+ agentKey: string,
+ profileId: string
+): AgentProfileSettingsResult {
+ const settingsAgents = useAppStore((state) => state.settingsAgents.items);
+ const availableAgents = useAvailableAgents().items;
+
+ const agent = useMemo(() => {
+ return settingsAgents.find((item) => item.name === agentKey) ?? null;
+ }, [agentKey, settingsAgents]);
+
+ const profile = useMemo(() => {
+ return agent?.profiles.find((item) => item.id === profileId) ?? null;
+ }, [agent?.profiles, profileId]);
+
+ const modelConfig = useMemo(() => {
+ const availableAgent = availableAgents.find((item) => item.name === agent?.name);
+ return availableAgent?.model_config ?? { default_model: '', available_models: [] };
+ }, [availableAgents, agent?.name]);
+
+ return { agent, profile, modelConfig };
+}
diff --git a/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts
new file mode 100644
index 0000000000..ab77100a95
--- /dev/null
+++ b/apps/web/app/settings/agents/[agentId]/use-profile-mcp-config.ts
@@ -0,0 +1,190 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { getAgentProfileMcpConfigAction, updateAgentProfileMcpConfigAction } from '@/app/actions/agents';
+import type { AgentProfileMcpConfig, McpServerDef } from '@/lib/types/http';
+
+type McpStatus = 'idle' | 'loading' | 'success' | 'error';
+
+type UseProfileMcpConfigParams = {
+ profileId: string;
+ supportsMcp: boolean;
+ initialConfig?: AgentProfileMcpConfig | null;
+ onToastError: (error: unknown) => void;
+};
+
+type UseProfileMcpConfigResult = {
+ mcpEnabled: boolean;
+ mcpServers: string;
+ mcpError: string | null;
+ mcpDirty: boolean;
+ mcpStatus: McpStatus;
+ setMcpEnabled: (enabled: boolean) => void;
+ handleMcpServersChange: (value: string) => void;
+ handleSaveMcp: () => Promise;
+};
+
+export function useProfileMcpConfig({
+ profileId,
+ supportsMcp,
+ initialConfig,
+ onToastError,
+}: UseProfileMcpConfigParams): UseProfileMcpConfigResult {
+ const emptyExample = '{\n "mcpServers": {}\n}';
+ const initialServers =
+ initialConfig?.servers && Object.keys(initialConfig.servers).length > 0
+ ? JSON.stringify({ mcpServers: initialConfig.servers }, null, 2)
+ : emptyExample;
+ const [mcpConfig, setMcpConfig] = useState(
+ initialConfig ?? null
+ );
+ const [mcpEnabled, setMcpEnabledState] = useState(initialConfig?.enabled ?? false);
+ const [mcpServers, setMcpServers] = useState(initialServers);
+ const [mcpError, setMcpError] = useState(null);
+ const [mcpDirty, setMcpDirty] = useState(false);
+ const [mcpStatus, setMcpStatus] = useState('idle');
+ const [hasInitialConfig] = useState(initialConfig !== undefined);
+
+ const isEditableProfile = Boolean(profileId) && !profileId.startsWith('draft-');
+
+ useEffect(() => {
+ let active = true;
+ if (!supportsMcp || !isEditableProfile) {
+ Promise.resolve().then(() => {
+ if (!active) return;
+ setMcpConfig(null);
+ setMcpEnabledState(false);
+ setMcpServers(emptyExample);
+ setMcpDirty(false);
+ setMcpError(null);
+ setMcpStatus('idle');
+ });
+ return () => {
+ active = false;
+ };
+ }
+ if (hasInitialConfig) {
+ return () => {
+ active = false;
+ };
+ }
+
+ Promise.resolve().then(() => {
+ if (!active) return;
+ setMcpStatus('loading');
+ });
+ getAgentProfileMcpConfigAction(profileId)
+ .then((config) => {
+ if (!active) return;
+ setMcpConfig(config);
+ setMcpEnabledState(config.enabled);
+ const nextServers =
+ config.servers && Object.keys(config.servers).length > 0
+ ? JSON.stringify({ mcpServers: config.servers }, null, 2)
+ : emptyExample;
+ setMcpServers(nextServers);
+ setMcpDirty(false);
+ setMcpError(null);
+ setMcpStatus('idle');
+ })
+ .catch((error) => {
+ if (!active) return;
+ setMcpError(error instanceof Error ? error.message : 'Failed to load MCP config');
+ setMcpStatus('error');
+ });
+
+ return () => {
+ active = false;
+ };
+ }, [profileId, supportsMcp, isEditableProfile, hasInitialConfig]);
+
+ const isEmptyExample = (value: string) => value.trim() === emptyExample.trim();
+
+ const setMcpEnabled = (enabled: boolean) => {
+ setMcpEnabledState(enabled);
+ setMcpDirty(true);
+ };
+
+ const normalizeServers = (value: unknown) => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('MCP servers config must be a JSON object');
+ }
+ if ('mcpServers' in value) {
+ const nested = (value 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 value as Record;
+ };
+
+ const handleMcpServersChange = (value: string) => {
+ if (!value.trim()) {
+ setMcpServers(emptyExample);
+ } else {
+ setMcpServers(value);
+ }
+ setMcpDirty(true);
+ if (!value.trim()) {
+ setMcpError(null);
+ return;
+ }
+ try {
+ const parsed = JSON.parse(value);
+ normalizeServers(parsed);
+ setMcpError(null);
+ } catch {
+ setMcpError('Invalid JSON');
+ }
+ };
+
+ const handleSaveMcp = async () => {
+ if (!isEditableProfile) {
+ return;
+ }
+ setMcpStatus('loading');
+ let servers: Record = {};
+ try {
+ const raw = isEmptyExample(mcpServers) ? '{}' : mcpServers;
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
+ servers = normalizeServers(parsed);
+ } catch (error) {
+ setMcpStatus('error');
+ setMcpError(error instanceof Error ? error.message : 'Invalid MCP config');
+ return;
+ }
+
+ try {
+ const updated = await updateAgentProfileMcpConfigAction(profileId, {
+ enabled: mcpEnabled,
+ mcpServers: servers,
+ meta: mcpConfig?.meta ?? {},
+ });
+ setMcpConfig(updated);
+ setMcpEnabledState(updated.enabled);
+ const nextServers =
+ updated.servers && Object.keys(updated.servers).length > 0
+ ? JSON.stringify({ mcpServers: updated.servers }, null, 2)
+ : emptyExample;
+ setMcpServers(nextServers);
+ setMcpDirty(false);
+ setMcpError(null);
+ setMcpStatus('success');
+ } catch (error) {
+ setMcpStatus('error');
+ onToastError(error);
+ }
+ };
+
+ return {
+ mcpEnabled,
+ mcpServers,
+ mcpError,
+ mcpDirty,
+ mcpStatus,
+ setMcpEnabled,
+ handleMcpServersChange,
+ handleSaveMcp,
+ };
+}
diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx
index 470b1962df..75fa2601f0 100644
--- a/apps/web/app/settings/executor/[id]/page.tsx
+++ b/apps/web/app/settings/executor/[id]/page.tsx
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@kand
import { Input } from '@kandev/ui/input';
import { Label } from '@kandev/ui/label';
import { Separator } from '@kandev/ui/separator';
+import { Textarea } from '@kandev/ui/textarea';
import {
Dialog,
DialogContent,
@@ -18,8 +19,8 @@ import {
} from '@kandev/ui/dialog';
import { updateExecutorAction, deleteExecutorAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
-import type { Executor } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
+import type { Executor } from '@/lib/types/http';
export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
@@ -50,6 +51,8 @@ type ExecutorEditFormProps = {
function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
const router = useRouter();
+ const executors = useAppStore((state) => state.executors.items);
+ const setExecutors = useAppStore((state) => state.setExecutors);
const [draft, setDraft] = useState({ ...executor });
const [savedExecutor, setSavedExecutor] = useState({ ...executor });
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -59,6 +62,19 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
const isSystem = draft.is_system ?? false;
const isLocalDocker = draft.type === 'local_docker';
+ const mcpPolicyError = useMemo(() => {
+ const value = draft.config?.mcp_policy ?? '';
+ if (!value.trim()) return null;
+ try {
+ const parsed = JSON.parse(value);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return 'MCP policy must be a JSON object';
+ }
+ } catch {
+ return 'Invalid JSON';
+ }
+ return null;
+ }, [draft.config?.mcp_policy]);
const ExecutorIcon = useMemo(() => {
return draft.type === 'local_pc' ? IconCpu : IconServer;
@@ -77,19 +93,24 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
if (!draft) return;
setIsSaving(true);
try {
- const payload = {
- name: draft.name,
- type: draft.type,
- status: draft.status,
- config: draft.config ?? {},
- };
+ const payload = isSystem
+ ? { config: draft.config ?? {} }
+ : {
+ name: draft.name,
+ type: draft.type,
+ status: draft.status,
+ config: draft.config ?? {},
+ };
const client = getWebSocketClient();
const updated = client
? await client.request('executor.update', { id: draft.id, ...payload })
: await updateExecutorAction(draft.id, payload);
setDraft(updated);
setSavedExecutor(updated);
- router.push('/settings/executors');
+ setExecutors(
+ executors.map((item) => (item.id === updated.id ? { ...item, ...updated } : item))
+ );
+ // Stay on the executor page after saving so the user can continue editing.
} finally {
setIsSaving(false);
}
@@ -105,6 +126,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
} else {
await deleteExecutorAction(draft.id);
}
+ setExecutors(executors.filter((item) => item.id !== draft.id));
router.push('/settings/executors');
} finally {
setIsDeleting(false);
@@ -121,8 +143,8 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
{draft.type === 'local_pc'
? 'Uses locally installed agents on this machine.'
: draft.type === 'local_docker'
- ? 'Runs Docker containers on this machine.'
- : 'Remote Docker support is coming soon.'}
+ ? 'Runs Docker containers on this machine.'
+ : 'Remote Docker support is coming soon.'}
router.push('/settings/executors')}>
@@ -182,19 +204,159 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
)}
- {draft.type === 'remote_docker' && (
-
-
- Remote Docker
- Coming soon.
-
-
-
- Remote Docker executors will support SSH and cloud runtimes in a future update.
+
+
+
+ MCP Policy
+
+ Advanced
+
+
+
+ JSON policy overrides for MCP servers on this executor. Use it to enforce transport
+ rules, restrict which servers can run, or rewrite URLs for this runtime.
+
+ Examples: block stdio on remote runners, allowlist approved servers, or rewrite
+ localhost URLs for Docker/K8s networking.
-
-
- )}
+
+
+
+ MCP policy JSON
+
+
@@ -202,7 +364,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
router.push('/settings/executors')}>
Cancel
-
+
{isSaving ? 'Saving...' : 'Save Changes'}
@@ -230,7 +392,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
Delete Executor
- Type "delete" to confirm deletion. This action cannot be undone.
+ Type "delete" to confirm deletion. This action cannot be undone.
diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx
index 4383cf5dfd..89f895c034 100644
--- a/apps/web/app/settings/executor/new/page.tsx
+++ b/apps/web/app/settings/executor/new/page.tsx
@@ -11,8 +11,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@kandev/ui/separator';
import { createExecutorAction } from '@/app/actions/executors';
import { getWebSocketClient } from '@/lib/ws/connection';
+import { useAppStore } from '@/components/state-provider';
+import type { Executor } from '@/lib/types/http';
-const EXECUTOR_TYPES = ['local_docker', 'remote_docker'] as const;
+const EXECUTOR_TYPES = ['local_docker'] as const;
type ExecutorType = (typeof EXECUTOR_TYPES)[number];
export default function ExecutorCreatePage() {
@@ -36,6 +38,8 @@ function ExecutorCreatePageContent() {
const [name, setName] = useState('Local Docker');
const [dockerHost, setDockerHost] = useState('unix:///var/run/docker.sock');
const [isCreating, setIsCreating] = useState(false);
+ const executors = useAppStore((state) => state.executors.items);
+ const setExecutors = useAppStore((state) => state.setExecutors);
const handleCreate = async () => {
if (type !== 'local_docker') {
@@ -50,11 +54,10 @@ function ExecutorCreatePageContent() {
config: { docker_host: dockerHost },
};
const client = getWebSocketClient();
- if (client) {
- await client.request('executor.create', payload);
- } else {
- await createExecutorAction(payload);
- }
+ const created = client
+ ? await client.request('executor.create', payload)
+ : await createExecutorAction(payload);
+ setExecutors([...executors.filter((item) => item.id !== created.id), created]);
router.push('/settings/executors');
} finally {
setIsCreating(false);
@@ -93,9 +96,6 @@ function ExecutorCreatePageContent() {
Local Docker
-
- Remote Docker (coming soon)
-
diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx
index 2f4bd5618e..aa0d65b605 100644
--- a/apps/web/app/settings/executors/page.tsx
+++ b/apps/web/app/settings/executors/page.tsx
@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
-import { IconChevronRight, IconCloud, IconCpu, IconServer } from '@tabler/icons-react';
+import { IconChevronRight, IconCpu, IconServer } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
@@ -18,30 +18,6 @@ export default function ExecutorsSettingsPage() {
icon: IconServer,
enabled: true,
},
- {
- id: 'remote_docker',
- label: 'Remote Docker',
- description: 'Connect to a remote Docker host.',
- href: '/settings/executor/new?type=remote_docker',
- icon: IconCloud,
- enabled: false,
- },
- {
- id: 'remote_server',
- label: 'Remote Server',
- description: 'SSH into a dedicated host.',
- href: '/settings/executor/new?type=remote_server',
- icon: IconCloud,
- enabled: false,
- },
- {
- id: 'k8s',
- label: 'K8s',
- description: 'Run workloads in a Kubernetes cluster.',
- href: '/settings/executor/new?type=k8s',
- icon: IconCloud,
- enabled: false,
- },
];
return (
@@ -101,14 +77,22 @@ export default function ExecutorsSettingsPage() {
- {executors.map((executor) => {
+ {executors
+ .filter((executor) => executor.type !== 'remote_docker')
+ .map((executor) => {
const Icon = executor.type === 'local_pc' ? IconCpu : IconServer;
const typeLabel =
executor.type === 'local_pc'
? 'Local PC'
: executor.type === 'local_docker'
? 'Local Docker'
- : 'Remote Docker';
+ : executor.type === 'remote_docker'
+ ? 'Remote Docker'
+ : executor.type === 'remote_vps'
+ ? 'Remote Server'
+ : executor.type === 'k8s'
+ ? 'K8s'
+ : 'Remote';
return (
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index eb8d235126..6f4fb5be31 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -2,6 +2,7 @@ import { SettingsLayoutClient } from '@/components/settings/settings-layout-clie
import { StateProvider } from '@/components/state-provider';
import {
listAgentDiscovery,
+ listAvailableAgents,
listAgents,
listEnvironments,
listExecutors,
@@ -21,19 +22,23 @@ export default function SettingsLayout({
async function SettingsLayoutServer({ children }: { children: React.ReactNode }) {
let initialState = {};
try {
- const [
- workspaces,
- executors,
- environments,
- agents,
- discovery,
- ] = await Promise.all([
+ const [workspaces, executors, environments, agents, discovery] = await Promise.all([
listWorkspaces({ cache: 'no-store' }),
listExecutors({ cache: 'no-store' }),
listEnvironments({ cache: 'no-store' }),
listAgents({ cache: 'no-store' }),
listAgentDiscovery({ cache: 'no-store' }),
]);
+ let availableAgents = { agents: [] as typeof discovery.agents };
+ let availableAgentsLoaded = true;
+ try {
+ availableAgents = await listAvailableAgents({
+ cache: 'no-store',
+ });
+ } catch {
+ availableAgents = { agents: [] as typeof discovery.agents };
+ availableAgentsLoaded = false;
+ }
initialState = {
workspaces: {
items: workspaces.workspaces.map((workspace) => ({
@@ -66,6 +71,11 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode })
agentDiscovery: {
items: discovery.agents,
},
+ availableAgents: {
+ items: availableAgents.agents ?? [],
+ loaded: availableAgentsLoaded,
+ loading: false,
+ },
settingsData: {
executorsLoaded: true,
environmentsLoaded: true,
diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx
new file mode 100644
index 0000000000..cf7f9be058
--- /dev/null
+++ b/apps/web/components/settings/agent-profile-page.tsx
@@ -0,0 +1,303 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { IconTrash } from '@tabler/icons-react';
+import { Badge } from '@kandev/ui/badge';
+import { Button } from '@kandev/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
+import { Input } from '@kandev/ui/input';
+import { Label } from '@kandev/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select';
+import { Separator } from '@kandev/ui/separator';
+import { Switch } from '@kandev/ui/switch';
+import { Textarea } from '@kandev/ui/textarea';
+import { useToast } from '@/components/toast-provider';
+import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
+import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents';
+import type { Agent, AgentProfile, ModelConfig } from '@/lib/types/http';
+import { useAppStore } from '@/components/state-provider';
+import { ProfileMcpConfigCard } from '@/app/settings/agents/[agentId]/profile-mcp-config-card';
+import type { AgentProfileMcpConfig } from '@/lib/types/http';
+import { useAgentProfileSettings } from '@/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings';
+
+type ProfileEditorProps = {
+ agent: Agent;
+ profile: AgentProfile;
+ modelConfig: ModelConfig;
+ initialMcpConfig?: AgentProfileMcpConfig | null;
+};
+
+function ProfileEditor({ agent, profile, modelConfig, initialMcpConfig }: ProfileEditorProps) {
+ const { toast } = useToast();
+ const settingsAgents = useAppStore((state) => state.settingsAgents.items);
+ const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
+ const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
+ const [draft, setDraft] = useState({ ...profile });
+ const [savedProfile, setSavedProfile] = useState(profile);
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
+ const syncAgentsToStore = (nextAgents: Agent[]) => {
+ setSettingsAgents(nextAgents);
+ setAgentProfiles(
+ nextAgents.flatMap((agentItem) =>
+ agentItem.profiles.map((agentProfile) => ({
+ id: agentProfile.id,
+ label: `${agentProfile.agent_display_name} • ${agentProfile.name}`,
+ agent_id: agentItem.id,
+ }))
+ )
+ );
+ };
+
+ const isDirty = useMemo(() => {
+ return (
+ draft.name !== savedProfile.name ||
+ draft.model !== savedProfile.model ||
+ draft.auto_approve !== savedProfile.auto_approve ||
+ draft.dangerously_skip_permissions !== savedProfile.dangerously_skip_permissions ||
+ draft.plan !== savedProfile.plan
+ );
+ }, [draft, savedProfile]);
+
+ const handleSave = async () => {
+ if (!draft.name.trim()) {
+ toast({
+ title: 'Profile name is required',
+ description: 'Please enter a profile name before saving.',
+ variant: 'error',
+ });
+ return;
+ }
+ setSaveStatus('loading');
+ try {
+ const updated = await updateAgentProfileAction(draft.id, {
+ name: draft.name,
+ model: draft.model,
+ auto_approve: draft.auto_approve,
+ dangerously_skip_permissions: draft.dangerously_skip_permissions,
+ plan: draft.plan,
+ });
+ setSavedProfile(updated);
+ setDraft(updated);
+ const nextAgents = settingsAgents.map((agentItem) =>
+ agentItem.id === agent.id
+ ? {
+ ...agentItem,
+ profiles: agentItem.profiles.map((profileItem) =>
+ profileItem.id === updated.id ? updated : profileItem
+ ),
+ }
+ : agentItem
+ );
+ syncAgentsToStore(nextAgents);
+ setSaveStatus('success');
+ } catch (error) {
+ setSaveStatus('error');
+ toast({
+ title: 'Failed to save profile',
+ description: error instanceof Error ? error.message : 'Request failed',
+ variant: 'error',
+ });
+ }
+ };
+
+ const handleDeleteProfile = async () => {
+ try {
+ await deleteAgentProfileAction(draft.id);
+ const nextAgents = settingsAgents.map((agentItem) =>
+ agentItem.id === agent.id
+ ? {
+ ...agentItem,
+ profiles: agentItem.profiles.filter((profileItem) => profileItem.id !== draft.id),
+ }
+ : agentItem
+ );
+ syncAgentsToStore(nextAgents);
+ window.location.assign('/settings/agents');
+ } catch (error) {
+ toast({
+ title: 'Failed to delete profile',
+ description: error instanceof Error ? error.message : 'Request failed',
+ variant: 'error',
+ });
+ }
+ };
+
+ return (
+
+
+
+
{profile.agent_display_name} • {savedProfile.name}
+
+ {profile.agent_display_name} profile settings
+
+
+
+ {isDirty && }
+
+
+
+
+
+
+
+
+
+ Profile settings
+ {agent.supports_mcp && MCP }
+
+
+
+
+ Profile name
+ setDraft({ ...draft, name: event.target.value })}
+ placeholder="Default profile"
+ />
+
+
+
+ Model
+ {modelConfig.available_models.length > 0 ? (
+ setDraft({ ...draft, model: value })}
+ >
+
+
+
+
+ {modelConfig.available_models.map((model) => (
+
+ {model.name}
+ {model.is_default && (
+ (default)
+ )}
+
+ ))}
+
+
+ ) : (
+ setDraft({ ...draft, model: event.target.value })}
+ placeholder="model-id"
+ />
+ )}
+
+
+
+ Append Prompt
+
+
+
+
+
+
Auto-approve
+
Automatically approve tool calls.
+
+
+ setDraft({ ...draft, auto_approve: checked })
+ }
+ />
+
+
+
+
+
Skip permissions
+
+ Skip permission checks when running tools.
+
+
+
+ setDraft({ ...draft, dangerously_skip_permissions: checked })
+ }
+ />
+
+
+
+
+
+
+ toast({
+ title: 'Failed to save MCP config',
+ description: error instanceof Error ? error.message : 'Request failed',
+ variant: 'error',
+ })
+ }
+ />
+
+
+
+ Delete profile
+
+
+
+
Remove this profile
+
This action cannot be undone.
+
+
+
+ Delete
+
+
+
+
+ );
+}
+
+type AgentProfilePageClientProps = {
+ initialMcpConfig?: AgentProfileMcpConfig | null;
+};
+
+export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientProps) {
+ const params = useParams();
+ const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
+ const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
+ const agentKey = decodeURIComponent(agentParam ?? '');
+ const profileId = profileParam ?? '';
+ const { agent, profile, modelConfig } = useAgentProfileSettings(agentKey, profileId);
+
+ if (!agent || !profile) {
+ return (
+
+
+ Profile not found.
+
+ Back to Agents
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/components/settings/prompts-settings.tsx b/apps/web/components/settings/prompts-settings.tsx
index 92f5e5eb0e..15ab5d34d8 100644
--- a/apps/web/components/settings/prompts-settings.tsx
+++ b/apps/web/components/settings/prompts-settings.tsx
@@ -140,7 +140,7 @@ export function PromptsSettings() {
onSave={() => undefined}
showSaveButton={false}
>
-
+
Use @name in the chat input to insert a
prompt’s content. Prompts are matched by name and expanded in place.
@@ -247,7 +247,7 @@ export function PromptsSettings() {
) : (
-
+
{getPromptPreview(prompt.content)}
)}
diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx
index 1a517b99c5..5be9ae8a6f 100644
--- a/apps/web/components/settings/settings-app-sidebar.tsx
+++ b/apps/web/components/settings/settings-app-sidebar.tsx
@@ -155,58 +155,6 @@ export function SettingsAppSidebar() {
)}
- {/* Environments */}
-
-
-
-
- Environments
-
-
- {environments.length > 0 && (
-
- {environments.map((env) => (
-
-
-
- {env.name}
-
-
-
- ))}
-
- )}
-
-
- {/* Executors */}
-
-
-
-
- Executors
-
-
- {executors.length > 0 && (
-
- {executors.map((executor) => (
-
-
-
- {executor.name}
-
-
-
- ))}
-
- )}
-
-
{/* Agents */}
@@ -248,6 +196,60 @@ export function SettingsAppSidebar() {
+
+ {/* Environments */}
+
+
+
+
+ Environments
+
+
+ {environments.length > 0 && (
+
+ {environments.map((env) => (
+
+
+
+ {env.name}
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Executors */}
+
+
+
+
+ Executors
+
+
+ {executors.filter((executor) => executor.type !== 'remote_docker').length > 0 && (
+
+ {executors
+ .filter((executor) => executor.type !== 'remote_docker')
+ .map((executor) => (
+
+
+
+ {executor.name}
+
+
+
+ ))}
+
+ )}
+
diff --git a/apps/web/components/settings/unsaved-indicator.tsx b/apps/web/components/settings/unsaved-indicator.tsx
index bb89d8130d..328885951a 100644
--- a/apps/web/components/settings/unsaved-indicator.tsx
+++ b/apps/web/components/settings/unsaved-indicator.tsx
@@ -10,6 +10,7 @@ type UnsavedSaveButtonProps = {
status: 'idle' | 'loading' | 'success' | 'error';
onClick: () => void;
cleanLabel?: string;
+ disabled?: boolean;
};
const warningButtonClass = 'border-yellow-500/60 text-yellow-500 hover:bg-yellow-500/10';
@@ -23,6 +24,7 @@ export function UnsavedSaveButton({
isLoading,
status,
onClick,
+ disabled,
cleanLabel = 'Save',
}: UnsavedSaveButtonProps) {
return (
@@ -31,7 +33,7 @@ export function UnsavedSaveButton({
size="lg"
variant={isDirty ? 'secondary' : 'outline'}
onClick={onClick}
- disabled={isLoading}
+ disabled={isLoading || Boolean(disabled)}
className={isDirty ? warningButtonClass : 'cursor-pointer'}
>
{isDirty &&
}
diff --git a/apps/web/lib/http/index.ts b/apps/web/lib/http/index.ts
index a8671bfa60..9775e55ff6 100644
--- a/apps/web/lib/http/index.ts
+++ b/apps/web/lib/http/index.ts
@@ -4,6 +4,7 @@ import type {
ListAgentsResponse,
ListAgentDiscoveryResponse,
ListAvailableAgentsResponse,
+ AgentProfileMcpConfig,
ListBoardsResponse,
ListEnvironmentsResponse,
ListExecutorsResponse,
@@ -360,3 +361,10 @@ export async function listAvailableAgents(
): Promise
{
return fetchJson('/api/v1/agents/available', options);
}
+
+export async function getAgentProfileMcpConfig(
+ profileId: string,
+ options?: ApiRequestOptions
+): Promise {
+ return fetchJson(`/api/v1/agent-profiles/${profileId}/mcp-config`, options);
+}
diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts
index f1bf1a3caa..b56509b84d 100644
--- a/apps/web/lib/types/http.ts
+++ b/apps/web/lib/types/http.ts
@@ -377,6 +377,28 @@ export type Agent = {
updated_at: string;
};
+export type McpServerType = 'stdio' | 'http' | 'sse' | 'streamable_http';
+export type McpServerMode = 'shared' | 'per_session' | 'auto';
+
+export type McpServerDef = {
+ type?: McpServerType;
+ command?: string;
+ args?: string[];
+ env?: Record;
+ url?: string;
+ headers?: Record;
+ mode?: McpServerMode;
+ meta?: Record;
+ extra?: Record;
+};
+
+export type AgentProfileMcpConfig = {
+ profile_id: string;
+ enabled: boolean;
+ servers: Record;
+ meta?: Record;
+};
+
export type AgentDiscovery = {
name: string;
supports_mcp: boolean;