From 37f4992ed59c9f442e664e030318d181d5dbfb68 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?=
Date: Thu, 13 Aug 2026 17:25:00 +0100
Subject: [PATCH] fix(agents): expose kandev MCP tools to custom TUI agents
(#2603)
Co-authored-by: Kandev Agent
Co-authored-by: Claude Opus 5 (1M context)
---
apps/backend/internal/agent/agents/agent.go | 25 ++-
.../internal/agent/agents/tui_agent.go | 27 ++-
.../internal/agent/agents/tui_agent_test.go | 75 +++++++
.../agent/mcpconfig/strategy_registry.go | 81 +++++++
.../agent/mcpconfig/strategy_registry_test.go | 68 ++++++
.../internal/agent/registry/custom_tui.go | 82 +++++--
.../agent/registry/custom_tui_mcp_test.go | 135 ++++++++++++
.../agent/registry/custom_tui_test.go | 14 +-
.../internal/agent/registry/registry_test.go | 2 +-
.../agent/runtime/lifecycle/container.go | 2 +-
.../runtime/lifecycle/executor_docker.go | 2 +-
.../runtime/lifecycle/executor_standalone.go | 2 +-
.../agent/settings/controller/controller.go | 2 +
.../agent/settings/controller/custom_tui.go | 112 +++++++++-
.../settings/controller/custom_tui_test.go | 201 ++++++++++++++++++
.../agent/settings/controller/profile_crud.go | 1 +
.../settings/controller/reconciler_test.go | 54 ++++-
.../internal/agent/settings/dto/dto.go | 2 +
.../settings/handlers/custom_tui_handlers.go | 65 +++++-
.../agent/settings/handlers/handlers.go | 2 +
.../internal/agent/settings/models/models.go | 7 +
.../internal/agent/settings/store/sqlite.go | 41 +++-
.../settings/store/sqlite_tui_update_test.go | 127 +++++++++++
apps/backend/internal/backendapp/services.go | 12 +-
...t_handlers_workflow_session_config_test.go | 8 +-
apps/backend/pkg/websocket/actions.go | 6 +
.../app/settings/agents/[agentId]/page.tsx | 18 +-
.../agents/custom-tui-mcp-mount.test.tsx | 109 ++++++++++
apps/web/app/settings/agents/page.tsx | 3 +
.../settings/add-tui-agent-dialog.tsx | 23 +-
.../settings/custom-tui-mcp-card.tsx | 78 +++++++
.../settings/mcp-strategy-select.test.tsx | 31 +++
.../settings/mcp-strategy-select.tsx | 114 ++++++++++
apps/web/eslint.i18n.options.mjs | 1 +
apps/web/lib/api/domains/settings-api.ts | 44 +++-
apps/web/lib/types/backend.ts | 11 +
apps/web/lib/types/http-agents.ts | 14 ++
.../handlers/agents-settings-updated.test.ts | 111 ++++++++++
apps/web/lib/ws/handlers/agents.ts | 21 ++
apps/web/src/locales/en/agents.json | 4 +
apps/web/src/locales/pseudo/agents.json | 4 +
apps/web/src/locales/pt-pt/agents.json | 22 +-
apps/web/src/locales/zh-cn/agents.json | 4 +
apps/web/src/locales/zh-hk/agents.json | 44 ++--
apps/web/src/locales/zh-tw/agents.json | 44 ++--
45 files changed, 1742 insertions(+), 113 deletions(-)
create mode 100644 apps/backend/internal/agent/mcpconfig/strategy_registry.go
create mode 100644 apps/backend/internal/agent/mcpconfig/strategy_registry_test.go
create mode 100644 apps/backend/internal/agent/registry/custom_tui_mcp_test.go
create mode 100644 apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go
create mode 100644 apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx
create mode 100644 apps/web/components/settings/custom-tui-mcp-card.tsx
create mode 100644 apps/web/components/settings/mcp-strategy-select.test.tsx
create mode 100644 apps/web/components/settings/mcp-strategy-select.tsx
create mode 100644 apps/web/lib/ws/handlers/agents-settings-updated.test.ts
diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go
index 5ca39f685c..902a021c59 100644
--- a/apps/backend/internal/agent/agents/agent.go
+++ b/apps/backend/internal/agent/agents/agent.go
@@ -117,12 +117,35 @@ type LoginAgent interface {
}
// IsPassthroughOnly returns true if the agent only supports passthrough mode
-// and should not have interactive MCP tools (e.g. ask_user_question) registered.
+// (a raw CLI under a PTY, no ACP protocol mode). It governs execution mode —
+// notably seeding the agent's default profile as CLI passthrough — and is true
+// for every TUI agent regardless of MCP configuration.
+//
+// For "should interactive MCP tools be registered?", use
+// SupportsInteractiveMCPTools instead. The two questions used to share this one
+// predicate, which is why a custom TUI agent could never get ask_user_question.
func IsPassthroughOnly(a Agent) bool {
_, ok := a.(*TUIAgent)
return ok
}
+// SupportsInteractiveMCPTools reports whether interactive MCP tools (notably
+// ask_user_question) should be registered for the agent's session.
+//
+// A TUI agent with no MCP injection strategy is a plain terminal tool that
+// never speaks MCP: registering a tool it cannot call just means a question
+// that hangs forever. A TUI agent *with* a strategy wraps a real MCP-capable
+// CLI that kandev points at the per-session server, so it should get the same
+// tools as the built-in claude-acp agent running in passthrough mode. Every
+// non-TUI agent supports them.
+func SupportsInteractiveMCPTools(a Agent) bool {
+ tui, ok := a.(*TUIAgent)
+ if !ok {
+ return true
+ }
+ return tui.MCPStrategy() != nil
+}
+
// LogoVariant selects light or dark logo.
type LogoVariant int
diff --git a/apps/backend/internal/agent/agents/tui_agent.go b/apps/backend/internal/agent/agents/tui_agent.go
index 58133b6bff..d29e0ea47d 100644
--- a/apps/backend/internal/agent/agents/tui_agent.go
+++ b/apps/backend/internal/agent/agents/tui_agent.go
@@ -4,6 +4,7 @@ import (
"context"
"time"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
@@ -29,6 +30,12 @@ type TUIAgentConfig struct {
ModelFlag Param // e.g. NewParam("--model", "{model}") — lets users pick a model in profile settings
CommandArgs []string // extra args after Command
DetectOpts []DetectOption // defaults to WithCommand(Command)
+ // MCPStrategy materializes kandev's per-session MCP server (and the
+ // profile's configured servers) into the wrapped CLI's own shape. Nil — the
+ // default — means no injection, which is correct for a TUI tool that is not
+ // an MCP client at all. Resolved from the user's stored strategy key; see
+ // mcpconfig.StrategyByKey for why it is chosen rather than inferred.
+ MCPStrategy mcpconfig.PassthroughMCPStrategy
}
// TUIAgent implements Agent + PassthroughAgent for CLI passthrough TUI tools.
@@ -75,6 +82,7 @@ func NewTUIAgent(cfg TUIAgentConfig) *TUIAgent {
IdleTimeout: cfg.IdleTimeout,
BufferMaxBytes: cfg.BufferMax,
WaitForTerminal: cfg.WaitForTerm,
+ MCPStrategy: cfg.MCPStrategy,
// Ink-based TUIs (Claude Code and similar) coalesce multi-byte
// stdin reads into a paste burst, absorbing a trailing "\r" into
// the pasted content instead of dispatching Enter, and enable
@@ -113,7 +121,24 @@ func (a *TUIAgent) Logo(v LogoVariant) []byte {
}
func (a *TUIAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
- return Detect(ctx, a.cfg.DetectOpts...)
+ result, err := Detect(ctx, a.cfg.DetectOpts...)
+ if err != nil {
+ return result, err
+ }
+ // Derive MCP support from the configured strategy rather than storing it as
+ // an independent flag. Discovery writes this value back over agents.supports_mcp
+ // on every sweep (see controller.upsertAgent), so anything set separately at
+ // creation time is reverted within a boot. Deriving makes that write the
+ // mechanism that keeps the flag correct instead of the thing fighting it,
+ // and makes "supports MCP but has no way to inject it" unrepresentable.
+ result.SupportsMCP = a.cfg.MCPStrategy != nil
+ return result, nil
+}
+
+// MCPStrategy returns the configured passthrough MCP injection strategy, or nil
+// when the agent does no injection.
+func (a *TUIAgent) MCPStrategy() mcpconfig.PassthroughMCPStrategy {
+ return a.cfg.MCPStrategy
}
func (a *TUIAgent) BuildCommand(_ CommandOptions) Command {
diff --git a/apps/backend/internal/agent/agents/tui_agent_test.go b/apps/backend/internal/agent/agents/tui_agent_test.go
index 7da4f35bd1..0be583c013 100644
--- a/apps/backend/internal/agent/agents/tui_agent_test.go
+++ b/apps/backend/internal/agent/agents/tui_agent_test.go
@@ -1,8 +1,11 @@
package agents
import (
+ "context"
"testing"
"time"
+
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
)
// Custom TUI agents wrap arbitrary CLIs, commonly Ink-based TUIs such as Claude
@@ -31,3 +34,75 @@ func TestNewTUIAgentDefaultsToInkSafePassthrough(t *testing.T) {
t.Errorf("effective submit sequence = %q, want %q", got, "\r")
}
}
+
+// TestTUIAgentDerivesSupportsMCPFromStrategy pins the mechanism that makes the
+// flag survive. Discovery writes IsInstalled's SupportsMCP back over the
+// agents.supports_mcp column on every sweep (controller.upsertAgent), so a flag
+// set independently at creation is reverted within a boot. Deriving it here
+// makes that write keep the flag correct instead of clobbering it.
+func TestTUIAgentDerivesSupportsMCPFromStrategy(t *testing.T) {
+ withStrategy := NewTUIAgent(TUIAgentConfig{
+ AgentID: "with", AgentName: "with", Command: "echo",
+ MCPStrategy: mcpconfig.ClaudeStrategy{},
+ })
+ result, err := withStrategy.IsInstalled(context.Background())
+ if err != nil {
+ t.Fatalf("IsInstalled: %v", err)
+ }
+ if !result.SupportsMCP {
+ t.Error("SupportsMCP = false for an agent with a strategy, want true")
+ }
+
+ without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"})
+ result, err = without.IsInstalled(context.Background())
+ if err != nil {
+ t.Fatalf("IsInstalled: %v", err)
+ }
+ if result.SupportsMCP {
+ t.Error("SupportsMCP = true for an agent with no strategy, want false")
+ }
+}
+
+// TestTUIAgentCarriesStrategyIntoPassthroughConfig pins the field the launch
+// path actually reads: applyPassthroughMCP returns immediately when
+// PassthroughConfig.MCPStrategy is nil, so a strategy that does not land here
+// injects nothing no matter what else is configured.
+func TestTUIAgentCarriesStrategyIntoPassthroughConfig(t *testing.T) {
+ a := NewTUIAgent(TUIAgentConfig{
+ AgentID: "x", AgentName: "x", Command: "echo",
+ MCPStrategy: mcpconfig.CodexStrategy{},
+ })
+ if a.PassthroughConfig().MCPStrategy == nil {
+ t.Fatal("PassthroughConfig.MCPStrategy = nil, want the configured strategy")
+ }
+}
+
+// TestInteractiveMCPToolsSplitFromPassthroughOnly pins the distinction between
+// two questions that used to share one predicate. IsPassthroughOnly governs
+// execution mode (it seeds the default profile as CLI passthrough), so it must
+// stay true for every TUI agent. Interactive-tool registration is separate: a
+// TUI agent wrapping a real MCP-capable CLI should get ask_user_question, which
+// it never could while both answers came from the same type assertion.
+func TestInteractiveMCPToolsSplitFromPassthroughOnly(t *testing.T) {
+ withStrategy := NewTUIAgent(TUIAgentConfig{
+ AgentID: "with", AgentName: "with", Command: "echo",
+ MCPStrategy: mcpconfig.ClaudeStrategy{},
+ })
+ without := NewTUIAgent(TUIAgentConfig{AgentID: "without", AgentName: "without", Command: "echo"})
+
+ // Both are passthrough-only: neither has an ACP protocol mode. Weakening
+ // this would seed them as ACP agents and break them outright.
+ if !IsPassthroughOnly(withStrategy) {
+ t.Error("IsPassthroughOnly = false for a TUI agent with a strategy, want true")
+ }
+ if !IsPassthroughOnly(without) {
+ t.Error("IsPassthroughOnly = false for a plain TUI agent, want true")
+ }
+
+ if !SupportsInteractiveMCPTools(withStrategy) {
+ t.Error("SupportsInteractiveMCPTools = false for a TUI agent with a strategy, want true")
+ }
+ if SupportsInteractiveMCPTools(without) {
+ t.Error("SupportsInteractiveMCPTools = true for a plain TUI agent, want false")
+ }
+}
diff --git a/apps/backend/internal/agent/mcpconfig/strategy_registry.go b/apps/backend/internal/agent/mcpconfig/strategy_registry.go
new file mode 100644
index 0000000000..299d0bbbd1
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/strategy_registry.go
@@ -0,0 +1,81 @@
+package mcpconfig
+
+// Strategy keys are the stable identifiers used to persist a passthrough MCP
+// strategy choice (custom TUI agents store one on their tui_config) and to
+// carry it over the settings API. Built-in agents declare their strategy in Go
+// and never round-trip through a key.
+//
+// A custom TUI agent wraps an arbitrary command — `zsh -ic "fuelclaude --model
+// opus"` is a real example — so kandev cannot infer which CLI is underneath or
+// how that CLI loads MCP servers. Guessing wrong is the worst outcome
+// available: the config file gets written, the CLI ignores it, and nothing
+// anywhere reports an error. The user picks the key instead.
+const (
+ // StrategyKeyNone means the agent does not get MCP injection. It is the
+ // default for custom TUI agents and the stored value for every agent
+ // created before this field existed.
+ StrategyKeyNone = ""
+ StrategyKeyClaude = "claude"
+ StrategyKeyCodex = "codex"
+ StrategyKeyCursor = "cursor"
+ StrategyKeyOpenCode = "opencode"
+ StrategyKeyPi = "pi"
+)
+
+// StrategyOption describes one selectable strategy for the settings UI.
+type StrategyOption struct {
+ Key string `json:"key"`
+ // Description is the strategy's own Describe() text, so the UI explains the
+ // actual mechanism ("an MCP config file passed via the --mcp-config flag")
+ // rather than restating the key.
+ Description string `json:"description"`
+}
+
+// strategyByKey is the single source of truth for key → implementation.
+// StrategyOptions and StrategyByKey both read it, so a new strategy becomes
+// selectable in the UI and resolvable at launch from one edit.
+var strategyByKey = map[string]PassthroughMCPStrategy{
+ StrategyKeyClaude: ClaudeStrategy{},
+ StrategyKeyCodex: CodexStrategy{},
+ StrategyKeyCursor: CursorStrategy{},
+ StrategyKeyOpenCode: OpenCodeStrategy{},
+ StrategyKeyPi: PiStrategy{},
+}
+
+// strategyKeyOrder fixes the presentation order of StrategyOptions. Map
+// iteration order is random, and a dropdown that reshuffles on every request is
+// unusable.
+var strategyKeyOrder = []string{
+ StrategyKeyClaude,
+ StrategyKeyCodex,
+ StrategyKeyCursor,
+ StrategyKeyOpenCode,
+ StrategyKeyPi,
+}
+
+// StrategyByKey resolves a persisted strategy key to its implementation.
+//
+// The empty key resolves to (nil, true): "this agent does no MCP injection" is
+// a valid stored value, not a failure. An unknown key returns ok=false so the
+// API layer can reject it up front — resolving it to nil instead would persist
+// a typo that silently disables MCP forever.
+func StrategyByKey(key string) (PassthroughMCPStrategy, bool) {
+ if key == StrategyKeyNone {
+ return nil, true
+ }
+ strategy, ok := strategyByKey[key]
+ return strategy, ok
+}
+
+// StrategyOptions returns the selectable strategies in stable order, each with
+// the mechanism description the UI shows next to it.
+func StrategyOptions() []StrategyOption {
+ options := make([]StrategyOption, 0, len(strategyKeyOrder))
+ for _, key := range strategyKeyOrder {
+ options = append(options, StrategyOption{
+ Key: key,
+ Description: strategyByKey[key].Describe(),
+ })
+ }
+ return options
+}
diff --git a/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go b/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go
new file mode 100644
index 0000000000..4301d00bb8
--- /dev/null
+++ b/apps/backend/internal/agent/mcpconfig/strategy_registry_test.go
@@ -0,0 +1,68 @@
+package mcpconfig
+
+import "testing"
+
+func TestStrategyByKeyResolvesEveryOfferedOption(t *testing.T) {
+ // Every key the UI can offer must resolve, or a user could pick an option
+ // that is rejected on save.
+ for _, opt := range StrategyOptions() {
+ strategy, ok := StrategyByKey(opt.Key)
+ if !ok {
+ t.Errorf("StrategyByKey(%q) not ok, but it is an offered option", opt.Key)
+ continue
+ }
+ if strategy == nil {
+ t.Errorf("StrategyByKey(%q) = nil, want an implementation", opt.Key)
+ }
+ if opt.Description == "" {
+ t.Errorf("option %q has an empty description", opt.Key)
+ }
+ }
+}
+
+func TestStrategyByKeyEmptyIsValidAndNil(t *testing.T) {
+ // "No MCP injection" is a legitimate stored value — the default for custom
+ // TUI agents and for every row written before the field existed.
+ strategy, ok := StrategyByKey(StrategyKeyNone)
+ if !ok {
+ t.Fatal("StrategyByKey(\"\") not ok, want ok (no-injection is valid)")
+ }
+ if strategy != nil {
+ t.Fatalf("StrategyByKey(\"\") = %T, want nil", strategy)
+ }
+}
+
+func TestStrategyByKeyRejectsUnknown(t *testing.T) {
+ // A typo must fail loudly at the API boundary. Resolving it to nil would
+ // persist a value that silently disables MCP with no error anywhere.
+ if _, ok := StrategyByKey("cluade"); ok {
+ t.Fatal("StrategyByKey(\"cluade\") ok = true, want false")
+ }
+}
+
+func TestStrategyOptionsAreStablyOrdered(t *testing.T) {
+ // Map iteration is randomised; a dropdown that reshuffles per request is
+ // unusable. Compare several passes rather than pinning exact contents.
+ first := StrategyOptions()
+ for range 20 {
+ next := StrategyOptions()
+ if len(next) != len(first) {
+ t.Fatalf("option count changed: %d then %d", len(first), len(next))
+ }
+ for i := range first {
+ if next[i].Key != first[i].Key {
+ t.Fatalf("option order changed at %d: %q then %q", i, first[i].Key, next[i].Key)
+ }
+ }
+ }
+}
+
+func TestStrategyOptionsExcludeNone(t *testing.T) {
+ // The empty key is represented by the UI's own "off" choice, not as a
+ // selectable strategy with a mechanism description.
+ for _, opt := range StrategyOptions() {
+ if opt.Key == StrategyKeyNone {
+ t.Fatal("StrategyOptions includes the empty key")
+ }
+ }
+}
diff --git a/apps/backend/internal/agent/registry/custom_tui.go b/apps/backend/internal/agent/registry/custom_tui.go
index 732054b9dc..b443af810d 100644
--- a/apps/backend/internal/agent/registry/custom_tui.go
+++ b/apps/backend/internal/agent/registry/custom_tui.go
@@ -5,38 +5,88 @@ import (
"strings"
"github.com/kandev/kandev/internal/agent/agents"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
)
-// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and registers it.
-// The command string is split into binary + args. Any {{model}} placeholder in the command
-// is replaced with the model value.
-func (r *Registry) RegisterCustomTUIAgent(slug, displayName, command, description, model string, commandArgs []string) error {
+// CustomTUIAgentSpec is the user-provided definition of a custom TUI agent, as
+// stored on the agent row's tui_config and replayed into the registry on boot.
+type CustomTUIAgentSpec struct {
+ Slug string
+ DisplayName string
+ Command string
+ Description string
+ Model string
+ CommandArgs []string
+ // MCPStrategyKey selects how kandev injects MCP servers into the wrapped
+ // CLI (see mcpconfig.StrategyByKey). Empty means no injection.
+ MCPStrategyKey string
+}
+
+// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
+// longer resolves — a typo from an older client, or a key removed by an
+// upgrade. Failing loudly beats registering the agent with MCP silently off.
+var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy")
+
+// buildCustomTUIAgent turns a user-provided spec into a TUIAgent. The command
+// string is split into binary + args, and any {{model}} placeholder in the
+// command is replaced with the model value.
+func buildCustomTUIAgent(spec CustomTUIAgentSpec) (*agents.TUIAgent, error) {
// Replace {{model}} template in the command string
- resolvedCommand := command
- if model != "" {
- resolvedCommand = strings.ReplaceAll(command, "{{model}}", model)
+ resolvedCommand := spec.Command
+ if spec.Model != "" {
+ resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model)
}
// Split command into binary + args
parts := strings.Fields(resolvedCommand)
if len(parts) == 0 {
- return fmt.Errorf("command is empty")
+ return nil, fmt.Errorf("command is empty")
}
binary := parts[0]
args := parts[1:]
- if len(commandArgs) > 0 {
- args = append(args, commandArgs...)
+ if len(spec.CommandArgs) > 0 {
+ args = append(args, spec.CommandArgs...)
}
- cfg := agents.TUIAgentConfig{
- AgentID: slug,
- AgentName: slug,
+ strategy, ok := mcpconfig.StrategyByKey(spec.MCPStrategyKey)
+ if !ok {
+ return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
+ }
+
+ return agents.NewTUIAgent(agents.TUIAgentConfig{
+ AgentID: spec.Slug,
+ AgentName: spec.Slug,
Command: binary,
- Desc: description,
- Display: displayName,
+ Desc: spec.Description,
+ Display: spec.DisplayName,
WaitForTerm: true,
CommandArgs: args,
+ MCPStrategy: strategy,
+ }), nil
+}
+
+// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and
+// registers it, failing if the ID is already taken.
+func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
+ tuiAgent, err := buildCustomTUIAgent(spec)
+ if err != nil {
+ return err
}
- tuiAgent := agents.NewTUIAgent(cfg)
return r.Register(tuiAgent)
}
+
+// ReplaceCustomTUIAgent rebuilds an already-registered custom TUI agent in
+// place. The MCP strategy is baked into the TUIAgent at construction, so
+// changing it needs a new instance rather than a mutation.
+//
+// This uses Replace rather than Unregister + Register because the pair leaves a
+// window with no entry for the ID: a session launching in that window fails
+// with "agent type not found", and a competing registration can leave the agent
+// missing until restart.
+func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error {
+ tuiAgent, err := buildCustomTUIAgent(spec)
+ if err != nil {
+ return err
+ }
+ return r.Replace(tuiAgent)
+}
diff --git a/apps/backend/internal/agent/registry/custom_tui_mcp_test.go b/apps/backend/internal/agent/registry/custom_tui_mcp_test.go
new file mode 100644
index 0000000000..e74079c8db
--- /dev/null
+++ b/apps/backend/internal/agent/registry/custom_tui_mcp_test.go
@@ -0,0 +1,135 @@
+package registry
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/agents"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
+ "github.com/kandev/kandev/internal/common/logger"
+)
+
+func newTestRegistry(t *testing.T) *Registry {
+ t.Helper()
+ log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
+ if err != nil {
+ t.Fatalf("logger: %v", err)
+ }
+ return NewRegistry(log)
+}
+
+func registeredStrategy(t *testing.T, reg *Registry, slug string) mcpconfig.PassthroughMCPStrategy {
+ t.Helper()
+ ag, ok := reg.Get(slug)
+ if !ok {
+ t.Fatalf("agent %q not registered", slug)
+ }
+ pt, ok := ag.(agents.PassthroughAgent)
+ if !ok {
+ t.Fatalf("agent %q does not implement PassthroughAgent", slug)
+ }
+ return pt.PassthroughConfig().MCPStrategy
+}
+
+// TestRegisterCustomTUIAgent_StrategyKeyReachesAgent covers the contract the
+// registration tests previously left untested: every case passed the zero-value
+// key, so a regression that dropped the strategy on the way to the agent would
+// not have failed anything here.
+func TestRegisterCustomTUIAgent_StrategyKeyReachesAgent(t *testing.T) {
+ reg := newTestRegistry(t)
+
+ if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
+ Slug: "with-strategy", DisplayName: "With", Command: "cli",
+ MCPStrategyKey: mcpconfig.StrategyKeyClaude,
+ }); err != nil {
+ t.Fatalf("RegisterCustomTUIAgent: %v", err)
+ }
+ if registeredStrategy(t, reg, "with-strategy") == nil {
+ t.Error("MCPStrategy = nil, want the Claude strategy")
+ }
+}
+
+func TestRegisterCustomTUIAgent_NoKeyLeavesStrategyNil(t *testing.T) {
+ reg := newTestRegistry(t)
+
+ if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
+ Slug: "no-strategy", DisplayName: "None", Command: "k9s",
+ }); err != nil {
+ t.Fatalf("RegisterCustomTUIAgent: %v", err)
+ }
+ if registeredStrategy(t, reg, "no-strategy") != nil {
+ t.Error("MCPStrategy set, want nil")
+ }
+}
+
+// TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering pins that a
+// bad key fails loudly instead of registering an agent whose MCP is silently off.
+func TestRegisterCustomTUIAgent_UnknownKeyRejectedWithoutRegistering(t *testing.T) {
+ reg := newTestRegistry(t)
+
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
+ Slug: "bad-key", DisplayName: "Bad", Command: "cli", MCPStrategyKey: "cluade",
+ })
+ if !errors.Is(err, ErrUnknownMCPStrategy) {
+ t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
+ }
+ if _, ok := reg.Get("bad-key"); ok {
+ t.Error("agent was registered despite the rejected strategy key")
+ }
+}
+
+// TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry is the reason
+// Replace is used instead of Unregister + Register: the pair leaves a window in
+// which the ID resolves to nothing, and a session launching then fails with
+// "agent type not found". The entry must be continuously resolvable.
+func TestReplaceCustomTUIAgent_SwapsStrategyWithoutRemovingEntry(t *testing.T) {
+ reg := newTestRegistry(t)
+ spec := CustomTUIAgentSpec{Slug: "swap-me", DisplayName: "Swap", Command: "cli --flag"}
+ if err := reg.RegisterCustomTUIAgent(spec); err != nil {
+ t.Fatalf("RegisterCustomTUIAgent: %v", err)
+ }
+
+ spec.MCPStrategyKey = mcpconfig.StrategyKeyCodex
+ if err := reg.ReplaceCustomTUIAgent(spec); err != nil {
+ t.Fatalf("ReplaceCustomTUIAgent: %v", err)
+ }
+
+ if registeredStrategy(t, reg, "swap-me") == nil {
+ t.Error("MCPStrategy = nil after replace")
+ }
+ // Replace must not duplicate or drop the entry, and the rest of the
+ // definition has to survive the rebuild.
+ ag, _ := reg.Get("swap-me")
+ want := []string{"cli", "--flag"}
+ got := ag.Runtime().Cmd.Args()
+ if len(got) != len(want) {
+ t.Fatalf("argv = %#v, want %#v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("argv = %#v, want %#v", got, want)
+ }
+ }
+}
+
+// TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact pins that a bad
+// key on an edit does not take the existing agent down with it.
+func TestReplaceCustomTUIAgent_RejectsUnknownKeyLeavingEntryIntact(t *testing.T) {
+ reg := newTestRegistry(t)
+ spec := CustomTUIAgentSpec{
+ Slug: "keep-me", DisplayName: "Keep", Command: "cli",
+ MCPStrategyKey: mcpconfig.StrategyKeyClaude,
+ }
+ if err := reg.RegisterCustomTUIAgent(spec); err != nil {
+ t.Fatalf("RegisterCustomTUIAgent: %v", err)
+ }
+
+ bad := spec
+ bad.MCPStrategyKey = "nope"
+ if err := reg.ReplaceCustomTUIAgent(bad); !errors.Is(err, ErrUnknownMCPStrategy) {
+ t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
+ }
+ if registeredStrategy(t, reg, "keep-me") == nil {
+ t.Error("existing agent lost its strategy after a rejected replace")
+ }
+}
diff --git a/apps/backend/internal/agent/registry/custom_tui_test.go b/apps/backend/internal/agent/registry/custom_tui_test.go
index 4584aec39c..5c38ed66a4 100644
--- a/apps/backend/internal/agent/registry/custom_tui_test.go
+++ b/apps/backend/internal/agent/registry/custom_tui_test.go
@@ -8,7 +8,7 @@ func TestRegisterCustomTUIAgent_Success(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
- err := reg.RegisterCustomTUIAgent("my-agent", "My Agent", "my-agent --verbose", "A test agent", "", nil)
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "my-agent", DisplayName: "My Agent", Command: "my-agent --verbose", Description: "A test agent", Model: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -41,7 +41,7 @@ func TestRegisterCustomTUIAgent_ModelTemplate(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
- err := reg.RegisterCustomTUIAgent("tmpl-agent", "Template", "my-cli --model {{model}}", "", "best", nil)
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "tmpl-agent", DisplayName: "Template", Command: "my-cli --model {{model}}", Description: "", Model: "best"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -69,7 +69,7 @@ func TestRegisterCustomTUIAgent_ModelTemplateNotReplacedWhenEmpty(t *testing.T)
log := newTestLogger()
reg := NewRegistry(log)
- err := reg.RegisterCustomTUIAgent("no-model", "No Model", "cli --model {{model}}", "", "", nil)
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "no-model", DisplayName: "No Model", Command: "cli --model {{model}}", Description: "", Model: ""})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -91,7 +91,7 @@ func TestRegisterCustomTUIAgent_CommandArgs(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
- err := reg.RegisterCustomTUIAgent("extra-args", "Extra", "my-cli", "", "", []string{"--extra", "--flag"})
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "extra-args", DisplayName: "Extra", Command: "my-cli", Description: "", Model: "", CommandArgs: []string{"--extra", "--flag"}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -107,7 +107,7 @@ func TestRegisterCustomTUIAgent_EmptyCommand(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
- err := reg.RegisterCustomTUIAgent("empty-cmd", "Empty", "", "", "", nil)
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "empty-cmd", DisplayName: "Empty", Command: "", Description: "", Model: ""})
if err == nil {
t.Error("expected error for empty command")
}
@@ -117,8 +117,8 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
- _ = reg.RegisterCustomTUIAgent("dup-agent", "First", "first-cli", "", "", nil)
- err := reg.RegisterCustomTUIAgent("dup-agent", "Second", "second-cli", "", "", nil)
+ _ = reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "First", Command: "first-cli", Description: "", Model: ""})
+ err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: "dup-agent", DisplayName: "Second", Command: "second-cli", Description: "", Model: ""})
if err == nil {
t.Error("expected error for duplicate registration")
}
diff --git a/apps/backend/internal/agent/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go
index ea15a5a54e..d7a2572ea9 100644
--- a/apps/backend/internal/agent/registry/registry_test.go
+++ b/apps/backend/internal/agent/registry/registry_test.go
@@ -406,7 +406,7 @@ func TestResolveFamilyIDsReportsEveryAmbiguousCandidate(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
reg := NewRegistry(newTestLogger())
reg.LoadDefaults()
- if err := reg.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil {
+ if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui", Description: "", Model: ""}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
diff --git a/apps/backend/internal/agent/runtime/lifecycle/container.go b/apps/backend/internal/agent/runtime/lifecycle/container.go
index 4c9f82e0e5..805a71492f 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/container.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/container.go
@@ -272,7 +272,7 @@ func (cm *ContainerManager) createInstanceAndClient(
if config.AgentConfig != nil {
agentType = config.AgentConfig.ID()
}
- disableAskQuestion := agents.IsPassthroughOnly(config.AgentConfig)
+ disableAskQuestion := !agents.SupportsInteractiveMCPTools(config.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go
index 121d6cdd34..c8eff5559b 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker.go
@@ -543,7 +543,7 @@ func buildReconnectCreateInstanceRequest(req *ExecutorCreateRequest, instanceID
var stripEnv []string
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
- disableAskQuestion = agents.IsPassthroughOnly(req.AgentConfig)
+ disableAskQuestion = !agents.SupportsInteractiveMCPTools(req.AgentConfig)
if rt := req.AgentConfig.Runtime(); rt != nil {
assumeMcpSse = rt.AssumeMcpSse
assumeMcpHttp = rt.AssumeMcpHttp
diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go
index c509ee99a3..5169cc9baf 100644
--- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go
+++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go
@@ -137,7 +137,7 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr
if req.AgentConfig != nil {
agentType = req.AgentConfig.ID()
}
- disableAskQuestion := agents.IsPassthroughOnly(req.AgentConfig)
+ disableAskQuestion := !agents.SupportsInteractiveMCPTools(req.AgentConfig)
assumeMcpSse := false
assumeMcpHttp := false
requiresProcessKill := false
diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go
index 37561078d4..5c8ed20925 100644
--- a/apps/backend/internal/agent/settings/controller/controller.go
+++ b/apps/backend/internal/agent/settings/controller/controller.go
@@ -49,6 +49,8 @@ var (
ErrCommandRequired = errors.New("command is required")
ErrInvalidProfileEnvVars = errors.New("invalid profile env vars")
ErrInvalidCommandPrefix = errors.New("invalid command prefix")
+ ErrUnknownMCPStrategy = errors.New("unknown MCP strategy")
+ ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent")
)
type Controller struct {
diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go
index cf6f19961b..3d73c7bf87 100644
--- a/apps/backend/internal/agent/settings/controller/custom_tui.go
+++ b/apps/backend/internal/agent/settings/controller/custom_tui.go
@@ -8,6 +8,8 @@ import (
"regexp"
"strings"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
+ "github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
)
@@ -29,6 +31,9 @@ type CreateCustomTUIAgentRequest struct {
Command string
Description string
CommandArgs []string
+ // MCPStrategy selects how kandev injects its per-session MCP server into
+ // the wrapped CLI. Empty means no MCP tools, which is the default.
+ MCPStrategy string
}
// CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database.
@@ -40,6 +45,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
if req.Command == "" {
return nil, ErrCommandRequired
}
+ if _, ok := mcpconfig.StrategyByKey(req.MCPStrategy); !ok {
+ return nil, ErrUnknownMCPStrategy
+ }
// Check for conflict with existing registry entry
if c.agentRegistry.Exists(slug) {
@@ -56,9 +64,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
}
// Register in the in-memory registry
- if regErr := c.agentRegistry.RegisterCustomTUIAgent(
- slug, req.DisplayName, req.Command, req.Description, req.Model, req.CommandArgs,
- ); regErr != nil {
+ if regErr := c.agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
+ Slug: slug,
+ DisplayName: req.DisplayName,
+ Command: req.Command,
+ Description: req.Description,
+ Model: req.Model,
+ CommandArgs: req.CommandArgs,
+ MCPStrategyKey: req.MCPStrategy,
+ }); regErr != nil {
return nil, fmt.Errorf("failed to register agent: %w", regErr)
}
@@ -70,10 +84,14 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
Description: req.Description,
CommandArgs: req.CommandArgs,
WaitForTerminal: true,
+ MCPStrategy: req.MCPStrategy,
}
agent := &models.Agent{
- Name: slug,
- TUIConfig: tuiConfig,
+ Name: slug,
+ // Mirrors what TUIAgent.IsInstalled derives, so the profile MCP editor
+ // works immediately instead of only after the next discovery sweep.
+ SupportsMCP: req.MCPStrategy != mcpconfig.StrategyKeyNone,
+ TUIConfig: tuiConfig,
}
if err := c.repo.CreateAgent(ctx, agent); err != nil {
// Rollback registry on DB failure
@@ -101,3 +119,87 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
result := toAgentDTO(agent, profiles)
return &result, nil
}
+
+// SetCustomTUIAgentMCPStrategy changes an existing custom TUI agent's MCP
+// injection strategy.
+//
+// Everything else about a tui_config is immutable — there is no edit route for
+// the command — but the strategy has to be mutable or every agent created
+// before this feature existed could never get MCP tools without being deleted
+// and rebuilt, losing its profiles and any session history pointing at it.
+//
+// The in-memory registry entry is rebuilt because the strategy is baked into
+// the TUIAgent at construction; the DB row is updated to match. Sessions
+// already running keep the strategy they launched with until they restart,
+// since the passthrough command is built once at launch.
+func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID, strategyKey string) (*dto.AgentDTO, error) {
+ if _, ok := mcpconfig.StrategyByKey(strategyKey); !ok {
+ return nil, ErrUnknownMCPStrategy
+ }
+
+ agent, err := c.repo.GetAgent(ctx, agentID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrAgentNotFound
+ }
+ return nil, err
+ }
+ if agent == nil {
+ return nil, ErrAgentNotFound
+ }
+ if agent.TUIConfig == nil {
+ // Built-in agents declare their strategy in Go; only custom TUI agents
+ // carry a user-selected one.
+ return nil, ErrNotCustomTUIAgent
+ }
+ if agent.TUIConfig.MCPStrategy == strategyKey {
+ return c.customTUIAgentDTO(ctx, agent)
+ }
+
+ previous := agent.TUIConfig.MCPStrategy
+ agent.TUIConfig.MCPStrategy = strategyKey
+ agent.SupportsMCP = strategyKey != mcpconfig.StrategyKeyNone
+ if err := c.repo.UpdateAgent(ctx, agent); err != nil {
+ return nil, err
+ }
+
+ if err := c.reregisterCustomTUIAgent(agent); err != nil {
+ // Roll the row back so the DB cannot claim a strategy the running
+ // registry does not have — that mismatch would persist until restart.
+ agent.TUIConfig.MCPStrategy = previous
+ agent.SupportsMCP = previous != mcpconfig.StrategyKeyNone
+ if rollbackErr := c.repo.UpdateAgent(ctx, agent); rollbackErr != nil {
+ return nil, fmt.Errorf("re-register agent: %w (rollback also failed: %v)", err, rollbackErr)
+ }
+ return nil, fmt.Errorf("failed to re-register agent: %w", err)
+ }
+
+ return c.customTUIAgentDTO(ctx, agent)
+}
+
+// reregisterCustomTUIAgent rebuilds the registry entry from the agent's stored
+// tui_config. The strategy is baked into the TUIAgent at construction, so a
+// changed strategy needs a new instance rather than a mutation. The swap is
+// atomic: Unregister + Register would leave a window in which a launching
+// session sees no entry for this agent ID.
+func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
+ cfg := agent.TUIConfig
+ return c.agentRegistry.ReplaceCustomTUIAgent(registry.CustomTUIAgentSpec{
+ Slug: agent.Name,
+ DisplayName: cfg.DisplayName,
+ Command: cfg.Command,
+ Description: cfg.Description,
+ Model: cfg.Model,
+ CommandArgs: cfg.CommandArgs,
+ MCPStrategyKey: cfg.MCPStrategy,
+ })
+}
+
+func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
+ profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
+ if err != nil {
+ return nil, err
+ }
+ result := toAgentDTO(agent, filterGlobalProfiles(profiles))
+ return &result, nil
+}
diff --git a/apps/backend/internal/agent/settings/controller/custom_tui_test.go b/apps/backend/internal/agent/settings/controller/custom_tui_test.go
index b6d5a68357..4bac2cfe75 100644
--- a/apps/backend/internal/agent/settings/controller/custom_tui_test.go
+++ b/apps/backend/internal/agent/settings/controller/custom_tui_test.go
@@ -2,10 +2,14 @@ package controller
import (
"context"
+ "errors"
"slices"
"testing"
+ "github.com/kandev/kandev/internal/agent/agents"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
+ "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -99,3 +103,200 @@ func TestCreateCustomTUIAgent_NoCommandArgs(t *testing.T) {
t.Errorf("stored CommandArgs = %#v, want empty", got)
}
}
+
+// --- MCP strategy ------------------------------------------------------------
+
+// TestCreateCustomTUIAgent_MCPStrategyEnablesInjection is the test that
+// distinguishes the fix from the bug in issue #2474. Before the fix a custom
+// TUI agent was registered with no MCP strategy and supports_mcp=0, so
+// applyPassthroughMCP was a no-op and the mcpconfig service refused the profile
+// — kandev's own session tools were unreachable with no way to turn them on.
+func TestCreateCustomTUIAgent_MCPStrategyEnablesInjection(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Fuel Claude",
+ Command: "fuelclaude",
+ MCPStrategy: mcpconfig.StrategyKeyClaude,
+ }); err != nil {
+ t.Fatalf("CreateCustomTUIAgent: %v", err)
+ }
+
+ ag, ok := c.agentRegistry.Get("fuel-claude")
+ if !ok {
+ t.Fatal("agent not registered")
+ }
+ pt, ok := ag.(agents.PassthroughAgent)
+ if !ok {
+ t.Fatal("agent does not implement PassthroughAgent")
+ }
+ // The strategy is what applyPassthroughMCP keys off; nil means the CLI is
+ // never told the per-session MCP server exists.
+ if pt.PassthroughConfig().MCPStrategy == nil {
+ t.Error("PassthroughConfig.MCPStrategy = nil, want the Claude strategy")
+ }
+ // supports_mcp gates the profile MCP editor and the mcpconfig service.
+ if stored := st.byName["fuel-claude"]; !stored.SupportsMCP {
+ t.Error("stored SupportsMCP = false, want true")
+ }
+ if got := st.byName["fuel-claude"].TUIConfig.MCPStrategy; got != mcpconfig.StrategyKeyClaude {
+ t.Errorf("stored MCPStrategy = %q, want %q", got, mcpconfig.StrategyKeyClaude)
+ }
+}
+
+// TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff pins that the default is
+// unchanged: a plain TUI tool that never speaks MCP must not advertise support.
+func TestCreateCustomTUIAgent_NoStrategyLeavesMCPOff(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Plain Tool",
+ Command: "k9s",
+ }); err != nil {
+ t.Fatalf("CreateCustomTUIAgent: %v", err)
+ }
+
+ ag, _ := c.agentRegistry.Get("plain-tool")
+ if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
+ t.Error("MCPStrategy set, want nil for an agent created without one")
+ }
+ if st.byName["plain-tool"].SupportsMCP {
+ t.Error("stored SupportsMCP = true, want false")
+ }
+}
+
+// TestCreateCustomTUIAgent_RejectsUnknownStrategy pins that a bad key fails at
+// creation. Accepting it would persist an agent whose MCP is silently off
+// forever, with no error surfaced anywhere.
+func TestCreateCustomTUIAgent_RejectsUnknownStrategy(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Typo Agent",
+ Command: "some-cli",
+ MCPStrategy: "cluade",
+ })
+ if !errors.Is(err, ErrUnknownMCPStrategy) {
+ t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
+ }
+ if _, registered := c.agentRegistry.Get("typo-agent"); registered {
+ t.Error("agent was registered despite the rejected strategy")
+ }
+ if _, stored := st.byName["typo-agent"]; stored {
+ t.Error("agent was persisted despite the rejected strategy")
+ }
+}
+
+// TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent covers the upgrade path:
+// every custom TUI agent created before this feature has an empty strategy, and
+// there is no route to edit a tui_config. Without this they could only get MCP
+// by being deleted and rebuilt, losing their profiles.
+func TestSetCustomTUIAgentMCPStrategy_OptsInExistingAgent(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Legacy Agent",
+ Command: "legacy-cli --flag",
+ })
+ if err != nil {
+ t.Fatalf("CreateCustomTUIAgent: %v", err)
+ }
+
+ got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyCodex)
+ if err != nil {
+ t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
+ }
+ if !got.SupportsMCP {
+ t.Error("returned SupportsMCP = false, want true")
+ }
+
+ ag, ok := c.agentRegistry.Get("legacy-agent")
+ if !ok {
+ t.Fatal("agent missing from registry after re-register")
+ }
+ pt, ok := ag.(agents.PassthroughAgent)
+ if !ok {
+ t.Fatal("agent does not implement PassthroughAgent")
+ }
+ if pt.PassthroughConfig().MCPStrategy == nil {
+ t.Error("MCPStrategy = nil after opting in")
+ }
+ // The rest of the definition must survive the rebuild — the re-register
+ // reconstructs the agent from stored config, so a dropped field here would
+ // silently change the command the user launches.
+ want := []string{"legacy-cli", "--flag"}
+ if argv := ag.Runtime().Cmd.Args(); !slices.Equal(argv, want) {
+ t.Errorf("argv = %#v, want %#v", argv, want)
+ }
+ if stored := st.byName["legacy-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyCodex {
+ t.Errorf("stored strategy = %q, want %q", stored.TUIConfig.MCPStrategy, mcpconfig.StrategyKeyCodex)
+ }
+}
+
+// TestSetCustomTUIAgentMCPStrategy_TurnsOff pins the reverse direction: clearing
+// the strategy must also clear supports_mcp, or the profile MCP editor would
+// stay visible for an agent that can no longer receive any servers.
+func TestSetCustomTUIAgentMCPStrategy_TurnsOff(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Toggle Agent",
+ Command: "toggle-cli",
+ MCPStrategy: mcpconfig.StrategyKeyClaude,
+ })
+ if err != nil {
+ t.Fatalf("CreateCustomTUIAgent: %v", err)
+ }
+
+ got, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, mcpconfig.StrategyKeyNone)
+ if err != nil {
+ t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
+ }
+ if got.SupportsMCP {
+ t.Error("returned SupportsMCP = true, want false after clearing the strategy")
+ }
+ ag, _ := c.agentRegistry.Get("toggle-agent")
+ if pt, ok := ag.(agents.PassthroughAgent); ok && pt.PassthroughConfig().MCPStrategy != nil {
+ t.Error("MCPStrategy still set after clearing")
+ }
+}
+
+func TestSetCustomTUIAgentMCPStrategy_RejectsNonTUIAgent(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ builtin := &models.Agent{Name: "claude-acp"}
+ if err := st.CreateAgent(context.Background(), builtin); err != nil {
+ t.Fatalf("CreateAgent: %v", err)
+ }
+
+ _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), builtin.ID, mcpconfig.StrategyKeyClaude)
+ if !errors.Is(err, ErrNotCustomTUIAgent) {
+ t.Fatalf("err = %v, want ErrNotCustomTUIAgent", err)
+ }
+}
+
+func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) {
+ st := newFakeStore()
+ c := newCustomTUIController(t, st)
+
+ created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
+ DisplayName: "Guard Agent",
+ Command: "guard-cli",
+ })
+ if err != nil {
+ t.Fatalf("CreateCustomTUIAgent: %v", err)
+ }
+
+ if _, err := c.SetCustomTUIAgentMCPStrategy(context.Background(), created.ID, "nope"); !errors.Is(err, ErrUnknownMCPStrategy) {
+ t.Fatalf("err = %v, want ErrUnknownMCPStrategy", err)
+ }
+ if stored := st.byName["guard-agent"]; stored.TUIConfig.MCPStrategy != mcpconfig.StrategyKeyNone {
+ t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy)
+ }
+}
diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go
index 1ee348fa41..b8cbd16faa 100644
--- a/apps/backend/internal/agent/settings/controller/profile_crud.go
+++ b/apps/backend/internal/agent/settings/controller/profile_crud.go
@@ -783,6 +783,7 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD
Description: agent.TUIConfig.Description,
CommandArgs: agent.TUIConfig.CommandArgs,
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
+ MCPStrategy: agent.TUIConfig.MCPStrategy,
}
}
return result
diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go
index 88021351db..06aa3fc4c0 100644
--- a/apps/backend/internal/agent/settings/controller/reconciler_test.go
+++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go
@@ -3,7 +3,9 @@ package controller
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
+ "fmt"
"strconv"
"testing"
"time"
@@ -62,16 +64,41 @@ func newFakeStore() *fakeStore {
}
}
+// copyAgent deep-copies an agent so the fake store behaves like a real one:
+// callers get a snapshot, not a handle on stored state.
+//
+// This matters more than it looks. When Get returned the stored pointer and
+// UpdateAgent was a no-op, a controller could mutate a nested field, never
+// persist it, and every assertion still passed — the test was reading the same
+// struct it had just written through. That masked UpdateAgent silently dropping
+// the tui_config column. Round-tripping through JSON forces writes to go through
+// UpdateAgent to be observable, the same as SQL.
+func copyAgent(a *models.Agent) *models.Agent {
+ if a == nil {
+ return nil
+ }
+ data, err := json.Marshal(a)
+ if err != nil {
+ panic("fakeStore: marshal agent: " + err.Error())
+ }
+ var out models.Agent
+ if err := json.Unmarshal(data, &out); err != nil {
+ panic("fakeStore: unmarshal agent: " + err.Error())
+ }
+ return &out
+}
+
func (f *fakeStore) CreateAgent(_ context.Context, a *models.Agent) error {
f.nextAgentID++
a.ID = "agent-" + strconv.Itoa(f.nextAgentID)
- f.agents[a.ID] = a
- f.byName[a.Name] = a
+ stored := copyAgent(a)
+ f.agents[a.ID] = stored
+ f.byName[a.Name] = stored
return nil
}
func (f *fakeStore) GetAgent(_ context.Context, id string) (*models.Agent, error) {
- return f.agents[id], nil
+ return copyAgent(f.agents[id]), nil
}
func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agent, error) {
@@ -79,13 +106,28 @@ func (f *fakeStore) GetAgentByName(_ context.Context, name string) (*models.Agen
return nil, f.getByNameErr
}
if a, ok := f.byName[name]; ok {
- return a, nil
+ return copyAgent(a), nil
}
return nil, sql.ErrNoRows
}
-func (f *fakeStore) UpdateAgent(context.Context, *models.Agent) error { return nil }
-func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil }
+func (f *fakeStore) UpdateAgent(_ context.Context, a *models.Agent) error {
+ existing, ok := f.agents[a.ID]
+ if !ok {
+ return fmt.Errorf("agent not found: %s", a.ID)
+ }
+ stored := copyAgent(a)
+ // Mirror the real store's column list so the fake cannot accept a write the
+ // database would reject or ignore.
+ existing.WorkspaceID = stored.WorkspaceID
+ existing.SupportsMCP = stored.SupportsMCP
+ existing.MCPConfigPath = stored.MCPConfigPath
+ existing.TUIConfig = stored.TUIConfig
+ f.byName[existing.Name] = existing
+ return nil
+}
+
+func (f *fakeStore) DeleteAgent(context.Context, string) error { return nil }
func (f *fakeStore) ListAgents(_ context.Context) ([]*models.Agent, error) {
if f.listAgentsErr != nil {
diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go
index eff69305d0..5f3f3282f4 100644
--- a/apps/backend/internal/agent/settings/dto/dto.go
+++ b/apps/backend/internal/agent/settings/dto/dto.go
@@ -79,6 +79,8 @@ type TUIConfigDTO struct {
Description string `json:"description,omitempty"`
CommandArgs []string `json:"command_args,omitempty"`
WaitForTerminal bool `json:"wait_for_terminal"`
+ // MCPStrategy is the selected MCP injection mechanism ("" = none).
+ MCPStrategy string `json:"mcp_strategy,omitempty"`
}
type AgentDTO struct {
diff --git a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go
index 9cb977ecbe..9d5849068b 100644
--- a/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go
+++ b/apps/backend/internal/agent/settings/handlers/custom_tui_handlers.go
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
+ "github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/settings/controller"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -19,6 +20,9 @@ type createCustomTUIAgentRequest struct {
// which the registry splits on whitespace — each entry is one argv element,
// so an argument containing a space can only be expressed here.
CommandArgs []string `json:"command_args"`
+ // MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can
+ // point it at the per-session server. Empty (the default) = no MCP tools.
+ MCPStrategy string `json:"mcp_strategy"`
}
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
@@ -28,6 +32,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust
Command: r.Command,
Description: r.Description,
CommandArgs: r.CommandArgs,
+ MCPStrategy: r.MCPStrategy,
}
}
@@ -49,7 +54,7 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest())
if err != nil {
switch err {
- case controller.ErrInvalidSlug, controller.ErrCommandRequired:
+ case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -82,3 +87,61 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
+
+type updateCustomTUIAgentMCPRequest struct {
+ MCPStrategy string `json:"mcp_strategy"`
+}
+
+// httpListMCPStrategies serves the selectable MCP injection strategies. The
+// list is served rather than hardcoded in the client so a strategy added in Go
+// becomes selectable without a matching frontend edit, and so the UI can show
+// each strategy's own description of the mechanism it uses.
+func (h *Handlers) httpListMCPStrategies(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"strategies": mcpconfig.StrategyOptions()})
+}
+
+// httpUpdateCustomTUIAgentMCP changes an existing custom TUI agent's MCP
+// injection strategy. The rest of a tui_config stays immutable; this exists so
+// agents created before the field existed can opt in without being deleted and
+// rebuilt.
+func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) {
+ var body updateCustomTUIAgentMCPRequest
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
+ return
+ }
+
+ resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
+ if err != nil {
+ switch err {
+ case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent:
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ case controller.ErrAgentNotFound:
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ default:
+ h.logger.Error("failed to update custom TUI agent MCP strategy", zap.Error(err))
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ }
+ return
+ }
+
+ // The agent's supports_mcp and tui_config both changed; refresh the agent
+ // list so open settings tabs show the MCP editor appearing or disappearing
+ // without a reload.
+ //
+ // ActionAgentSettingsUpdated, not ActionAgentUpdated: the latter is a
+ // runtime status ping shaped {agentId, status} feeding a different store
+ // slice, so sending an agent record on it inserts {id: undefined} into the
+ // runtime list instead of updating settings.
+ //
+ //ws:global agent settings are not workspace-scoped — the agents list is a
+ // global settings surface, so every connected client's copy is stale after
+ // this change. Matches the sibling agent.profile.* and agent.available.updated
+ // broadcasts in this package.
+ if h.hub != nil {
+ notification, _ := ws.NewNotification(ws.ActionAgentSettingsUpdated, gin.H{"agent": resp})
+ h.hub.Broadcast(notification)
+ }
+
+ c.JSON(http.StatusOK, resp)
+}
diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go
index 3cac96ea57..bb6e17385f 100644
--- a/apps/backend/internal/agent/settings/handlers/handlers.go
+++ b/apps/backend/internal/agent/settings/handlers/handlers.go
@@ -58,6 +58,8 @@ func (h *Handlers) registerHTTP(router *gin.Engine) {
api.GET("/agents", h.httpListAgents)
api.POST("/agents", h.interlock, h.httpCreateAgent)
api.POST("/agents/tui", h.interlock, h.httpCreateCustomTUIAgent)
+ api.GET("/agents/tui/mcp-strategies", h.httpListMCPStrategies)
+ api.PATCH("/agents/tui/:id/mcp", h.interlock, h.httpUpdateCustomTUIAgentMCP)
api.GET("/agents/:id", h.httpGetAgent)
api.PATCH("/agents/:id", h.interlock, h.httpUpdateAgent)
api.DELETE("/agents/:id", h.interlock, h.httpDeleteAgent)
diff --git a/apps/backend/internal/agent/settings/models/models.go b/apps/backend/internal/agent/settings/models/models.go
index 639cb18f72..0c9c970a6d 100644
--- a/apps/backend/internal/agent/settings/models/models.go
+++ b/apps/backend/internal/agent/settings/models/models.go
@@ -29,6 +29,13 @@ type TUIConfigJSON struct {
Description string `json:"description,omitempty"`
CommandArgs []string `json:"command_args,omitempty"`
WaitForTerminal bool `json:"wait_for_terminal"`
+ // MCPStrategy selects how kandev injects its per-session MCP server into
+ // the wrapped CLI (mcpconfig.StrategyKey*). Empty — the value every row
+ // written before this field existed decodes to — means no injection.
+ // Stored here rather than as an agent column because it is part of the
+ // user's CLI definition, and because tui_config is JSON: adding the field
+ // needs no migration.
+ MCPStrategy string `json:"mcp_strategy,omitempty"`
}
type AgentProfile struct {
diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go
index cdee34d97c..0613cf8588 100644
--- a/apps/backend/internal/agent/settings/store/sqlite.go
+++ b/apps/backend/internal/agent/settings/store/sqlite.go
@@ -351,6 +351,22 @@ func (r *sqliteRepository) Close() error {
return r.db.Close()
}
+// marshalTUIConfig renders a TUI config for the nullable tui_config column.
+// Shared by CreateAgent and UpdateAgent so the two writers cannot drift — the
+// column being absent from one of them is exactly how an editable field was
+// lost on save.
+func marshalTUIConfig(cfg *models.TUIConfigJSON) (*string, error) {
+ if cfg == nil {
+ return nil, nil
+ }
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal tui_config: %w", err)
+ }
+ s := string(data)
+ return &s, nil
+}
+
func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent) error {
if agent.ID == "" {
agent.ID = uuid.New().String()
@@ -358,16 +374,11 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent)
now := time.Now().UTC()
agent.CreatedAt = now
agent.UpdatedAt = now
- var tuiConfigJSON *string
- if agent.TUIConfig != nil {
- data, err := json.Marshal(agent.TUIConfig)
- if err != nil {
- return fmt.Errorf("failed to marshal tui_config: %w", err)
- }
- s := string(data)
- tuiConfigJSON = &s
+ tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
+ if err != nil {
+ return err
}
- _, err := r.db.ExecContext(ctx, r.db.Rebind(`
+ _, err = r.db.ExecContext(ctx, r.db.Rebind(`
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`), agent.ID, agent.Name, agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.CreatedAt, agent.UpdatedAt)
@@ -392,10 +403,18 @@ func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*mo
func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent) error {
agent.UpdatedAt = time.Now().UTC()
+ // tui_config is written here as well as in CreateAgent: a custom TUI agent's
+ // MCP strategy is editable after creation, and omitting the column silently
+ // discarded that edit — the row kept supports_mcp while the strategy it was
+ // derived from reverted on the next boot.
+ tuiConfigJSON, err := marshalTUIConfig(agent.TUIConfig)
+ if err != nil {
+ return err
+ }
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
- UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
+ UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, tui_config = ?, updated_at = ?
WHERE id = ?
- `), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
+ `), agent.WorkspaceID, dialect.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, tuiConfigJSON, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
diff --git a/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go b/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go
new file mode 100644
index 0000000000..c5bf8e17f7
--- /dev/null
+++ b/apps/backend/internal/agent/settings/store/sqlite_tui_update_test.go
@@ -0,0 +1,127 @@
+package store
+
+import (
+ "context"
+ "testing"
+
+ "github.com/kandev/kandev/internal/agent/settings/models"
+)
+
+// TestUpdateAgent_PersistsTUIConfig pins the column that UpdateAgent used to
+// omit. A custom TUI agent's MCP strategy is editable after creation, and the
+// UPDATE statement listed only workspace_id/supports_mcp/mcp_config_path — so
+// the edit was accepted, supports_mcp was written, and the strategy it is
+// derived from silently reverted on the next read. Round-tripping through the
+// real store is the only thing that catches this: a fake repository that
+// returns the same pointer it was handed reports success either way.
+func TestUpdateAgent_PersistsTUIConfig(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+
+ agent := &models.Agent{
+ Name: "fuel-claude",
+ TUIConfig: &models.TUIConfigJSON{
+ Command: "fuelclaude",
+ DisplayName: "Fuel Claude",
+ WaitForTerminal: true,
+ },
+ }
+ if err := repo.CreateAgent(ctx, agent); err != nil {
+ t.Fatalf("CreateAgent: %v", err)
+ }
+
+ agent.TUIConfig.MCPStrategy = "claude"
+ agent.SupportsMCP = true
+ if err := repo.UpdateAgent(ctx, agent); err != nil {
+ t.Fatalf("UpdateAgent: %v", err)
+ }
+
+ // Re-read from the database rather than inspecting the struct we just
+ // mutated — that distinction is the entire point of this test.
+ stored, err := repo.GetAgent(ctx, agent.ID)
+ if err != nil {
+ t.Fatalf("GetAgent: %v", err)
+ }
+ if stored.TUIConfig == nil {
+ t.Fatal("TUIConfig = nil after update")
+ }
+ if stored.TUIConfig.MCPStrategy != "claude" {
+ t.Errorf("MCPStrategy = %q, want %q", stored.TUIConfig.MCPStrategy, "claude")
+ }
+ if !stored.SupportsMCP {
+ t.Error("SupportsMCP = false, want true")
+ }
+ // The rest of the config must survive the write: UpdateAgent rewrites the
+ // whole JSON blob, so a dropped field here would quietly change the command
+ // the user launches.
+ if stored.TUIConfig.Command != "fuelclaude" {
+ t.Errorf("Command = %q, want %q", stored.TUIConfig.Command, "fuelclaude")
+ }
+ if stored.TUIConfig.DisplayName != "Fuel Claude" {
+ t.Errorf("DisplayName = %q, want %q", stored.TUIConfig.DisplayName, "Fuel Claude")
+ }
+ if !stored.TUIConfig.WaitForTerminal {
+ t.Error("WaitForTerminal = false, want true")
+ }
+}
+
+// TestUpdateAgent_ClearsTUIStrategy pins the off direction, which shares the
+// same write path.
+func TestUpdateAgent_ClearsTUIStrategy(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+
+ agent := &models.Agent{
+ Name: "toggle-agent",
+ SupportsMCP: true,
+ TUIConfig: &models.TUIConfigJSON{
+ Command: "toggle-cli",
+ DisplayName: "Toggle",
+ MCPStrategy: "codex",
+ },
+ }
+ if err := repo.CreateAgent(ctx, agent); err != nil {
+ t.Fatalf("CreateAgent: %v", err)
+ }
+
+ agent.TUIConfig.MCPStrategy = ""
+ agent.SupportsMCP = false
+ if err := repo.UpdateAgent(ctx, agent); err != nil {
+ t.Fatalf("UpdateAgent: %v", err)
+ }
+
+ stored, err := repo.GetAgent(ctx, agent.ID)
+ if err != nil {
+ t.Fatalf("GetAgent: %v", err)
+ }
+ if stored.TUIConfig.MCPStrategy != "" {
+ t.Errorf("MCPStrategy = %q, want empty", stored.TUIConfig.MCPStrategy)
+ }
+ if stored.SupportsMCP {
+ t.Error("SupportsMCP = true, want false")
+ }
+}
+
+// TestUpdateAgent_NonTUIAgentKeepsNullConfig guards the nil path: a built-in
+// agent has no tui_config and must not gain one.
+func TestUpdateAgent_NonTUIAgentKeepsNullConfig(t *testing.T) {
+ repo := newTestRepo(t)
+ ctx := context.Background()
+
+ agent := &models.Agent{Name: "claude-acp"}
+ if err := repo.CreateAgent(ctx, agent); err != nil {
+ t.Fatalf("CreateAgent: %v", err)
+ }
+ agent.SupportsMCP = true
+ if err := repo.UpdateAgent(ctx, agent); err != nil {
+ t.Fatalf("UpdateAgent: %v", err)
+ }
+
+ stored, err := repo.GetAgent(ctx, agent.ID)
+ if err != nil {
+ t.Fatalf("GetAgent: %v", err)
+ }
+ if stored.TUIConfig != nil {
+ t.Errorf("TUIConfig = %+v, want nil", stored.TUIConfig)
+ }
+}
diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go
index 3d90c275cd..4eefd8d7f1 100644
--- a/apps/backend/internal/backendapp/services.go
+++ b/apps/backend/internal/backendapp/services.go
@@ -392,9 +392,15 @@ func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry
continue
}
cfg := agent.TUIConfig
- if regErr := agentRegistry.RegisterCustomTUIAgent(
- agent.Name, cfg.DisplayName, cfg.Command, cfg.Description, cfg.Model, cfg.CommandArgs,
- ); regErr != nil {
+ if regErr := agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
+ Slug: agent.Name,
+ DisplayName: cfg.DisplayName,
+ Command: cfg.Command,
+ Description: cfg.Description,
+ Model: cfg.Model,
+ CommandArgs: cfg.CommandArgs,
+ MCPStrategyKey: cfg.MCPStrategy,
+ }); regErr != nil {
log.Warn("failed to register custom TUI agent",
zap.String("name", agent.Name), zap.Error(regErr))
}
diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go
index c54c8846b8..3768d42a10 100644
--- a/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go
+++ b/apps/backend/internal/orchestrator/event_handlers_workflow_session_config_test.go
@@ -455,7 +455,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousAgentFamilyWarns(t *testing.T) {
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
- if err := shadowed.RegisterCustomTUIAgent(tc.slug, tc.displayName, "claude-tui", "", "", nil); err != nil {
+ if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: tc.slug, DisplayName: tc.displayName, Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -495,7 +495,7 @@ func TestProcessOnEnterConfigureSessionUnrelatedAmbiguousRuleStillApplies(t *tes
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
// "Claude" now names both claude-acp and this agent — and neither is codex-acp.
- if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
+ if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -561,7 +561,7 @@ func TestProcessOnEnterConfigureSessionRelevantAmbiguityRefusesDespiteExactRule(
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
- if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
+ if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
@@ -603,7 +603,7 @@ func TestProcessOnEnterConfigureSessionAmbiguousSessionFamilyRefuses(t *testing.
shadowed := registry.NewRegistry(testLogger())
shadowed.LoadDefaults()
// "Claude" now answers for both claude-acp and this agent.
- if err := shadowed.RegisterCustomTUIAgent("aaa-claude", "Claude", "claude-tui", "", "", nil); err != nil {
+ if err := shadowed.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{Slug: "aaa-claude", DisplayName: "Claude", Command: "claude-tui"}); err != nil {
t.Fatalf("register custom agent: %v", err)
}
svc.agentFamilyResolver = shadowed
diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go
index db96aad098..d9e2ae6dcd 100644
--- a/apps/backend/pkg/websocket/actions.go
+++ b/apps/backend/pkg/websocket/actions.go
@@ -253,6 +253,12 @@ const (
ActionAgentProfileCreated = "agent.profile.created"
ActionAgentProfileUpdated = "agent.profile.updated"
+ // ActionAgentSettingsUpdated carries a full agent settings record
+ // (dto.AgentDTO) after a settings-side change such as a custom TUI agent's
+ // MCP strategy. Distinct from ActionAgentUpdated, which is a runtime status
+ // ping keyed by {agentId, status} and feeds a different store slice.
+ ActionAgentSettingsUpdated = "agent.settings.updated"
+
// Permission request actions (agent -> user -> agent)
ActionPermissionRequested = "permission.requested" // Agent requesting permission
ActionPermissionRespond = "permission.respond" // User responding to permission request
diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx
index 9dd9edd6b4..9849d98a59 100644
--- a/apps/web/app/settings/agents/[agentId]/page.tsx
+++ b/apps/web/app/settings/agents/[agentId]/page.tsx
@@ -363,6 +363,20 @@ function useAgentSaveRevision(agent: DraftAgent) {
return { revision, saved, setSaved };
}
+/**
+ * Explains why the shared Save control is blocked, or undefined when it is not.
+ * Extracted so AgentSetupForm stays within the file's function-length limit.
+ */
+function resolveSaveInvalidReason(
+ t: (key: string) => string,
+ profilesValid: boolean,
+ hasInvalidMcpConfig: boolean,
+): string | undefined {
+ if (!profilesValid) return t("agents:everyProfileNeedsNameAndModel");
+ if (hasInvalidMcpConfig) return t("agents:fixInvalidMcpConfig");
+ return undefined;
+}
+
function AgentSetupForm({
initialAgent,
savedAgent,
@@ -420,9 +434,7 @@ function AgentSetupForm({
if (savedDraft) saveRevision.setSaved(JSON.stringify(savedDraft));
};
const profilesValid = areAgentProfilesValid(draftAgent);
- let saveInvalidReason: string | undefined;
- if (!profilesValid) saveInvalidReason = t("agents:everyProfileNeedsNameAndModel");
- else if (hasInvalidMcpConfig) saveInvalidReason = t("agents:fixInvalidMcpConfig");
+ const saveInvalidReason = resolveSaveInvalidReason(t, profilesValid, hasInvalidMcpConfig);
useSettingsSaveContributor({
id: `agent:${draftAgent.id}`,
revision: saveRevision.revision,
diff --git a/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx
new file mode 100644
index 0000000000..9a70e36cd5
--- /dev/null
+++ b/apps/web/app/settings/agents/custom-tui-mcp-mount.test.tsx
@@ -0,0 +1,109 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { StateProvider } from "@/components/state-provider";
+import { ToastProvider } from "@/components/toast-provider";
+import type { Agent } from "@/lib/types/http-agents";
+
+// The strategy list is fetched on mount; the mount assertion below is about
+// whether the control is reachable at all, not about the option list.
+vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
+ ...(await importOriginal>()),
+ listMCPStrategies: vi.fn(async () => [{ key: "claude", description: "a config file" }]),
+ updateCustomTUIAgentMCPStrategy: vi.fn(),
+}));
+
+const { CustomTUIMcpCard } = await import("@/components/settings/custom-tui-mcp-card");
+const { InstalledAgentCard } = await import("@/components/settings/installed-agent-card");
+
+const TIMESTAMP = "2026-08-13T10:00:00Z";
+const STRATEGY_SELECT = "mcp-strategy-select";
+
+function tuiAgent(overrides: Partial = {}): Agent {
+ return {
+ id: "agent-1",
+ name: "fuel-claude",
+ supports_mcp: false,
+ tui_config: {
+ command: "fuelclaude",
+ display_name: "Fuel Claude",
+ wait_for_terminal: true,
+ },
+ profiles: [],
+ created_at: TIMESTAMP,
+ updated_at: TIMESTAMP,
+ ...overrides,
+ } as Agent;
+}
+
+function discovery(name: string) {
+ return {
+ name,
+ supports_mcp: false,
+ mcp_config_path: null,
+ installation_paths: [],
+ available: true,
+ matched_path: "/usr/bin/fuelclaude",
+ };
+}
+
+/**
+ * Composes the card exactly as the agents index page does: inside
+ * InstalledAgentCard, keyed off the saved agent record.
+ *
+ * The point of asserting through this composition rather than rendering
+ * CustomTUIMcpCard alone is that the control was previously mounted on the
+ * agent detail route, which redirects saved agents back to the index before the
+ * form mounts. Testing the leaf in isolation passed while the control was
+ * reachable from nowhere.
+ */
+function renderIndexComposition(saved: Agent | undefined, name = "fuel-claude") {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("custom TUI MCP control on the agents index", () => {
+ afterEach(cleanup);
+
+ it("renders for a saved custom TUI agent", async () => {
+ renderIndexComposition(tuiAgent());
+ expect(await screen.findByTestId("agent-mcp-strategy-fuel-claude")).toBeTruthy();
+ expect(screen.getByTestId(STRATEGY_SELECT)).toBeTruthy();
+ });
+
+ it("shows the agent's stored strategy as the current value", async () => {
+ renderIndexComposition(
+ tuiAgent({
+ supports_mcp: true,
+ tui_config: {
+ command: "fuelclaude",
+ display_name: "Fuel Claude",
+ wait_for_terminal: true,
+ mcp_strategy: "claude",
+ },
+ }),
+ );
+ const trigger = await screen.findByTestId(STRATEGY_SELECT);
+ expect(trigger.textContent).toContain("claude");
+ });
+
+ it("renders nothing for a built-in (non-TUI) agent", () => {
+ renderIndexComposition(tuiAgent({ tui_config: null }), "claude-acp");
+ expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
+ });
+
+ it("renders nothing for an agent that has not been saved yet", () => {
+ renderIndexComposition(undefined);
+ expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
+ });
+});
diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx
index 44c2ced89c..dab6f63681 100644
--- a/apps/web/app/settings/agents/page.tsx
+++ b/apps/web/app/settings/agents/page.tsx
@@ -28,6 +28,7 @@ import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agent
import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog";
import { AgentProfilesSubList } from "@/components/settings/agents/agent-profiles-section";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
+import { CustomTUIMcpCard } from "@/components/settings/custom-tui-mcp-card";
import { InstalledAgentCard } from "@/components/settings/installed-agent-card";
import { AGENTS_BROWSE_SETTINGS_HREF } from "@/lib/settings-discovery/catalog/agents";
import {
@@ -242,6 +243,7 @@ function InstalledAgentsSection({
savedAgent={savedAgentsByName.get(agent.name)}
agentName={agent.name}
/>
+
))}
@@ -296,6 +298,7 @@ function useAgentPageState() {
display_name: string;
model?: string;
command: string;
+ mcp_strategy?: string;
}) => {
await createCustomTUIAgent(data);
const [discoveryResp, agentsResp, availableResp] = await Promise.all([
diff --git a/apps/web/components/settings/add-tui-agent-dialog.tsx b/apps/web/components/settings/add-tui-agent-dialog.tsx
index 56e74ce810..0d69a7a4a1 100644
--- a/apps/web/components/settings/add-tui-agent-dialog.tsx
+++ b/apps/web/components/settings/add-tui-agent-dialog.tsx
@@ -14,8 +14,14 @@ import {
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
+import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
-type TUIAgentFormData = { display_name: string; model?: string; command: string };
+type TUIAgentFormData = {
+ display_name: string;
+ model?: string;
+ command: string;
+ mcp_strategy?: string;
+};
// The placeholder kandev substitutes into the TUI command. The user types it
// verbatim, so it is interpolated as a value rather than written into the
@@ -32,6 +38,7 @@ type DialogHandlersParams = {
displayName: string;
model: string;
command: string;
+ mcpStrategy: string;
setError: React.Dispatch>;
setLoading: React.Dispatch>;
onSubmit: (data: TUIAgentFormData) => Promise;
@@ -43,6 +50,7 @@ function useDialogHandlers({
displayName,
model,
command,
+ mcpStrategy,
setError,
setLoading,
onSubmit,
@@ -65,6 +73,7 @@ function useDialogHandlers({
display_name: displayName.trim(),
model: model.trim() || undefined,
command: command.trim(),
+ mcp_strategy: mcpStrategy || undefined,
});
reset();
onOpenChange(false);
@@ -88,13 +97,18 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
const [displayName, setDisplayName] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
+ const [mcpStrategy, setMcpStrategy] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ // Only fetch the strategy list while the dialog is open.
+ const strategies = useMCPStrategies(open);
+
const reset = () => {
setDisplayName("");
setModel("");
setCommand("");
+ setMcpStrategy("");
setError(null);
setLoading(false);
};
@@ -103,6 +117,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
displayName,
model,
command,
+ mcpStrategy,
setError,
setLoading,
onSubmit,
@@ -153,6 +168,12 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
+
{error &&
{error}
}
diff --git a/apps/web/components/settings/custom-tui-mcp-card.tsx b/apps/web/components/settings/custom-tui-mcp-card.tsx
new file mode 100644
index 0000000000..526829c5fe
--- /dev/null
+++ b/apps/web/components/settings/custom-tui-mcp-card.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useAppStore } from "@/components/state-provider";
+import { useToast } from "@/components/toast-provider";
+import { MCPStrategySelect, useMCPStrategies } from "@/components/settings/mcp-strategy-select";
+import { updateCustomTUIAgentMCPStrategy } from "@/lib/api/domains/settings-api";
+import type { Agent } from "@/lib/types/http-agents";
+
+type CustomTUIMcpCardProps = {
+ /** Renders nothing unless this is a saved custom TUI agent. */
+ agent: Agent | null | undefined;
+};
+
+/**
+ * Lets the user change a custom TUI agent's MCP injection strategy.
+ *
+ * Self-contained (own store write, own toast) so it can mount wherever the
+ * agent is actually shown. It first lived on the agent detail route, which
+ * redirects saved agents straight back to the index — so it rendered nowhere a
+ * user would find it.
+ *
+ * Saves immediately rather than registering with the shared settings-save
+ * coordinator, matching the profile-enabled toggle: the strategy is agent-level
+ * state outside the profile draft, the backend rebuilds the registry entry as
+ * part of the write, and turning it on is what makes the per-profile MCP editor
+ * appear — deferring that behind a separate Save would be confusing.
+ */
+export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const savedAgents = useAppStore((state) => state.settingsAgents.items);
+ const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
+ // Gated: this card is rendered once per agent on the index, and hooks run
+ // before the early return below, so an unconditional fetch would issue one
+ // identical request per built-in agent as well.
+ const isCustomTUIAgent = Boolean(agent?.tui_config);
+ const strategies = useMCPStrategies(isCustomTUIAgent);
+ const [saving, setSaving] = useState(false);
+
+ if (!agent?.tui_config) return null;
+
+ const handleChange = async (strategy: string) => {
+ setSaving(true);
+ try {
+ const updated = await updateCustomTUIAgentMCPStrategy(agent.id, strategy);
+ // Write through the store rather than waiting on the WS echo so the
+ // control reflects the save immediately. Profiles are preserved: this
+ // endpoint only changes agent-level settings.
+ setSettingsAgents(
+ savedAgents.map((item) =>
+ item.id === updated.id ? { ...item, ...updated, profiles: item.profiles } : item,
+ ),
+ );
+ } catch (error) {
+ toast({
+ title: t("agents:failedToSaveAgent"),
+ description: error instanceof Error ? error.message : t("agents:requestFailed"),
+ variant: "error",
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/components/settings/mcp-strategy-select.test.tsx b/apps/web/components/settings/mcp-strategy-select.test.tsx
new file mode 100644
index 0000000000..1c279f0999
--- /dev/null
+++ b/apps/web/components/settings/mcp-strategy-select.test.tsx
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { fromStrategyValue, MCP_STRATEGY_NONE, toStrategyValue } from "./mcp-strategy-select";
+
+// Radix Select cannot hold an empty-string value, so "no MCP injection" needs a
+// sentinel in the control and "" on the wire. If the mapping leaks, the backend
+// receives "__none__", rejects it as an unknown strategy, and the user cannot
+// turn MCP off again.
+describe("MCP strategy value mapping", () => {
+ it("maps an unset strategy to the sentinel", () => {
+ expect(toStrategyValue(undefined)).toBe(MCP_STRATEGY_NONE);
+ expect(toStrategyValue("")).toBe(MCP_STRATEGY_NONE);
+ });
+
+ it("passes a real strategy key through unchanged", () => {
+ expect(toStrategyValue("claude")).toBe("claude");
+ });
+
+ it("maps the sentinel back to an empty key for the API", () => {
+ expect(fromStrategyValue(MCP_STRATEGY_NONE)).toBe("");
+ });
+
+ it("passes a real strategy key back unchanged", () => {
+ expect(fromStrategyValue("codex")).toBe("codex");
+ });
+
+ it("round-trips every value the control can hold", () => {
+ for (const key of ["", "claude", "codex", "cursor", "opencode", "pi"]) {
+ expect(fromStrategyValue(toStrategyValue(key))).toBe(key);
+ }
+ });
+});
diff --git a/apps/web/components/settings/mcp-strategy-select.tsx b/apps/web/components/settings/mcp-strategy-select.tsx
new file mode 100644
index 0000000000..46c661a6d7
--- /dev/null
+++ b/apps/web/components/settings/mcp-strategy-select.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Label } from "@kandev/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
+import { listMCPStrategies } from "@/lib/api/domains/settings-api";
+import type { MCPStrategyOption } from "@/lib/types/http-agents";
+
+/**
+ * Radix Select cannot hold an empty-string value, so the "off" choice needs a
+ * sentinel. Callers convert between it and "" at the API boundary via
+ * {@link toStrategyValue} / {@link fromStrategyValue}. It is a control value,
+ * never displayed and never translated.
+ */
+export const MCP_STRATEGY_NONE = "__none__";
+
+/** Maps a stored strategy key ("" = off) to the Select's value. */
+export function toStrategyValue(strategy: string | undefined): string {
+ return strategy ? strategy : MCP_STRATEGY_NONE;
+}
+
+/** Maps the Select's value back to a stored strategy key ("" = off). */
+export function fromStrategyValue(value: string): string {
+ return value === MCP_STRATEGY_NONE ? "" : value;
+}
+
+/**
+ * Loads the selectable MCP strategies from the backend.
+ *
+ * The list is fetched rather than hardcoded so a strategy added in Go becomes
+ * selectable without a matching frontend edit — and so an option can never be
+ * offered that the backend would reject on save.
+ */
+export function useMCPStrategies(enabled: boolean) {
+ const [strategies, setStrategies] = useState([]);
+
+ useEffect(() => {
+ if (!enabled) return;
+ let active = true;
+ listMCPStrategies()
+ .then((options) => {
+ if (active) setStrategies(options);
+ })
+ .catch(() => {
+ // Non-fatal: the picker falls back to the "off" choice only. Creating
+ // an agent without MCP still works, which is the pre-existing behavior.
+ if (active) setStrategies([]);
+ });
+ return () => {
+ active = false;
+ };
+ }, [enabled]);
+
+ return strategies;
+}
+
+type MCPStrategySelectProps = {
+ id: string;
+ /** Stored strategy key; "" or undefined means no injection. */
+ value: string | undefined;
+ /** Receives the stored strategy key ("" when the user picks "off"). */
+ onChange: (strategy: string) => void;
+ strategies: MCPStrategyOption[];
+ disabled?: boolean;
+};
+
+/**
+ * Picks how kandev injects its per-session MCP server into a custom TUI agent's
+ * wrapped CLI.
+ *
+ * This is an explicit choice rather than something inferred from the command,
+ * because the command is arbitrary (`zsh -ic "fuelclaude --model opus"` is a
+ * real example) and a wrong guess fails silently: kandev writes a config file,
+ * the CLI ignores it, and no error surfaces anywhere.
+ */
+export function MCPStrategySelect({
+ id,
+ value,
+ onChange,
+ strategies,
+ disabled,
+}: MCPStrategySelectProps) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
{t("agents:mcpStrategyHelp")}
+
+ );
+}
diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs
index c4056edb4f..db4f39815f 100644
--- a/apps/web/eslint.i18n.options.mjs
+++ b/apps/web/eslint.i18n.options.mjs
@@ -537,6 +537,7 @@ export const i18nGuardFiles = [
"components/settings/command-prefix-field.tsx",
"components/settings/install-agent-card.tsx",
"components/settings/installed-agent-card.tsx",
+ "components/settings/mcp-strategy-select.tsx",
"components/settings/mode-combobox.tsx",
"components/settings/profile-capability-helpers.tsx",
"components/settings/profile-form-fields.tsx",
diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts
index 4a9939d018..7c7c30c2bf 100644
--- a/apps/web/lib/api/domains/settings-api.ts
+++ b/apps/web/lib/api/domains/settings-api.ts
@@ -22,6 +22,7 @@ import type {
AgentModelConfigResponse,
ResolveAgentModelConfigRequest,
} from "@/lib/types/http";
+import type { MCPStrategyOption } from "@/lib/types/http-agents";
import type {
MessageQueueSettingsPatch,
MessageQueueSettingsResponse,
@@ -332,7 +333,13 @@ export async function getInstallJob(
}
export async function createCustomTUIAgent(
- payload: { display_name: string; model?: string; command: string; description?: string },
+ payload: {
+ display_name: string;
+ model?: string;
+ command: string;
+ description?: string;
+ mcp_strategy?: string;
+ },
options?: ApiRequestOptions,
): Promise {
const res = await fetchJson("/api/v1/agents/tui", {
@@ -342,6 +349,41 @@ export async function createCustomTUIAgent(
return normalizeAgentResponse(res);
}
+/**
+ * Lists the MCP injection mechanisms a custom TUI agent can use. Served by the
+ * backend so the option list cannot drift from the strategies that actually
+ * resolve at launch.
+ */
+export async function listMCPStrategies(options?: ApiRequestOptions): Promise {
+ const res = await fetchJson<{ strategies?: MCPStrategyOption[] }>(
+ "/api/v1/agents/tui/mcp-strategies",
+ options,
+ );
+ return res.strategies ?? [];
+}
+
+/**
+ * Changes an existing custom TUI agent's MCP injection strategy. Pass an empty
+ * string to turn injection off. This is the only mutable part of a tui_config —
+ * it exists so agents created before the field existed can opt in without being
+ * deleted and rebuilt.
+ */
+export async function updateCustomTUIAgentMCPStrategy(
+ agentId: string,
+ mcpStrategy: string,
+ options?: ApiRequestOptions,
+): Promise {
+ const res = await fetchJson(`/api/v1/agents/tui/${agentId}/mcp`, {
+ ...options,
+ init: {
+ method: "PATCH",
+ body: JSON.stringify({ mcp_strategy: mcpStrategy }),
+ ...(options?.init ?? {}),
+ },
+ });
+ return normalizeAgentResponse(res);
+}
+
export async function fetchDynamicModels(
agentName: string,
options?: ApiRequestOptions & { refresh?: boolean },
diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts
index 715e9f8b20..ef76ea4740 100644
--- a/apps/web/lib/types/backend.ts
+++ b/apps/web/lib/types/backend.ts
@@ -15,6 +15,7 @@ import type { RunEventAppendedPayload } from "./run-events";
export type { RunEventAppendedPayload } from "./run-events";
import type {
+ Agent,
AvailableAgent,
ForegroundActivity,
TaskPendingAction,
@@ -120,6 +121,15 @@ export type AgentUpdatePayload = {
message?: string;
};
+/**
+ * A full agent settings record after an agent-level settings change (e.g. a
+ * custom TUI agent's MCP strategy). Distinct from AgentUpdatePayload, which is
+ * the runtime status ping.
+ */
+export type AgentSettingsUpdatedPayload = {
+ agent: Agent;
+};
+
export type AgentAvailableUpdatedPayload = {
agents: AvailableAgent[];
tools?: ToolStatus[];
@@ -345,6 +355,7 @@ export type BackendMessageMap = SessionBackendMessageMap &
>;
"task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>;
"agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>;
+ "agent.settings.updated": BackendMessage<"agent.settings.updated", AgentSettingsUpdatedPayload>;
"agent.available.updated": BackendMessage<
"agent.available.updated",
AgentAvailableUpdatedPayload
diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts
index 88f53c7d61..0aff09ae00 100644
--- a/apps/web/lib/types/http-agents.ts
+++ b/apps/web/lib/types/http-agents.ts
@@ -26,6 +26,20 @@ export type TUIConfig = {
description?: string;
command_args?: string[];
wait_for_terminal: boolean;
+ /**
+ * How kandev injects its per-session MCP server into the wrapped CLI.
+ * Empty/absent means no injection — the agent gets no kandev task tools.
+ * Keys come from `GET /api/v1/agents/tui/mcp-strategies`; never hardcode a
+ * list here, or a strategy added in Go silently stops being selectable.
+ */
+ mcp_strategy?: string;
+};
+
+/** One selectable MCP injection mechanism, served by the backend. */
+export type MCPStrategyOption = {
+ key: string;
+ /** The strategy's own description of the mechanism it uses. */
+ description: string;
};
export type Agent = {
diff --git a/apps/web/lib/ws/handlers/agents-settings-updated.test.ts b/apps/web/lib/ws/handlers/agents-settings-updated.test.ts
new file mode 100644
index 0000000000..fdbf508b18
--- /dev/null
+++ b/apps/web/lib/ws/handlers/agents-settings-updated.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import { create, type StoreApi } from "zustand";
+import { immer } from "zustand/middleware/immer";
+
+import { createSettingsSlice } from "@/lib/state/slices/settings/settings-slice";
+import type { SettingsSlice } from "@/lib/state/slices/settings/types";
+import type { AppState } from "@/lib/state/store";
+import { registerAgentsHandlers } from "./agents";
+
+function makeStore() {
+ return create()(
+ immer((set, get, store) => createSettingsSlice(set, get, store)),
+ ) as unknown as StoreApi;
+}
+
+const TIMESTAMP = "2026-08-13T10:00:00Z";
+const SETTINGS_UPDATED = "agent.settings.updated";
+
+function message(action: string, payload: unknown) {
+ return { id: `message-${action}`, type: "notification", action, timestamp: TIMESTAMP, payload };
+}
+
+function savedAgent(overrides: Record = {}) {
+ return {
+ id: "agent-1",
+ name: "fuel-claude",
+ supports_mcp: false,
+ tui_config: {
+ command: "fuelclaude",
+ display_name: "Fuel Claude",
+ wait_for_terminal: true,
+ },
+ profiles: [{ id: "p1", name: "Default" }],
+ created_at: TIMESTAMP,
+ updated_at: TIMESTAMP,
+ ...overrides,
+ };
+}
+
+/**
+ * These pin the split between two actions that were briefly conflated. The
+ * settings record was first broadcast on `agent.updated`, whose handler expects
+ * `{agentId, status}` — it read `undefined` and pushed `{id: undefined}` into
+ * the runtime-status list instead of updating settings.
+ */
+describe("agent.settings.updated handler", () => {
+ it("replaces the agent's settings record in settingsAgents", () => {
+ const store = makeStore();
+ store.getState().setSettingsAgents([savedAgent()] as never);
+ const handlers = registerAgentsHandlers(store);
+
+ handlers[SETTINGS_UPDATED]?.(
+ message(SETTINGS_UPDATED, {
+ agent: savedAgent({
+ supports_mcp: true,
+ tui_config: {
+ command: "fuelclaude",
+ display_name: "Fuel Claude",
+ wait_for_terminal: true,
+ mcp_strategy: "claude",
+ },
+ }),
+ }) as never,
+ );
+
+ const [updated] = store.getState().settingsAgents.items;
+ expect(updated.supports_mcp).toBe(true);
+ expect(updated.tui_config?.mcp_strategy).toBe("claude");
+ });
+
+ it("preserves locally normalized profiles", () => {
+ const store = makeStore();
+ store.getState().setSettingsAgents([savedAgent()] as never);
+ const handlers = registerAgentsHandlers(store);
+
+ // The event carries agent-level settings; profiles have their own events,
+ // so an agent record arriving without them must not wipe the list.
+ handlers[SETTINGS_UPDATED]?.(
+ message(SETTINGS_UPDATED, {
+ agent: savedAgent({ supports_mcp: true, profiles: [] }),
+ }) as never,
+ );
+
+ expect(store.getState().settingsAgents.items[0].profiles).toHaveLength(1);
+ });
+
+ it("ignores an agent it does not already track", () => {
+ const store = makeStore();
+ store.getState().setSettingsAgents([savedAgent()] as never);
+ const handlers = registerAgentsHandlers(store);
+
+ handlers[SETTINGS_UPDATED]?.(
+ message(SETTINGS_UPDATED, { agent: savedAgent({ id: "agent-other" }) }) as never,
+ );
+
+ expect(store.getState().settingsAgents.items).toHaveLength(1);
+ expect(store.getState().settingsAgents.items[0].id).toBe("agent-1");
+ });
+
+ it("ignores a payload with no agent rather than corrupting the list", () => {
+ const store = makeStore();
+ store.getState().setSettingsAgents([savedAgent()] as never);
+ const handlers = registerAgentsHandlers(store);
+
+ handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, {}) as never);
+ handlers[SETTINGS_UPDATED]?.(message(SETTINGS_UPDATED, { agent: {} }) as never);
+
+ expect(store.getState().settingsAgents.items).toHaveLength(1);
+ expect(store.getState().settingsAgents.items[0].id).toBe("agent-1");
+ });
+});
diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts
index 20b6d7f442..43c200faa9 100644
--- a/apps/web/lib/ws/handlers/agents.ts
+++ b/apps/web/lib/ws/handlers/agents.ts
@@ -230,6 +230,27 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers {
},
}));
},
+ // Full settings record for one agent (e.g. a custom TUI agent's MCP
+ // strategy changed). Distinct from "agent.updated", which is a runtime
+ // status ping keyed by {agentId, status} and feeds state.agents.
+ "agent.settings.updated": (message) => {
+ const incoming = message.payload?.agent;
+ if (!incoming?.id) return;
+ store.setState((state) => {
+ const exists = state.settingsAgents.items.some((a) => a.id === incoming.id);
+ if (!exists) return state;
+ return {
+ ...state,
+ settingsAgents: {
+ items: state.settingsAgents.items.map((a) =>
+ // Preserve the locally normalized profiles: this event carries
+ // agent-level settings, and the profile list has its own events.
+ a.id === incoming.id ? { ...a, ...incoming, profiles: a.profiles } : a,
+ ),
+ },
+ };
+ });
+ },
"agent.profile.created": (message) => {
store.setState((state) => ({
...state,
diff --git a/apps/web/src/locales/en/agents.json b/apps/web/src/locales/en/agents.json
index f942eca790..48366f03cc 100644
--- a/apps/web/src/locales/en/agents.json
+++ b/apps/web/src/locales/en/agents.json
@@ -135,6 +135,10 @@
"mcpSaveProfileHint": "Save this profile to configure MCP servers.",
"mcpServersHelp": "MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.",
"mcpServersJson": "MCP servers (JSON)",
+ "mcpStrategy": "Kandev MCP tools",
+ "mcpStrategyHelp": "Kandev can hand this agent its own tools (set task title, ask the user a question, update the plan, mark a step complete). To do that it must write MCP config in the format your CLI reads, so pick the one that matches. Choosing the wrong format fails silently: the config is written, the CLI ignores it, and no error is shown. Leave this off if the command is not an MCP-capable coding agent.",
+ "mcpStrategyNone": "Off: no Kandev tools",
+ "mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Model",
"modelHeading": "Model",
"modelRequiredForAllProfiles": "Model is required for all profiles.",
diff --git a/apps/web/src/locales/pseudo/agents.json b/apps/web/src/locales/pseudo/agents.json
index 463ccff48d..e9b6c13f8c 100644
--- a/apps/web/src/locales/pseudo/agents.json
+++ b/apps/web/src/locales/pseudo/agents.json
@@ -135,6 +135,10 @@
"mcpSaveProfileHint": "Śàvē ţĥĩś ƥŕōƒĩĺē ţō ćōńƒĩĝũŕē ḾĆƤ śēŕvēŕś.",
"mcpServersHelp": "ḾĆƤ ďēƒĩńĩţĩōńś àŕē śţōŕēď ĩń ţĥē ďàţàƀàśē àńď ŕēśōĺvēď ƥēŕ ēxēćũţōŕ àţ ŕũńţĩḿē. Ţĥĩś ďōēś ńōţ ōvēŕŕĩďē ŷōũŕ ĺōćàĺ àĝēńţ ćōńƒĩĝ.",
"mcpServersJson": "ḾĆƤ śēŕvēŕś (ĴŚŌŃ)",
+ "mcpStrategy": "Ķàńďēv ḾĆƤ ţōōĺś",
+ "mcpStrategyHelp": "Ķàńďēv ćàń ĥàńď ţĥĩś àĝēńţ ĩţś ōŵń ţōōĺś (śēţ ţàśķ ţĩţĺē, àśķ ţĥē ũśēŕ à qũēśţĩōń, ũƥďàţē ţĥē ƥĺàń, ḿàŕķ à śţēƥ ćōḿƥĺēţē). Ţō ďō ţĥàţ ĩţ ḿũśţ ŵŕĩţē ḾĆƤ ćōńƒĩĝ ĩń ţĥē ƒōŕḿàţ ŷōũŕ ĆĹĨ ŕēàďś, śō ƥĩćķ ţĥē ōńē ţĥàţ ḿàţćĥēś. Ćĥōōśĩńĝ ţĥē ŵŕōńĝ ƒōŕḿàţ ƒàĩĺś śĩĺēńţĺŷ: ţĥē ćōńƒĩĝ ĩś ŵŕĩţţēń, ţĥē ĆĹĨ ĩĝńōŕēś ĩţ, àńď ńō ēŕŕōŕ ĩś śĥōŵń. Ĺēàvē ţĥĩś ōƒƒ ĩƒ ţĥē ćōḿḿàńď ĩś ńōţ àń ḾĆƤ-ćàƥàƀĺē ćōďĩńĝ àĝēńţ.",
+ "mcpStrategyNone": "Ōƒƒ: ńō Ķàńďēv ţōōĺś",
+ "mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Ḿōďēĺ",
"modelHeading": "Ḿōďēĺ",
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
diff --git a/apps/web/src/locales/pt-pt/agents.json b/apps/web/src/locales/pt-pt/agents.json
index 0db455b8e5..f3c99ec548 100644
--- a/apps/web/src/locales/pt-pt/agents.json
+++ b/apps/web/src/locales/pt-pt/agents.json
@@ -50,10 +50,8 @@
"conflictTasksTitle": "Tarefas:",
"conflictTierMappingRow": "Escalão <0>{{tier}}0> em {{workspace}} para {{provider}}",
"conflictTierMappingsTitle": "Mapeamentos de escalão da área de trabalho:",
- "conflictWatchersTitle": "Vigilâncias (serão desativadas):",
"conflictUtilityAgentsTitle": "Agentes utilitários (manterão ligações obsoletas):",
- "disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.",
- "disableProfileAnyway": "Desativar perfil",
+ "conflictWatchersTitle": "Vigilâncias (serão desativadas):",
"copied": "Copiado",
"copyInstallCommand": "Copiar o comando de instalação",
"copyToClipboard": "Copiar para a área de transferência",
@@ -75,6 +73,8 @@
"details": "detalhes",
"detectedAt": "Detetado em {{path}}",
"disableProfile": "Desativar perfil",
+ "disableProfileAnyway": "Desativar perfil",
+ "disableProfileUtilityWarning": "Agentes utilitários usam este perfil. Desativá-lo impede novas chamadas até reparar cada ligação.",
"disableSetting": "Desativar {{name}}",
"displayName": "Nome a apresentar",
"displayNameRequired": "O nome a apresentar é obrigatório",
@@ -84,18 +84,13 @@
"enableProfile": "Ativar perfil",
"enableSetting": "Ativar {{name}}",
"enabled": "Ativado",
- "enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil",
"enabledProfileHelper": "Os perfis desativados continuam a servir as sessões existentes, mas não são oferecidos ao criar novas tarefas ou agentes.",
+ "enabledProfileInfo": "Mais informações sobre a disponibilidade do perfil",
"everyProfileNeedsNameAndModel": "Todos os perfis precisam de um nome e de um modelo.",
"exampleValue": "ex.: {{example}}",
"failedToCreateAgent": "Não foi possível criar o agente",
"failedToDeleteProfile": "Não foi possível eliminar o perfil",
"failedToFetchCapabilities": "Não foi possível obter as capacidades",
- "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.",
- "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.",
- "modelSettings": "Definições do modelo",
- "resolvingModelOptions": "A carregar as opções do modelo…",
- "retryModelOptions": "Tentar novamente",
"failedToLoadCommandPreview": "Não foi possível carregar a pré-visualização do comando",
"failedToLoadMcpConfig": "Não foi possível carregar a configuração MCP",
"failedToRefresh": "Não foi possível atualizar: {{error}}",
@@ -128,9 +123,16 @@
"mcpSaveProfileHint": "Guarde este perfil para configurar servidores MCP.",
"mcpServersHelp": "As definições de MCP são guardadas na base de dados e resolvidas por executor em tempo de execução. Isto não substitui a configuração local do seu agente.",
"mcpServersJson": "Servidores MCP (JSON)",
+ "mcpStrategy": "Ferramentas MCP do Kandev",
+ "mcpStrategyHelp": "O Kandev pode disponibilizar a este agente as suas próprias ferramentas (definir o título da tarefa, colocar uma questão ao utilizador, atualizar o plano, marcar um passo como concluído). Para isso, tem de escrever a configuração MCP no formato que a sua CLI lê, por isso escolha o que corresponde. Escolher o formato errado falha silenciosamente: a configuração é escrita, a CLI ignora-a e não é apresentado qualquer erro. Deixe desativado se o comando não for um agente de programação compatível com MCP.",
+ "mcpStrategyNone": "Desativado: sem ferramentas do Kandev",
+ "mcpStrategyOption": "{{name}}: {{mechanism}}",
"model": "Modelo",
"modelHeading": "Modelo",
+ "modelOptionsAuthenticationRequired": "Inicie sessão neste agente para carregar as opções do modelo.",
+ "modelOptionsResolutionFailed": "Não foi possível carregar as opções do modelo.",
"modelRequiredForAllProfiles": "O modelo é obrigatório em todos os perfis.",
+ "modelSettings": "Definições do modelo",
"noAuthHintLogin": "Clique em Abrir terminal para iniciar sessão e depois atualize.",
"noAuthHintNotInstalled": "Instale a CLI do agente e depois clique em Atualizar.",
"noAuthHintShell": "Clique em Abrir terminal para iniciar uma shell, execute o comando de início de sessão do agente e depois atualize.",
@@ -162,7 +164,9 @@
"removeThisProfile": "Remover este perfil",
"requestFailed": "O pedido falhou",
"rescan": "Voltar a analisar",
+ "resolvingModelOptions": "A carregar as opções do modelo…",
"retry": "Tentar novamente",
+ "retryModelOptions": "Tentar novamente",
"retryUpdate": "Repetir a atualização",
"retryVersionCheck": "Repetir a verificação da versão",
"reviewUpdateBeforeApplying": "Reveja a atualização antes de o Kandev alterar o runtime deste anfitrião.",
diff --git a/apps/web/src/locales/zh-cn/agents.json b/apps/web/src/locales/zh-cn/agents.json
index 622634af7a..a9b5704fc5 100644
--- a/apps/web/src/locales/zh-cn/agents.json
+++ b/apps/web/src/locales/zh-cn/agents.json
@@ -133,6 +133,10 @@
"mcpSaveProfileHint": "保存此配置以配置 MCP 服务器。",
"mcpServersHelp": "MCP 定义存储在数据库中,并在运行时按执行器解析。这不会覆盖你的本地智能体配置。",
"mcpServersJson": "MCP 服务器(JSON)",
+ "mcpStrategy": "Kandev MCP 工具",
+ "mcpStrategyHelp": "Kandev 可以为该代理提供自己的工具(设置任务标题、向用户提问、更新计划、标记步骤完成)。为此,它必须以你的 CLI 能读取的格式写入 MCP 配置,因此请选择匹配的格式。选错格式会静默失败:配置已写入,但 CLI 会忽略它,并且不会显示任何错误。如果该命令不是支持 MCP 的编码代理,请保持关闭。",
+ "mcpStrategyNone": "关闭:不提供 Kandev 工具",
+ "mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
"modelRequiredForAllProfiles": "所有配置都需要模型。",
diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json
index 6a5706af2e..80e463620e 100644
--- a/apps/web/src/locales/zh-hk/agents.json
+++ b/apps/web/src/locales/zh-hk/agents.json
@@ -31,7 +31,6 @@
"capabilityProbeFailed": "探測失敗。請查看代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級映射。",
- "everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身份驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身份驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -52,18 +51,13 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}0> 層級",
"conflictTierMappingsTitle": "工作區層級映射:",
- "conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效綁定):",
- "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
- "disableProfileAnyway": "停用設定",
+ "conflictWatchersTitle": "監控(將被停用):",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼板",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
- "installAgents": "安裝代理程式",
- "profileCount_one": "{{count}} 個設定",
- "profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -80,27 +74,30 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
+ "disableProfileAnyway": "停用設定",
+ "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個綁定。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
+ "duplicate": "複製",
+ "duplicateProfileNamed": "複製 {{name}}",
+ "duplicateProfileSuccess": "代理程式設定檔已複製",
+ "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啓用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啓用代理程式設定檔",
"enableSetting": "啓用 {{name}}",
"enabled": "已啓用",
- "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
+ "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
+ "everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
+ "failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
- "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
- "modelOptionsResolutionFailed": "無法載入模型選項。",
- "modelSettings": "模型設定",
- "resolvingModelOptions": "正在載入模型選項…",
- "retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "刷新失敗:{{error}}",
@@ -115,6 +112,7 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
+ "installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -133,14 +131,21 @@
"mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。",
"mcpServersHelp": "MCP 定義存儲在數據庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。",
"mcpServersJson": "MCP 伺服器(JSON)",
+ "mcpStrategy": "Kandev MCP 工具",
+ "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。",
+ "mcpStrategyNone": "關閉:不提供 Kandev 工具",
+ "mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
+ "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
+ "modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
+ "modelSettings": "模型設定",
+ "newProfile": "新設定",
"noAuthHintLogin": "點擊「開啟終端」登入,然後刷新。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點擊「刷新」。",
"noAuthHintShell": "點擊「開啟終端」啓動 shell,執行代理程式的登入命令,然後刷新。",
"noAuthLoginRequired": "未驗證;需要登入",
- "newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點擊「重新掃描」。",
@@ -154,6 +159,8 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
+ "profileCount_one": "{{count}} 個設定",
+ "profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -170,7 +177,9 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
+ "resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
+ "retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -212,10 +221,5 @@
"watcherKindGithubIssue": "GitHub 議題",
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
- "watcherKindLinear": "Linear",
- "duplicate": "複製",
- "duplicateProfileNamed": "複製 {{name}}",
- "duplicateProfileSuccess": "代理程式設定檔已複製",
- "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
- "failedToDuplicateProfile": "複製代理程式設定檔失敗"
+ "watcherKindLinear": "Linear"
}
diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json
index 912a3a920e..b180cf618d 100644
--- a/apps/web/src/locales/zh-tw/agents.json
+++ b/apps/web/src/locales/zh-tw/agents.json
@@ -31,7 +31,6 @@
"capabilityProbeFailed": "探測失敗。請檢視代理程式日誌了解詳情。",
"capabilityProbing": "正在檢查代理程式能力…",
"changeTierMappingsFirst": "刪除此設定前,請先更改這些工作區層級對映。",
- "everythingInstalled": "所有可用代理程式和工具均已安裝。",
"checkingAuthentication": "正在檢查身分驗證",
"checkingCapabilitiesAndAuth": "正在檢查代理程式能力與身分驗證…",
"checkingLatestRuntimeVersion": "正在檢查最新執行時版本…",
@@ -52,18 +51,13 @@
"conflictTasksTitle": "任務:",
"conflictTierMappingRow": "{{workspace}} 中 {{provider}} 的 <0>{{tier}}0> 層級",
"conflictTierMappingsTitle": "工作區層級對映:",
- "conflictWatchersTitle": "監控(將被停用):",
"conflictUtilityAgentsTitle": "輔助代理程式(將保留失效繫結):",
- "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
- "disableProfileAnyway": "停用設定",
+ "conflictWatchersTitle": "監控(將被停用):",
"copied": "已複製",
"copyInstallCommand": "複製安裝命令",
"copyToClipboard": "複製到剪貼簿",
"create": "建立",
"createAgentProfileTitle": "建立 {{name}} 設定",
- "installAgents": "安裝代理程式",
- "profileCount_one": "{{count}} 個設定",
- "profileCount_other": "{{count}} 個設定",
"createProfileIntro": "為此代理程式建立新設定。",
"creating": "正在建立…",
"defaultProfile": "預設代理程式設定檔",
@@ -80,27 +74,30 @@
"details": "詳情",
"detectedAt": "檢測路徑:{{path}}",
"disableProfile": "停用代理程式設定檔",
+ "disableProfileAnyway": "停用設定",
+ "disableProfileUtilityWarning": "輔助代理程式正在使用此設定。停用後,新呼叫會停止,直到修復每個繫結。",
"disableSetting": "停用 {{name}}",
"displayName": "顯示名稱",
"displayNameRequired": "顯示名稱為必填項",
"done": "完成",
+ "duplicate": "複製",
+ "duplicateProfileNamed": "複製 {{name}}",
+ "duplicateProfileSuccess": "代理程式設定檔已複製",
+ "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
"enableMcp": "啟用 MCP",
"enableMcpDescription": "允許此設定在工作階段中使用 MCP 伺服器。",
"enableProfile": "啟用代理程式設定檔",
"enableSetting": "啟用 {{name}}",
"enabled": "已啟用",
- "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"enabledProfileHelper": "已停用的代理程式設定檔仍會繼續服務現有工作階段,但在建立新任務或代理程式時不會出現在選項中。",
+ "enabledProfileInfo": "關於代理程式設定檔可用性的更多資訊",
"everyProfileNeedsNameAndModel": "每個設定都需要名稱和模型。",
+ "everythingInstalled": "所有可用代理程式和工具均已安裝。",
"exampleValue": "例如 {{example}}",
"failedToCreateAgent": "建立代理程式失敗",
"failedToDeleteProfile": "刪除設定失敗",
+ "failedToDuplicateProfile": "複製代理程式設定檔失敗",
"failedToFetchCapabilities": "擷取能力失敗",
- "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
- "modelOptionsResolutionFailed": "無法載入模型選項。",
- "modelSettings": "模型設定",
- "resolvingModelOptions": "正在載入模型選項…",
- "retryModelOptions": "重試",
"failedToLoadCommandPreview": "載入命令預覽失敗",
"failedToLoadMcpConfig": "載入 MCP 設定失敗",
"failedToRefresh": "重新整理失敗:{{error}}",
@@ -115,6 +112,7 @@
"hostTerminal": "主機終端",
"hostTerminalDescription": "在 Kandev 主機上執行 shell。可用於手動安裝或設定代理程式。",
"install": "安裝",
+ "installAgents": "安裝代理程式",
"installQueued": "已排隊…",
"installationNotDetected": "未檢測到安裝",
"installed": "已安裝",
@@ -133,14 +131,21 @@
"mcpSaveProfileHint": "儲存此設定以設定 MCP 伺服器。",
"mcpServersHelp": "MCP 定義儲存在資料庫中,並在執行時按執行器解析。這不會覆蓋你的本地代理程式設定檔。",
"mcpServersJson": "MCP 伺服器(JSON)",
+ "mcpStrategy": "Kandev MCP 工具",
+ "mcpStrategyHelp": "Kandev 可以為這個代理程式提供自己的工具(設定任務標題、向使用者提問、更新計畫、標記步驟完成)。為此,它必須以你的 CLI 能讀取的格式寫入 MCP 設定,因此請選擇相符的格式。選錯格式會靜默失敗:設定已寫入,但 CLI 會忽略它,而且不會顯示任何錯誤。如果該命令不是支援 MCP 的程式設計代理程式,請保持關閉。",
+ "mcpStrategyNone": "關閉:不提供 Kandev 工具",
+ "mcpStrategyOption": "{{name}}:{{mechanism}}",
"model": "模型",
"modelHeading": "模型",
+ "modelOptionsAuthenticationRequired": "請登入此代理程式以載入模型選項。",
+ "modelOptionsResolutionFailed": "無法載入模型選項。",
"modelRequiredForAllProfiles": "所有設定都需要模型。",
+ "modelSettings": "模型設定",
+ "newProfile": "新設定",
"noAuthHintLogin": "點選「開啟終端」登入,然後重新整理。",
"noAuthHintNotInstalled": "安裝代理程式 CLI,然後點選「重新整理」。",
"noAuthHintShell": "點選「開啟終端」啟動 shell,執行代理程式的登入命令,然後重新整理。",
"noAuthLoginRequired": "未驗證;需要登入",
- "newProfile": "新設定",
"noCliFlagsConfigured": "未設定 CLI 旗標。請在下方新增。",
"noCommandPreview": "無可用命令預覽。",
"noInstalledAgentsDetected": "未檢測到已安裝的代理程式。請在下方安裝,然後點選「重新掃描」。",
@@ -154,6 +159,8 @@
"popularServers": "常用伺服器",
"probingCapabilities": "正在探測代理程式能力… 通常需要幾秒鐘。",
"profileActions": "設定操作",
+ "profileCount_one": "{{count}} 個設定",
+ "profileCount_other": "{{count}} 個設定",
"profileInUseIntro": "此設定目前正在使用。刪除後將影響以下內容:",
"profileName": "設定名稱",
"profileNameRequired": "設定名稱為必填項。",
@@ -170,7 +177,9 @@
"removeThisProfile": "移除此設定",
"requestFailed": "請求失敗",
"rescan": "重新掃描",
+ "resolvingModelOptions": "正在載入模型選項…",
"retry": "重試",
+ "retryModelOptions": "重試",
"retryUpdate": "重試更新",
"retryVersionCheck": "重試版本檢查",
"reviewUpdateBeforeApplying": "在 Kandev 更改此主機執行時之前,請先審閱更新。",
@@ -212,10 +221,5 @@
"watcherKindGithubIssue": "GitHub 議題",
"watcherKindGithubReview": "GitHub PR 審查",
"watcherKindJira": "Jira",
- "watcherKindLinear": "Linear",
- "duplicate": "複製",
- "duplicateProfileNamed": "複製 {{name}}",
- "duplicateProfileSuccess": "代理程式設定檔已複製",
- "duplicateSaveBlocked": "設定仍在儲存,請稍後重試。",
- "failedToDuplicateProfile": "複製代理程式設定檔失敗"
+ "watcherKindLinear": "Linear"
}