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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+35
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 "delete" to confirm deletion. This action cannot be undone.
|
||||
Type "delete" to confirm deletion. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user