Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)在本地执行。
Expand Down
10 changes: 8 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
}
Expand Down
57 changes: 52 additions & 5 deletions internal/config/mcpjson.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions internal/config/mcpjson_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading