diff --git a/README.md b/README.md
index a8eb0ac54..37b491838 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,11 @@ url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_KEY}" }
```
+Enabled MCP servers start connecting automatically in the background after a
+session begins, so chat stays usable while tools come online. Use `/mcp` or the
+desktop MCP panel to refresh status, reconnect a server, inspect failures, or
+disable a server for the current session.
+
**Already have an `.mcp.json`?** Drop it in the project root and Reasonix
reads it as-is — the `mcpServers` spec (`command`/`args`/`env`, `type`/`url`/
`headers`, `${VAR}` expansion) maps field-for-field onto `[[plugins]]`. Both
diff --git a/desktop/app.go b/desktop/app.go
index e89e5b1ab..68d1a770d 100644
--- a/desktop/app.go
+++ b/desktop/app.go
@@ -1467,14 +1467,17 @@ func skillRootsView() []SkillRootView {
cfg, _ := config.Load()
userCfg := config.LoadForEdit(config.UserConfigPath())
var custom []string
+ maxDepth := 3
if cfg != nil {
custom = cfg.SkillCustomPaths()
+ maxDepth = cfg.SkillMaxDepth()
}
- st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, DisableBuiltins: true, Stderr: io.Discard})
+ st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
counts := map[string]int{}
skillItems := map[string][]SkillRootSkillView{}
+ roots := st.Roots()
for _, sk := range st.List() {
- root := config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
+ root := skillDisplayRoot(sk, roots)
counts[root]++
skillItems[root] = append(skillItems[root], SkillRootSkillView{
Name: sk.Name,
@@ -1495,7 +1498,7 @@ func skillRootsView() []SkillRootView {
}
}
out := []SkillRootView{}
- for _, r := range st.Roots() {
+ for _, r := range roots {
dir := config.CanonicalSkillPath(r.Dir)
view := SkillRootView{
Dir: r.Dir,
@@ -1626,6 +1629,21 @@ func skillRootPath(path string) string {
return path
}
+func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
+ cleanPath := filepath.Clean(sk.Path)
+ for _, r := range roots {
+ if r.Scope != sk.Scope {
+ continue
+ }
+ cleanRoot := filepath.Clean(r.Dir)
+ prefix := cleanRoot + string(filepath.Separator)
+ if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
+ return config.CanonicalSkillPath(r.Dir)
+ }
+ }
+ return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
+}
+
// MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
// + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
type MCPServerInput struct {
@@ -1635,7 +1653,6 @@ type MCPServerInput struct {
Args []string `json:"args"`
URL string `json:"url"`
Env map[string]string `json:"env"`
- Tier string `json:"tier"`
}
// AddMCPServer connects a server live and persists it to config (Customize → MCP →
@@ -1652,8 +1669,8 @@ func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
Args: in.Args,
URL: in.URL,
Env: in.Env,
- Tier: normalizeMCPTier(in.Tier),
}
+ entry, _ = config.NormalizePluginCommandLine(entry)
if err := a.saveDesktopMCPServer(entry); err != nil {
return 0, err
}
@@ -1684,10 +1701,11 @@ func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
updated.Command = strings.TrimSpace(in.Command)
updated.Args = append([]string(nil), in.Args...)
updated.URL = strings.TrimSpace(in.URL)
- updated.Tier = normalizeMCPTier(in.Tier)
+ updated.Tier = ""
if in.Env != nil {
updated.Env = in.Env
}
+ updated, _ = config.NormalizePluginCommandLine(updated)
if updated.Type == "stdio" {
updated.URL = ""
} else {
@@ -1743,15 +1761,26 @@ func (a *App) RemoveMCPServer(name string) error {
return fmt.Errorf("no MCP server named %q", name)
}
-// RetryMCPServer reconnects a configured server that failed or was disconnected,
-// without touching config (the failed row's retry button).
-func (a *App) RetryMCPServer(name string) error {
+// ReconnectMCPServer disconnects the server if it is already connected (to force
+// a fresh handshake and tool re-registration), then reconnects. Failures are
+// recorded on the Host so the UI can render them.
+func (a *App) ReconnectMCPServer(name string) error {
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return fmt.Errorf("no active session")
}
+ if mcpConnected(tab.Ctrl, name) {
+ tab.Ctrl.DisconnectMCPServer(name)
+ }
_, err := a.connectConfiguredMCPServerForTab(tab, name)
- return err
+ if err != nil {
+ recordMCPFailure(tab.Ctrl, config.PluginEntry{Name: name}, err)
+ return err
+ }
+ a.mu.Lock()
+ delete(tab.disabledMCP, name)
+ a.mu.Unlock()
+ return nil
}
// ClearMCPServerAuthentication removes local auth-like config for one MCP and
@@ -1829,9 +1858,9 @@ func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (
return 0, fmt.Errorf("no configured MCP server named %q", name)
}
-// SetMCPServerTier persists how a configured MCP server should start on future
-// sessions. It does not tear down a connected server; the per-session toggle and
-// "connect now" remain separate controls.
+// SetMCPServerTier is kept for old desktop bindings. New config writes drop the
+// retired tier field, so this only affects the active session before the next
+// config reload.
func (a *App) SetMCPServerTier(name, tier string) error {
if name == "codegraph" {
return a.setCodegraphTier(tier)
@@ -1878,9 +1907,6 @@ func (a *App) setCodegraphEnabled(enabled bool) error {
if err := cfg.SaveTo(path); err != nil {
return err
}
- if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
- return err
- }
if enabled {
a.mu.Lock()
delete(tab.disabledMCP, "codegraph")
@@ -1917,9 +1943,6 @@ func (a *App) setCodegraphTier(tier string) error {
if err := cfg.SaveTo(path); err != nil {
return err
}
- if err := a.syncProjectCodegraphOverride(cfg.Codegraph); err != nil {
- return err
- }
tab := a.activeTab()
if tab == nil || tab.Ctrl == nil {
return nil
@@ -1953,37 +1976,21 @@ func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, er
}
func (a *App) saveDesktopMCPServer(entry config.PluginEntry) error {
- cfg, path, err := a.loadDesktopUserConfigForEdit()
- if err != nil {
+ if err := config.UpsertMCPPlugin(entry); err != nil {
return err
}
- if err := cfg.UpsertPlugin(entry); err != nil {
- return err
- }
- if err := cfg.SaveTo(path); err != nil {
- return err
- }
- _, err = a.removeProjectMCPOverride(entry.Name)
- return err
+ // Project-level overrides are no longer supported; clean up legacy entries.
+ _, _ = a.removeProjectMCPOverride(entry.Name)
+ return nil
}
func (a *App) removeDesktopMCPServer(name string) (bool, error) {
- removed := false
- cfg, path, err := a.loadDesktopUserConfigForEdit()
+ found, err := config.RemoveMCPPlugin(name)
if err != nil {
return false, err
}
- if cfg.RemovePlugin(name) {
- removed = true
- if err := cfg.SaveTo(path); err != nil {
- return false, err
- }
- }
- projectRemoved, err := a.removeProjectMCPOverride(name)
- if err != nil {
- return removed, err
- }
- return removed || projectRemoved, nil
+ projectRemoved, _ := a.removeProjectMCPOverride(name)
+ return found || projectRemoved, nil
}
func (a *App) removeProjectMCPOverride(name string) (bool, error) {
@@ -2008,23 +2015,6 @@ func (a *App) removeProjectMCPOverride(name string) (bool, error) {
return true, nil
}
-func (a *App) syncProjectCodegraphOverride(c config.CodegraphConfig) error {
- path := projectConfigPathForRoot(a.activeWorkspaceRoot())
- userPath := config.UserConfigPath()
- if path == "" || sameConfigPath(path, userPath) {
- return nil
- }
- if _, err := os.Stat(path); err != nil {
- if os.IsNotExist(err) {
- return nil
- }
- return err
- }
- cfg := config.LoadForEdit(path)
- cfg.Codegraph = c
- return cfg.SaveTo(path)
-}
-
func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
for _, p := range entries {
if p.Name == name {
@@ -2040,6 +2030,8 @@ func normalizeMCPTier(tier string) string {
return "eager"
case "background":
return "background"
+ case "":
+ return "background"
default:
return "lazy"
}
diff --git a/desktop/app_test.go b/desktop/app_test.go
index 28f7ca717..cd3ba4237 100644
--- a/desktop/app_test.go
+++ b/desktop/app_test.go
@@ -50,9 +50,30 @@ func isolateDesktopUserDirs(t *testing.T) string {
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", xdg)
t.Setenv("AppData", appData)
+ t.Setenv("APPDATA", appData)
return home
}
+func writeDesktopConfig(t *testing.T, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(config.UserConfigPath(), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func writeDesktopMCP(t *testing.T, body string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(config.UserMCPConfigPath()), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(config.UserMCPConfigPath(), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
app := NewApp()
cmds := app.Commands()
@@ -174,7 +195,7 @@ close_behavior = "quit"
}
}
-func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
+func TestSettingsIgnoresLegacyProjectConfigWhenUserConfigMissing(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
@@ -201,8 +222,8 @@ close_behavior = "quit"
if got.ConfigPath != config.UserConfigPath() {
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
}
- if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" {
- t.Fatalf("Settings did not seed from legacy project config: %+v", got)
+ if got.DefaultModel == "legacy-provider/legacy-model" || got.DesktopLanguage == "zh" || got.DesktopTheme == "light" || got.DesktopThemeStyle == "glacier" || got.CloseBehavior == "quit" {
+ t.Fatalf("Settings should ignore legacy project config: %+v", got)
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
@@ -211,8 +232,44 @@ close_behavior = "quit"
t.Fatalf("SetDesktopLanguage: %v", err)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
- if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" {
- t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior())
+ if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.DesktopThemeStyle() == "glacier" || userCfg.DesktopCloseBehavior() == "quit" {
+ t.Fatalf("saved user config should use global defaults plus the edit, not legacy project prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior())
+ }
+}
+
+func TestSettingsSubagentDefaultsRoundTrip(t *testing.T) {
+ isolateDesktopUserDirs(t)
+ if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
+ t.Fatalf("mkdir config dir: %v", err)
+ }
+ if err := os.WriteFile(config.UserConfigPath(), []byte(`
+default_model = "deepseek/deepseek-v4-flash"
+
+[[providers]]
+name = "deepseek"
+kind = "openai"
+base_url = "https://api.deepseek.com"
+models = ["deepseek-v4-flash", "deepseek-v4-pro"]
+default = "deepseek-v4-flash"
+`), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ app := NewApp()
+ if err := app.SetSubagentModel("deepseek/deepseek-v4-pro"); err != nil {
+ t.Fatalf("SetSubagentModel: %v", err)
+ }
+ if err := app.SetSubagentEffort("max"); err != nil {
+ t.Fatalf("SetSubagentEffort: %v", err)
+ }
+
+ got := app.Settings()
+ if got.SubagentModel != "deepseek/deepseek-v4-pro" || got.SubagentEffort != "max" {
+ t.Fatalf("subagent settings = model:%q effort:%q", got.SubagentModel, got.SubagentEffort)
+ }
+ cfg := config.LoadForEdit(config.UserConfigPath())
+ if cfg.Agent.SubagentModel != "deepseek/deepseek-v4-pro" || cfg.Agent.SubagentEffort != "max" {
+ t.Fatalf("saved config = model:%q effort:%q", cfg.Agent.SubagentModel, cfg.Agent.SubagentEffort)
}
}
@@ -554,21 +611,20 @@ func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
}
}
-func TestCapabilitiesShowsLazyMCPAsDeferredNotDisabled(t *testing.T) {
+func TestCapabilitiesShowsDefaultMCPAsInitializingNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -581,8 +637,8 @@ args = ["-y", "@playwright/mcp"]
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
- if s.Status != "deferred" {
- t.Fatalf("lazy MCP status = %q, want deferred; server = %+v", s.Status, s)
+ if s.Status != "initializing" {
+ t.Fatalf("default MCP status = %q, want initializing; server = %+v", s.Status, s)
}
return
}
@@ -611,8 +667,8 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
if s.AutoStart {
t.Fatalf("codegraph autoStart = true, want false; server = %+v", s)
}
- if s.Tier != "lazy" {
- t.Fatalf("codegraph tier = %q, want lazy; server = %+v", s.Tier, s)
+ if s.Tier != "background" {
+ t.Fatalf("codegraph tier = %q, want background; server = %+v", s.Tier, s)
}
return
}
@@ -620,22 +676,21 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
}
-func TestCapabilitiesMarksDeferredRemoteMCPAuthPossible(t *testing.T) {
+func TestCapabilitiesMarksBackgroundRemoteMCPAuthPossible(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "dida"
type = "http"
url = "https://mcp.dida365.com"
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -644,7 +699,7 @@ tier = "lazy"
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "dida" {
- if s.Status != "deferred" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
+ if s.Status != "initializing" || s.AuthStatus != "possible" || s.AuthURL != "https://mcp.dida365.com" {
t.Fatalf("dida auth diagnosis = %+v", s)
}
return
@@ -657,19 +712,18 @@ func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "stripe"
type = "http"
url = "https://mcp.stripe.com"
headers = { Authorization = "Bearer ${STRIPE_TOKEN}" }
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -691,18 +745,17 @@ func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "figma"
type = "http"
url = "https://mcp.figma.com/mcp"
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -726,10 +779,11 @@ func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "figma"
type = "http"
@@ -737,9 +791,7 @@ url = "https://mcp.figma.com/mcp?access_token=abc&workspace=main"
headers = { Authorization = "Bearer ${FIGMA_TOKEN}", "X-Org" = "team" }
env = { FIGMA_TOKEN = "${FIGMA_TOKEN}", DEBUG = "1" }
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -776,8 +828,8 @@ tier = "lazy"
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "figma" {
- if s.Status != "deferred" || s.AuthStatus != "possible" {
- t.Fatalf("figma should return to deferred possible auth: %+v", s)
+ if s.Status != "initializing" || s.AuthStatus != "possible" {
+ t.Fatalf("figma should return to background possible auth: %+v", s)
}
return
}
@@ -785,22 +837,22 @@ tier = "lazy"
t.Fatalf("figma MCP missing from Capabilities: %+v", view.Servers)
}
-func TestUpdateMCPServerKeepsLazyMCPDeferred(t *testing.T) {
+func TestUpdateMCPServerMigratesLegacyTierToBackground(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+tier = "lazy"
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -815,7 +867,6 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
Transport: "stdio",
Command: "node",
Args: []string{"server.js"},
- Tier: "lazy",
}); err != nil {
t.Fatalf("UpdateMCPServer: %v", err)
}
@@ -829,23 +880,21 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("env TOKEN = %q, want preserved env", got)
}
- userCfg := config.LoadForEdit(config.UserConfigPath())
- userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
+ userPlugin, ok := findPluginEntry(cfg.Plugins, "playwright")
if !ok {
- t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
+ t.Fatalf("playwright should be saved to mcp.toml: %+v", cfg.Plugins)
}
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
t.Fatalf("user plugin after migration = %+v", userPlugin)
}
- projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
- if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
- t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+ if userPlugin.Tier != "" {
+ t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
}
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
- if s.Status != "deferred" {
- t.Fatalf("updated lazy MCP status = %q, want deferred; server = %+v", s.Status, s)
+ if s.Status != "failed" {
+ t.Fatalf("updated MCP status = %q, want failed after immediate reconnect attempt; server = %+v", s.Status, s)
}
if s.Command != "node" || len(s.Args) != 1 || s.Args[0] != "server.js" {
t.Fatalf("server command not refreshed: %+v", s)
@@ -856,21 +905,59 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
-func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
+func TestUpdateMCPServerSplitsPastedCommandLine(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
-name = "broken"
+name = "playwright"
command = "npx"
-tier = "lazy"
-`), 0o644); err != nil {
+args = ["-y", "@playwright/mcp"]
+`)
+
+ app := NewApp()
+ app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+ defer app.activeCtrl().Close()
+
+ if err := app.UpdateMCPServer("playwright", MCPServerInput{
+ Name: "playwright",
+ Transport: "stdio",
+ Command: "npx -y @modelcontextprotocol/server-filesystem .",
+ }); err != nil {
+ t.Fatalf("UpdateMCPServer: %v", err)
+ }
+ cfg, err := config.Load()
+ if err != nil {
t.Fatal(err)
}
+ p := cfg.Plugins[0]
+ if p.Command != "npx" {
+ t.Fatalf("command = %q, want npx", p.Command)
+ }
+ if got := strings.Join(p.Args, "\x00"); got != strings.Join([]string{"-y", "@modelcontextprotocol/server-filesystem", "."}, "\x00") {
+ t.Fatalf("args = %v", p.Args)
+ }
+}
+
+func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
+ isolateDesktopUserDirs(t)
+ dir := t.TempDir()
+ t.Chdir(dir)
+ writeDesktopConfig(t, `
+[codegraph]
+enabled = false
+`)
+ writeDesktopMCP(t, `
+[[plugins]]
+name = "broken"
+command = "npx"
+tier = "background"
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -880,7 +967,6 @@ tier = "lazy"
Name: "broken",
Transport: "stdio",
Command: "reasonix-missing-mcp-binary",
- Tier: "background",
}); err != nil {
t.Fatalf("UpdateMCPServer should persist config even when reconnect fails: %v", err)
}
@@ -891,8 +977,8 @@ tier = "lazy"
if got := cfg.Plugins[0].Command; got != "reasonix-missing-mcp-binary" {
t.Fatalf("updated command = %q, want missing binary", got)
}
- if got := cfg.Plugins[0].Tier; got != "background" {
- t.Fatalf("updated tier = %q, want background", got)
+ if got := cfg.Plugins[0].Tier; got != "" {
+ t.Fatalf("updated tier = %q, want migrated empty", got)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
@@ -916,17 +1002,16 @@ func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "broken"
command = "reasonix-missing-mcp-binary"
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -937,26 +1022,21 @@ tier = "lazy"
}()
if err := app.SetMCPServerTier("broken", "background"); err != nil {
- t.Fatalf("SetMCPServerTier should persist tier even when immediate connect fails: %v", err)
+ t.Fatalf("SetMCPServerTier legacy binding: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
- if got := cfg.Plugins[0].Tier; got != "background" {
- t.Fatalf("saved tier = %q, want background", got)
+ if got := cfg.Plugins[0].Tier; got != "" {
+ t.Fatalf("saved tier = %q, want migrated empty", got)
}
- userCfg := config.LoadForEdit(config.UserConfigPath())
- userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
+ userPlugin, ok := findPluginEntry(cfg.Plugins, "broken")
if !ok {
- t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
+ t.Fatalf("broken should be saved to mcp.toml: %+v", cfg.Plugins)
}
- if userPlugin.Tier != "background" {
- t.Fatalf("user plugin tier = %q, want background", userPlugin.Tier)
- }
- projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
- if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
- t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
+ if userPlugin.Tier != "" {
+ t.Fatalf("user plugin tier = %q, want migrated empty", userPlugin.Tier)
}
if !mcpFailed(app.activeCtrl(), "broken") {
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
@@ -977,21 +1057,16 @@ tier = "lazy"
}
func TestSetMCPServerTierPersistsCodegraphConfig(t *testing.T) {
- t.Setenv("HOME", t.TempDir())
- t.Setenv("USERPROFILE", t.TempDir())
- t.Setenv("XDG_CONFIG_HOME", t.TempDir())
- t.Setenv("AppData", t.TempDir())
+ isolateDesktopUserDirs(t)
t.Setenv("PATH", t.TempDir())
t.Setenv("REASONIX_CACHE_DIR", t.TempDir()) // isolate the codegraph bundle cache so Resolve fails deterministically
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
auto_install = true
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -1007,15 +1082,15 @@ auto_install = true
if !cfg.Codegraph.Enabled {
t.Fatal("codegraph enabled = false, want true after selecting a startup tier")
}
- if got := cfg.Codegraph.Tier; got != "background" {
- t.Fatalf("codegraph tier = %q, want background", got)
+ if got := cfg.Codegraph.Tier; got != "" {
+ t.Fatalf("codegraph tier = %q, want migrated empty", got)
}
userCfg := config.LoadForEdit(config.UserConfigPath())
if !userCfg.Codegraph.Enabled {
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
}
- if got := userCfg.Codegraph.Tier; got != "background" {
- t.Fatalf("user codegraph tier = %q, want background", got)
+ if got := userCfg.Codegraph.Tier; got != "" {
+ t.Fatalf("user codegraph tier = %q, want migrated empty", got)
}
if !mcpFailed(app.activeCtrl(), "codegraph") {
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
@@ -1039,13 +1114,11 @@ func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = true
tier = "lazy"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -1077,21 +1150,20 @@ tier = "lazy"
t.Fatalf("codegraph missing from Capabilities: %+v", view.Servers)
}
-func TestCapabilitiesKeepsFailedMCPConfiguredTierAfterRestart(t *testing.T) {
+func TestCapabilitiesMigratesFailedMCPConfiguredTierAfterRestart(t *testing.T) {
isolateDesktopUserDirs(t)
dir := t.TempDir()
t.Chdir(dir)
- if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
+ writeDesktopConfig(t, `
[codegraph]
enabled = false
-
+`)
+ writeDesktopMCP(t, `
[[plugins]]
name = "broken"
command = "reasonix-missing-mcp-binary"
tier = "eager"
-`), 0o644); err != nil {
- t.Fatal(err)
- }
+`)
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -1108,8 +1180,8 @@ tier = "eager"
if s.Status != "failed" {
t.Fatalf("server status = %q, want failed; server = %+v", s.Status, s)
}
- if s.Tier != "eager" {
- t.Fatalf("server tier = %q, want eager so failed UI preserves the configured selection", s.Tier)
+ if s.Tier != "background" {
+ t.Fatalf("server tier = %q, want migrated background default", s.Tier)
}
if !s.Configured {
t.Fatalf("server configured = false, want true; server = %+v", s)
diff --git a/desktop/frontend/src/components/CapabilitiesPanel.tsx b/desktop/frontend/src/components/CapabilitiesPanel.tsx
index 6b9d35d6f..ab9a11cca 100644
--- a/desktop/frontend/src/components/CapabilitiesPanel.tsx
+++ b/desktop/frontend/src/components/CapabilitiesPanel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useId, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { asArray } from "../lib/array";
import { app, openExternal } from "../lib/bridge";
import { useT } from "../lib/i18n";
@@ -140,6 +140,11 @@ export function CapabilitiesPanel({
✕
+