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
This commit is contained in:
Carlos Florêncio
2026-01-21 01:13:20 +00:00
committed by GitHub
parent 7a1bb89fa9
commit 93251b48ce
49 changed files with 2498 additions and 488 deletions
+3 -1
View File
@@ -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
}
@@ -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) {
@@ -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),
@@ -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) {
@@ -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
}
@@ -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
@@ -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 {
@@ -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 {
@@ -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",
@@ -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
@@ -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
}
@@ -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)
}
@@ -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,
}
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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 @@
}
]
}
@@ -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"
)
@@ -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
@@ -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"`
}
@@ -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,
@@ -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"`
}
@@ -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()
@@ -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
+5 -6
View File
@@ -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
}
@@ -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()
@@ -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
}
@@ -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()
@@ -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})
}
@@ -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"
@@ -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
@@ -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)
}
+44 -3
View File
@@ -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
+16
View File
@@ -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<void>(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { method: 'DELETE' });
}
export async function getAgentProfileMcpConfigAction(profileId: string): Promise<AgentProfileMcpConfig> {
return fetchJson<AgentProfileMcpConfig>(`${apiBaseUrl}/api/v1/agent-profiles/${profileId}/mcp-config`);
}
export async function updateAgentProfileMcpConfigAction(
profileId: string,
payload: { enabled: boolean; mcpServers: Record<string, McpServerDef>; meta?: Record<string, unknown> }
): Promise<AgentProfileMcpConfig> {
return fetchJson<AgentProfileMcpConfig>(`${apiBaseUrl}/api/v1/agent-profiles/${profileId}/mcp-config`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
+157 -23
View File
@@ -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<Agent, 'profiles'> & { profiles: DraftProfile[]; isNew?: boolean };
const defaultMcpConfig: NonNullable<DraftProfile['mcp_config']> = {
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<DraftAgent>(initialAgent);
const [saveStatus, setSaveStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [newProfileId, setNewProfileId] = useState<string | null>(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<DraftProfile>) => {
@@ -170,11 +200,54 @@ function AgentSetupForm({
}));
};
const handleAgentFieldChange = (patch: Partial<DraftAgent>) => {
setDraftAgent((current) => ({ ...current, ...patch }));
const handleProfileMcpChange = (
profileId: string,
patch: Partial<NonNullable<DraftProfile['mcp_config']>>
) => {
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<string, McpServerDef>;
}
return parsed as Record<string, McpServerDef>;
};
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({
<CardTitle>{draftAgent.profiles[0]?.agent_display_name ?? draftAgent.name} Profiles</CardTitle>
{isAgentDirty && <UnsavedChangesBadge />}
</div>
<Button size="sm" variant="outline" onClick={handleAddProfile}>
<Button size="sm" variant="outline" onClick={handleAddProfile} className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-2" />
Add profile
</Button>
</CardHeader>
<CardContent className="space-y-4">
{draftAgent.supports_mcp && (
<div className="space-y-2">
<Label htmlFor="mcp-config-path">MCP Config Path</Label>
<Input
id="mcp-config-path"
value={draftAgent.mcp_config_path ?? ''}
onChange={(event) => handleAgentFieldChange({ mcp_config_path: event.target.value })}
placeholder="~/.copilot/mcp-config.json"
/>
</div>
)}
{draftAgent.profiles.map((profile) => (
<Card key={profile.id} className="border-muted">
{draftAgent.profiles.map((profile) => {
const isNew = profile.id === newProfileId;
return (
<Card
key={profile.id}
id={`profile-card-${profile.id}`}
className={isNew ? 'border-amber-400/70 shadow-sm' : 'border-muted'}
>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
@@ -396,9 +519,18 @@ function AgentSetupForm({
/>
</div>
</div>
<ProfileMcpConfigCard
profileId={profile.id}
supportsMcp={draftAgent.supports_mcp}
draftState={profile.id.startsWith('draft-') ? profile.mcp_config : undefined}
onDraftStateChange={(patch) => handleProfileMcpChange(profile.id, patch)}
onToastError={onToastError}
/>
</CardContent>
</Card>
))}
);
})}
</CardContent>
<div className="flex justify-end px-6 pb-6">
<UnsavedSaveButton
@@ -406,9 +538,11 @@ function AgentSetupForm({
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={handleSave}
disabled={hasInvalidMcpConfig}
/>
</div>
</Card>
</div>
);
}
@@ -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<string, Record<string, unknown>> = {
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<string, unknown> = {};
try {
parsed = JSON.parse(base) as Record<string, unknown>;
} 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<string, unknown>;
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle>MCP Configuration</CardTitle>
{currentDirty && <UnsavedChangesBadge />}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!isEditableProfile && (
<p className="text-xs text-muted-foreground">
{isDraft
? 'MCP config will be applied after the profile is saved.'
: 'Save this profile to configure MCP servers.'}
</p>
)}
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Enable MCP</Label>
<p className="text-xs text-muted-foreground">
Allow this profile to use MCP servers during sessions.
</p>
</div>
<Switch
checked={currentEnabled}
onCheckedChange={(checked) => {
if (isDraft) {
onDraftStateChange?.({ enabled: checked, dirty: true });
return;
}
setMcpEnabled(checked);
}}
disabled={!isEditableProfile && !isDraft}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`mcp-servers-${profileId}`}>MCP servers (JSON)</Label>
<Textarea
id={`mcp-servers-${profileId}`}
className="min-h-[200px] font-mono text-xs"
value={currentServers}
onChange={(event) => {
if (isDraft) {
handleDraftServersChange(event.target.value);
return;
}
handleMcpServersChange(event.target.value);
}}
disabled={!isEditableProfile && !isDraft}
/>
<p className="text-xs text-muted-foreground">
MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.</p>
<p className="text-xs font-medium text-muted-foreground">Popular servers</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => applyPopularServer('playwright')}
>
+ Playwright MCP
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => applyPopularServer('chrome-devtools')}
>
+ Chrome DevTools MCP
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => applyPopularServer('context7')}
>
+ Context7 MCP
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => applyPopularServer('github')}
>
+ GitHub MCP
</button>
</div>
{currentError && <p className="text-sm text-destructive">{currentError}</p>}
</div>
</CardContent>
<div className="flex justify-end px-6 pb-6">
{isEditableProfile ? (
<UnsavedSaveButton
isDirty={currentDirty}
isLoading={mcpStatus === 'loading'}
status={mcpStatus}
onClick={handleSaveMcp}
/>
) : null}
</div>
</Card>
);
}
@@ -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<AgentProfile>({ ...profile });
const [savedProfile, setSavedProfile] = useState<AgentProfile>(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 (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">{profile.agent_display_name} • {savedProfile.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
{profile.agent_display_name} profile settings
</p>
</div>
<div className="flex items-center gap-3">
{isDirty && <UnsavedChangesBadge />}
<UnsavedSaveButton
isDirty={isDirty}
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={handleSave}
/>
</div>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Profile settings</span>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Profile name</Label>
<Input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Default profile"
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
{modelConfig.available_models.length > 0 ? (
<Select
value={draft.model || modelConfig.default_model}
onValueChange={(value) => setDraft({ ...draft, model: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelConfig.available_models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
{model.is_default && (
<span className="ml-2 text-muted-foreground">(default)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={draft.model}
onChange={(event) => setDraft({ ...draft, model: event.target.value })}
placeholder="model-id"
/>
)}
</div>
<div className="space-y-2">
<Label>Append Prompt</Label>
<Textarea
value={draft.plan}
onChange={(event) => setDraft({ ...draft, plan: event.target.value })}
placeholder="Extra text appended to the agent prompt"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Auto-approve</Label>
<p className="text-xs text-muted-foreground">Automatically approve tool calls.</p>
</div>
<Switch
checked={draft.auto_approve}
onCheckedChange={(checked) =>
setDraft({ ...draft, auto_approve: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Skip permissions</Label>
<p className="text-xs text-muted-foreground">
Skip permission checks when running tools.
</p>
</div>
<Switch
checked={draft.dangerously_skip_permissions}
onCheckedChange={(checked) =>
setDraft({ ...draft, dangerously_skip_permissions: checked })
}
/>
</div>
</div>
</CardContent>
</Card>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Remove this profile</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
</div>
<Button variant="destructive" onClick={handleDeleteProfile}>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</CardContent>
</Card>
</div>
);
}
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 (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Profile not found.</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
</Button>
</CardContent>
</Card>
);
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 (
<ProfileEditor
key={profile.id}
agent={agent}
profile={profile}
modelConfig={modelConfig}
/>
<>
{Object.keys(initialState).length ? <StateHydrator initialState={initialState} /> : null}
<AgentProfilePage initialMcpConfig={initialMcpConfig} />
</>
);
}
@@ -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 };
}
@@ -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<void>;
};
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<AgentProfileMcpConfig | null>(
initialConfig ?? null
);
const [mcpEnabled, setMcpEnabledState] = useState(initialConfig?.enabled ?? false);
const [mcpServers, setMcpServers] = useState(initialServers);
const [mcpError, setMcpError] = useState<string | null>(null);
const [mcpDirty, setMcpDirty] = useState(false);
const [mcpStatus, setMcpStatus] = useState<McpStatus>('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<string, McpServerDef>;
}
return value as Record<string, McpServerDef>;
};
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<string, McpServerDef> = {};
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,
};
}
+186 -24
View File
@@ -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>({ ...executor });
const [savedExecutor, setSavedExecutor] = useState<Executor>({ ...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>('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.'}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => router.push('/settings/executors')}>
@@ -182,19 +204,159 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
</Card>
)}
{draft.type === 'remote_docker' && (
<Card>
<CardHeader>
<CardTitle>Remote Docker</CardTitle>
<CardDescription>Coming soon.</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Remote Docker executors will support SSH and cloud runtimes in a future update.
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
MCP Policy
<span className="rounded-full border border-muted-foreground/30 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
Advanced
</span>
</CardTitle>
<CardDescription>
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.
<p className="text-xs text-muted-foreground mt-2">
Examples: block stdio on remote runners, allowlist approved servers, or rewrite
localhost URLs for Docker/K8s networking.
</p>
</CardContent>
</Card>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Label htmlFor="mcp-policy">MCP policy JSON</Label>
<Textarea
id="mcp-policy"
value={draft.config?.mcp_policy ?? ''}
onChange={(event) =>
setDraft({
...draft,
config: { ...(draft.config ?? {}), mcp_policy: event.target.value },
})
}
placeholder='{"allow_stdio":true,"allow_http":true,"allowlist_servers":["github"],"url_rewrite":{"http://localhost:3000":"http://mcp-svc:3000"}}'
rows={8}
/>
{mcpPolicyError && <p className="text-xs text-destructive">{mcpPolicyError}</p>}
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">Quick presets</p>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => {
let parsed: Record<string, unknown> = {};
try {
if (draft.config?.mcp_policy?.trim()) {
parsed = JSON.parse(draft.config.mcp_policy) as Record<string, unknown>;
}
} catch {
parsed = {};
}
const next = {
...parsed,
allow_stdio: false,
allow_http: true,
allow_sse: true,
};
setDraft({
...draft,
config: { ...(draft.config ?? {}), mcp_policy: JSON.stringify(next, null, 2) },
});
}}
>
Only HTTP/SSE
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => {
let parsed: Record<string, unknown> = {};
try {
if (draft.config?.mcp_policy?.trim()) {
parsed = JSON.parse(draft.config.mcp_policy) as Record<string, unknown>;
}
} catch {
parsed = {};
}
const next = {
...parsed,
allow_stdio: true,
allow_http: false,
allow_sse: false,
};
setDraft({
...draft,
config: { ...(draft.config ?? {}), mcp_policy: JSON.stringify(next, null, 2) },
});
}}
>
Only stdio
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => {
let parsed: Record<string, unknown> = {};
try {
if (draft.config?.mcp_policy?.trim()) {
parsed = JSON.parse(draft.config.mcp_policy) as Record<string, unknown>;
}
} catch {
parsed = {};
}
const existing = Array.isArray(parsed.allowlist_servers)
? (parsed.allowlist_servers as string[])
: [];
const next = {
...parsed,
allowlist_servers: Array.from(
new Set([...existing, 'github', 'playwright'])
),
};
setDraft({
...draft,
config: { ...(draft.config ?? {}), mcp_policy: JSON.stringify(next, null, 2) },
});
}}
>
Allowlist GitHub + Playwright
</button>
<button
type="button"
className="text-xs rounded-full border border-muted-foreground/30 px-2 py-1 hover:bg-muted cursor-pointer"
onClick={() => {
let parsed: Record<string, unknown> = {};
try {
if (draft.config?.mcp_policy?.trim()) {
parsed = JSON.parse(draft.config.mcp_policy) as Record<string, unknown>;
}
} catch {
parsed = {};
}
const existing =
parsed.url_rewrite && typeof parsed.url_rewrite === 'object'
? (parsed.url_rewrite as Record<string, string>)
: {};
const next = {
...parsed,
url_rewrite: {
...existing,
'http://localhost:3000': 'http://host.docker.internal:3000',
},
};
setDraft({
...draft,
config: { ...(draft.config ?? {}), mcp_policy: JSON.stringify(next, null, 2) },
});
}}
>
Rewrite localhost for Docker
</button>
</div>
<p className="text-xs text-muted-foreground">
Leave empty to use the default policy for the runtime (local runs allow stdio + HTTP +
SSE, and apply no allowlist or URL rewrites).
</p>
</CardContent>
</Card>
<Separator />
@@ -202,7 +364,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
<Button variant="outline" onClick={() => router.push('/settings/executors')}>
Cancel
</Button>
<Button onClick={handleSaveExecutor} disabled={!isDirty || isSystem || isSaving}>
<Button onClick={handleSaveExecutor} disabled={!isDirty || Boolean(mcpPolicyError) || isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
@@ -230,7 +392,7 @@ function ExecutorEditForm({ executor }: ExecutorEditFormProps) {
<DialogHeader>
<DialogTitle>Delete Executor</DialogTitle>
<DialogDescription>
Type &quot;delete&quot; to confirm deletion. This action cannot be undone.
Type &quot;delete&quot; to confirm deletion. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
+9 -9
View File
@@ -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>('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() {
</SelectTrigger>
<SelectContent>
<SelectItem value="local_docker">Local Docker</SelectItem>
<SelectItem value="remote_docker" disabled>
Remote Docker (coming soon)
</SelectItem>
</SelectContent>
</Select>
</div>
+11 -27
View File
@@ -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() {
</div>
<div className="grid gap-3">
{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 (
<Link key={executor.id} href={`/settings/executor/${executor.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
+17 -7
View File
@@ -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,
@@ -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<AgentProfile>({ ...profile });
const [savedProfile, setSavedProfile] = useState<AgentProfile>(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 (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">{profile.agent_display_name} • {savedProfile.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
{profile.agent_display_name} profile settings
</p>
</div>
<div className="flex items-center gap-3">
{isDirty && <UnsavedChangesBadge />}
<UnsavedSaveButton
isDirty={isDirty}
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={handleSave}
/>
</div>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>Profile settings</span>
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Profile name</Label>
<Input
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Default profile"
/>
</div>
<div className="space-y-2">
<Label>Model</Label>
{modelConfig.available_models.length > 0 ? (
<Select
value={draft.model || modelConfig.default_model}
onValueChange={(value) => setDraft({ ...draft, model: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelConfig.available_models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
{model.is_default && (
<span className="ml-2 text-muted-foreground">(default)</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={draft.model}
onChange={(event) => setDraft({ ...draft, model: event.target.value })}
placeholder="model-id"
/>
)}
</div>
<div className="space-y-2">
<Label>Append Prompt</Label>
<Textarea
value={draft.plan}
onChange={(event) => setDraft({ ...draft, plan: event.target.value })}
placeholder="Extra text appended to the agent prompt"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Auto-approve</Label>
<p className="text-xs text-muted-foreground">Automatically approve tool calls.</p>
</div>
<Switch
checked={draft.auto_approve}
onCheckedChange={(checked) =>
setDraft({ ...draft, auto_approve: checked })
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<Label>Skip permissions</Label>
<p className="text-xs text-muted-foreground">
Skip permission checks when running tools.
</p>
</div>
<Switch
checked={draft.dangerously_skip_permissions}
onCheckedChange={(checked) =>
setDraft({ ...draft, dangerously_skip_permissions: checked })
}
/>
</div>
</div>
</CardContent>
</Card>
<ProfileMcpConfigCard
profileId={profile.id}
supportsMcp={agent.supports_mcp}
initialConfig={initialMcpConfig}
onToastError={(error) =>
toast({
title: 'Failed to save MCP config',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
})
}
/>
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Delete profile</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Remove this profile</p>
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
</div>
<Button variant="destructive" onClick={handleDeleteProfile}>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</CardContent>
</Card>
</div>
);
}
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 (
<Card>
<CardContent className="py-12 text-center">
<p className="text-sm text-muted-foreground">Profile not found.</p>
<Button className="mt-4" asChild>
<Link href="/settings/agents">Back to Agents</Link>
</Button>
</CardContent>
</Card>
);
}
return (
<ProfileEditor
key={profile.id}
agent={agent}
profile={profile}
modelConfig={modelConfig}
initialMcpConfig={initialMcpConfig}
/>
);
}
@@ -140,7 +140,7 @@ export function PromptsSettings() {
onSave={() => undefined}
showSaveButton={false}
>
<div className="rounded-lg border border-border/70 bg-muted/30 p-4 text-sm text-muted-foreground">
<div className="rounded-lg border border-border/70 bg-muted/30 p-4 text-xs text-muted-foreground">
Use <span className="font-medium text-foreground">@name</span> in the chat input to insert a
prompt’s content. Prompts are matched by name and expanded in place.
</div>
@@ -247,7 +247,7 @@ export function PromptsSettings() {
</div>
</div>
) : (
<div className="text-sm text-muted-foreground truncate">
<div className="text-xs text-muted-foreground truncate">
{getPromptPreview(prompt.content)}
</div>
)}
@@ -155,58 +155,6 @@ export function SettingsAppSidebar() {
)}
</SidebarMenuItem>
{/* Environments */}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Environments">
<Link href="/settings/environments">
<IconServer className="h-4 w-4" />
<span>Environments</span>
</Link>
</SidebarMenuButton>
{environments.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{environments.map((env) => (
<SidebarMenuSubItem key={env.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/environment/${env.id}`}
>
<Link href={`/settings/environment/${env.id}`}>
<span>{env.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* Executors */}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Executors">
<Link href="/settings/executors">
<IconCpu className="h-4 w-4" />
<span>Executors</span>
</Link>
</SidebarMenuButton>
{executors.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{executors.map((executor) => (
<SidebarMenuSubItem key={executor.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/executor/${executor.id}`}
>
<Link href={`/settings/executor/${executor.id}`}>
<span>{executor.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* Agents */}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Agents">
@@ -248,6 +196,60 @@ export function SettingsAppSidebar() {
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Environments */}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Environments">
<Link href="/settings/environments">
<IconServer className="h-4 w-4" />
<span>Environments</span>
</Link>
</SidebarMenuButton>
{environments.length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{environments.map((env) => (
<SidebarMenuSubItem key={env.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/environment/${env.id}`}
>
<Link href={`/settings/environment/${env.id}`}>
<span>{env.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
{/* Executors */}
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Executors">
<Link href="/settings/executors">
<IconCpu className="h-4 w-4" />
<span>Executors</span>
</Link>
</SidebarMenuButton>
{executors.filter((executor) => executor.type !== 'remote_docker').length > 0 && (
<SidebarMenuSub className="ml-3 mt-1">
{executors
.filter((executor) => executor.type !== 'remote_docker')
.map((executor) => (
<SidebarMenuSubItem key={executor.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/executor/${executor.id}`}
>
<Link href={`/settings/executor/${executor.id}`}>
<span>{executor.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -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 && <IconAlertCircle className="h-4 w-4 mr-2" />}
+8
View File
@@ -4,6 +4,7 @@ import type {
ListAgentsResponse,
ListAgentDiscoveryResponse,
ListAvailableAgentsResponse,
AgentProfileMcpConfig,
ListBoardsResponse,
ListEnvironmentsResponse,
ListExecutorsResponse,
@@ -360,3 +361,10 @@ export async function listAvailableAgents(
): Promise<ListAvailableAgentsResponse> {
return fetchJson<ListAvailableAgentsResponse>('/api/v1/agents/available', options);
}
export async function getAgentProfileMcpConfig(
profileId: string,
options?: ApiRequestOptions
): Promise<AgentProfileMcpConfig> {
return fetchJson<AgentProfileMcpConfig>(`/api/v1/agent-profiles/${profileId}/mcp-config`, options);
}
+22
View File
@@ -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<string, string>;
url?: string;
headers?: Record<string, string>;
mode?: McpServerMode;
meta?: Record<string, unknown>;
extra?: Record<string, unknown>;
};
export type AgentProfileMcpConfig = {
profile_id: string;
enabled: boolean;
servers: Record<string, McpServerDef>;
meta?: Record<string, unknown>;
};
export type AgentDiscovery = {
name: string;
supports_mcp: boolean;