diff --git a/README.md b/README.md index 93b7b262c..4e5789e89 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,11 @@ sources are merged; on a name collision `reasonix.toml` wins. } ``` +**Upgrading from `0.x`?** Your old `~/.reasonix/config.json` is still read for its +`mcpServers` (honouring `mcpDisabled`) as a lowest-priority source, so MCP servers +keep working — move them into `reasonix.toml`'s `[[plugins]]` or a `.mcp.json` when +convenient. + ### Slash commands In `reasonix chat`, built-in commands (`/compact`, `/new`, `/rewind`, `/tree`, diff --git a/README.zh-CN.md b/README.zh-CN.md index a3171b928..242121669 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -178,6 +178,10 @@ headers = { Authorization = "Bearer ${STRIPE_KEY}" } } ``` +**从 `0.x` 升级?** 旧的 `~/.reasonix/config.json` 仍会被读取(读其 `mcpServers`、并遵从 +`mcpDisabled`),作为最低优先级来源——所以 MCP 服务器照常可用;方便时再把它们挪进 +`reasonix.toml` 的 `[[plugins]]` 或 `.mcp.json`。 + ### 斜杠命令 `reasonix chat` 里,内置命令(`/compact`、`/new`、`/rewind`、`/tree`、`/branch`、`/switch`、`/todo`、`/model`、`/effort`、`/mcp`、`/help`)在本地执行。 diff --git a/internal/config/config.go b/internal/config/config.go index 7e2a12c80..2032b5037 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -490,8 +490,9 @@ func Default() *Config { } // Load builds the configuration: defaults, then user config, then project -// config, then any MCP servers from Claude Code's .mcp.json. A .env in the -// working directory is loaded first so api_key_env can resolve. +// config, then MCP servers from Claude Code's .mcp.json, then (lowest priority) +// the v0.x ~/.reasonix/config.json's mcpServers. A .env in the working directory +// is loaded first so api_key_env can resolve. func Load() (*Config, error) { loadDotEnv() cfg := Default() @@ -523,6 +524,11 @@ func Load() (*Config, error) { return nil, err } cfg.mergeMCPJSON(entries) + + // Lowest priority: the v0.x ~/.reasonix/config.json's mcpServers, so upgrading + // from the TypeScript line keeps MCP servers without rewriting them. Anything + // the v2 config or .mcp.json already declared wins on a name collision. + cfg.mergeMCPJSON(loadLegacyMCP(legacyConfigPath())) normalizeLegacyEffort(cfg) return cfg, nil } diff --git a/internal/config/mcpjson.go b/internal/config/mcpjson.go index af516c37d..934a64bb0 100644 --- a/internal/config/mcpjson.go +++ b/internal/config/mcpjson.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "sort" ) @@ -43,14 +44,23 @@ func loadMCPJSON(path string) ([]PluginEntry, error) { if err := json.Unmarshal(b, &doc); err != nil { return nil, fmt.Errorf("mcp config %s: %w", path, err) } - names := make([]string, 0, len(doc.MCPServers)) - for name := range doc.MCPServers { - names = append(names, name) + return specsToEntries(doc.MCPServers, nil), nil +} + +// specsToEntries converts an mcpServers map to PluginEntry values, sorted by name +// for a stable connection order. Names in skip are dropped (used for v0.x's +// mcpDisabled list). +func specsToEntries(specs map[string]mcpServerSpec, skip map[string]bool) []PluginEntry { + names := make([]string, 0, len(specs)) + for name := range specs { + if !skip[name] { + names = append(names, name) + } } sort.Strings(names) entries := make([]PluginEntry, 0, len(names)) for _, name := range names { - s := doc.MCPServers[name] + s := specs[name] entries = append(entries, PluginEntry{ Name: name, Type: s.Type, @@ -62,7 +72,44 @@ func loadMCPJSON(path string) ([]PluginEntry, error) { AutoStart: s.AutoStart, }) } - return entries, nil + return entries +} + +// legacyConfigPath is the v0.x (TypeScript line) config file, ~/.reasonix/config.json. +func legacyConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".reasonix", "config.json") +} + +// loadLegacyMCP reads the v0.x ~/.reasonix/config.json and returns its enabled +// mcpServers as PluginEntry values (servers listed in its mcpDisabled are +// skipped), so upgrading from v0.x keeps MCP servers working without rewriting +// them as [[plugins]]. Absent or malformed → nil: a stale legacy file must never +// block startup, and it is the lowest-priority source anyway (the v2 config and +// .mcp.json win on a name collision — see Load). +func loadLegacyMCP(path string) []PluginEntry { + if path == "" { + return nil + } + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var doc struct { + MCPServers map[string]mcpServerSpec `json:"mcpServers"` + MCPDisabled []string `json:"mcpDisabled"` + } + if err := json.Unmarshal(b, &doc); err != nil { + return nil + } + disabled := make(map[string]bool, len(doc.MCPDisabled)) + for _, n := range doc.MCPDisabled { + disabled[n] = true + } + return specsToEntries(doc.MCPServers, disabled) } // mergeMCPJSON appends servers from .mcp.json that the TOML config did not diff --git a/internal/config/mcpjson_test.go b/internal/config/mcpjson_test.go index f3c31c75d..7bf15369c 100644 --- a/internal/config/mcpjson_test.go +++ b/internal/config/mcpjson_test.go @@ -166,3 +166,50 @@ func TestMergeMCPJSONPrecedence(t *testing.T) { t.Errorf("non-colliding entry not appended: %+v", cfg.Plugins[1]) } } + +func TestLoadLegacyMCP(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + doc := `{ + "mcpServers": { + "github": { "command": "npx", "args": ["-y", "server-github"], "env": { "TOKEN": "x" } }, + "old": { "command": "foo" }, + "remote": { "type": "sse", "url": "https://x/sse", "headers": { "Authorization": "Bearer y" } } + }, + "mcpDisabled": ["old"], + "projects": { "/some/root": { "shellAllowed": [] } } +}` + if err := os.WriteFile(path, []byte(doc), 0o644); err != nil { + t.Fatal(err) + } + + got := loadLegacyMCP(path) + // "old" is in mcpDisabled and dropped; github + remote remain, name-sorted. + if len(got) != 2 { + t.Fatalf("got %d entries, want 2: %+v", len(got), got) + } + if got[0].Name != "github" || got[1].Name != "remote" { + t.Fatalf("names = %q, %q; want github, remote", got[0].Name, got[1].Name) + } + if got[0].Command != "npx" || got[0].Env["TOKEN"] != "x" { + t.Errorf("github mapped wrong: %+v", got[0]) + } + if got[1].Type != "sse" || got[1].URL != "https://x/sse" || got[1].Headers["Authorization"] != "Bearer y" { + t.Errorf("remote mapped wrong: %+v", got[1]) + } + + // Absent, malformed, and empty paths must not error — just yield nil, so a + // stale legacy file can never block startup. + if got := loadLegacyMCP(filepath.Join(dir, "nope.json")); got != nil { + t.Errorf("absent file: got %+v, want nil", got) + } + if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if got := loadLegacyMCP(path); got != nil { + t.Errorf("malformed file: got %+v, want nil", got) + } + if got := loadLegacyMCP(""); got != nil { + t.Errorf("empty path: got %+v, want nil", got) + } +}