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({ ✕ + + + {!view ? ( @@ -181,9 +186,8 @@ export function CapabilitiesPanel({ servers={serverGroups.failed} expanded={expandedErrors} onToggle={toggleError} - onRetry={(name) => void mutate(() => app.RetryMCPServer(name))} + onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))} onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))} - onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))} onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))} busy={busy} /> @@ -202,10 +206,10 @@ export function CapabilitiesPanel({ setEditing(name); }} onCancelEdit={() => setEditing(null)} - onRetry={(name) => void mutate(() => app.RetryMCPServer(name))} + onRetry={(name) => void mutate(() => app.ReconnectMCPServer(name))} + onReconnect={(name) => void mutate(() => app.ReconnectMCPServer(name))} onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))} onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))} - onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))} onUpdate={(name, input) => void mutate(() => app.UpdateMCPServer(name, input)).then((ok) => { if (ok) setEditing(null); @@ -594,9 +598,9 @@ function ServerGroup({ onEdit, onCancelEdit, onRetry, + onReconnect, onConfirmClearAuth, onToggle, - onSetTier, onUpdate, onToggleDetails, onToggleTools, @@ -610,9 +614,9 @@ function ServerGroup({ onEdit: (name: string) => void; onCancelEdit: () => void; onRetry: (name: string) => void; + onReconnect: (name: string) => void; onConfirmClearAuth: (name: string) => void; onToggle: (name: string, on: boolean) => void; - onSetTier: (name: string, tier: string) => void; onUpdate: (name: string, input: MCPServerInput) => void; onToggleDetails: (name: string) => void; onToggleTools: (name: string) => void; @@ -632,9 +636,9 @@ function ServerGroup({ onEdit={() => onEdit(s.name)} onCancelEdit={onCancelEdit} onRetry={() => onRetry(s.name)} + onReconnect={() => onReconnect(s.name)} onConfirmClearAuth={() => onConfirmClearAuth(s.name)} onToggle={(on) => onToggle(s.name, on)} - onSetTier={(tier) => onSetTier(s.name, tier)} onUpdate={(input) => onUpdate(s.name, input)} onToggleDetails={() => onToggleDetails(s.name)} onToggleTools={() => onToggleTools(s.name)} @@ -651,7 +655,6 @@ function FailedServersNotice({ onToggle, onRetry, onConfirmClearAuth, - onSetTier, onConfirm, }: { servers: ServerView[]; @@ -660,7 +663,6 @@ function FailedServersNotice({ onToggle: (name: string) => void; onRetry: (name: string) => void; onConfirmClearAuth: (name: string) => void; - onSetTier: (name: string, tier: string) => void; onConfirm: (name: string) => void; }) { const t = useT(); @@ -677,7 +679,6 @@ function FailedServersNotice({ const open = expanded.has(s.name); const error = s.error || t("caps.failed"); const actionLabel = serverActionLabel(s, t); - const canConfigure = s.configured; const handlePrimaryAction = () => { if (shouldOpenAuth(s)) { openExternal((s.authUrl || "").trim()); @@ -721,11 +722,6 @@ function FailedServersNotice({ /> )} - {canConfigure && ( -
- onSetTier(s.name, tier)} /> -
- )} {open && (
@@ -755,9 +751,9 @@ function ServerRow({ onEdit, onCancelEdit, onRetry, + onReconnect, onConfirmClearAuth, onToggle, - onSetTier, onUpdate, onToggleDetails, onToggleTools, @@ -771,9 +767,9 @@ function ServerRow({ onEdit: () => void; onCancelEdit: () => void; onRetry: () => void; + onReconnect: () => void; onConfirmClearAuth: () => void; onToggle: (on: boolean) => void; - onSetTier: (tier: string) => void; onUpdate: (input: MCPServerInput) => void; onToggleDetails: () => void; onToggleTools: () => void; @@ -856,8 +852,8 @@ function ServerRow({ busy={busy} onConfirm={onConfirm} onConnectNow={onRetry} + onReconnect={onReconnect} onConfirmClearAuth={onConfirmClearAuth} - onSetTier={onSetTier} toolsExpanded={toolsExpanded} editing={editing} onEdit={onEdit} @@ -876,8 +872,8 @@ function ServerDetails({ busy, onConfirm, onConnectNow, + onReconnect, onConfirmClearAuth, - onSetTier, toolsExpanded, editing, onEdit, @@ -890,8 +886,8 @@ function ServerDetails({ busy: boolean; onConfirm: () => void; onConnectNow: () => void; + onReconnect: () => void; onConfirmClearAuth: () => void; - onSetTier: (tier: string) => void; toolsExpanded: boolean; editing: boolean; onEdit: () => void; @@ -901,9 +897,9 @@ function ServerDetails({ }) { const t = useT(); const command = serverCommand(s); - const canConfigure = s.configured; const canEditConfig = s.configured && !s.builtIn; const canConnectNow = s.status === "deferred" || s.status === "disabled"; + const canReconnect = s.status === "connected"; const canShowTools = (s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0; const showClearAuth = canClearAuth(s); const authLabel = serverAuthLabel(s, t); @@ -931,9 +927,6 @@ function ServerDetails({ {authLabel}
)} - {canConfigure && ( - - )} {command && (
{s.transport === "stdio" ? t("caps.command") : t("caps.url")} @@ -953,6 +946,11 @@ function ServerDetails({ {t("caps.connectNow")} )} + {canReconnect && ( + + )} {canShowTools && (