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
{error}
}{t("agents:mcpStrategyHelp")}
+