| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "regexp" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/fileutil" |
| 13 | fileencoding "reasonix/internal/fileutil/encoding" |
| 14 | "reasonix/internal/mcpdiag" |
| 15 | ) |
| 16 | |
| 17 | // mcpJSONFile is the project-root file Claude Code calls .mcp.json. Reasonix reads |
| 18 | // it so an MCP server already configured for Claude works here unchanged — the |
| 19 | // server specs map field-for-field onto PluginEntry. |
| 20 | const mcpJSONFile = ".mcp.json" |
| 21 | |
| 22 | // mcpServerSpec mirrors one entry of Claude Code's "mcpServers" map. The field |
| 23 | // names and semantics match PluginEntry: command/args/env describe a local |
| 24 | // stdio server; type/url/headers describe a remote one. Reasonix also accepts |
| 25 | // startup and call timeout fields as Reasonix policy extensions. |
| 26 | type mcpServerSpec struct { |
| 27 | Type string `json:"type"` |
| 28 | Command string `json:"command"` |
| 29 | Args []string `json:"args"` |
| 30 | Env map[string]string `json:"env"` |
| 31 | URL string `json:"url"` |
| 32 | Headers map[string]string `json:"headers"` |
| 33 | StartupTimeoutSeconds int `json:"startup_timeout_seconds"` |
| 34 | CallTimeoutSeconds int `json:"call_timeout_seconds"` |
| 35 | ToolTimeoutSeconds map[string]int `json:"tool_timeout_seconds"` |
| 36 | AutoStart *bool `json:"auto_start"` |
| 37 | } |
| 38 | |
| 39 | // loadMCPJSON reads path (Claude Code's .mcp.json) and returns its servers as |
| 40 | // PluginEntry values, sorted by name for a stable connection order. An absent |
| 41 | // file is not an error (returns nil, nil). A present-but-malformed file is an |
| 42 | // error so a typo surfaces loudly instead of silently dropping every server. |
| 43 | func loadMCPJSON(path string) ([]PluginEntry, error) { |
| 44 | resolved, err := resolveConfigAccessPath(path, false) |
| 45 | if err != nil { |
| 46 | return nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 47 | } |
| 48 | b, err := fileencoding.ReadFileUTF8(resolved) |
| 49 | if os.IsNotExist(err) { |
| 50 | return nil, nil |
| 51 | } |
| 52 | if err != nil { |
| 53 | return nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 54 | } |
| 55 | var doc struct { |
| 56 | MCPServers map[string]mcpServerSpec `json:"mcpServers"` |
| 57 | } |
| 58 | if err := json.Unmarshal(b, &doc); err != nil { |
| 59 | return nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 60 | } |
| 61 | return specsToEntries(doc.MCPServers, nil), nil |
| 62 | } |
| 63 | |
| 64 | // LoadMCPJSONPlugin returns one server entry from a Claude-compatible .mcp.json. |
| 65 | func LoadMCPJSONPlugin(path, name string) (PluginEntry, bool, error) { |
| 66 | entries, err := loadMCPJSON(path) |
| 67 | if err != nil { |
| 68 | return PluginEntry{}, false, err |
| 69 | } |
| 70 | for _, entry := range entries { |
| 71 | if entry.Name == name { |
| 72 | return entry, true, nil |
| 73 | } |
| 74 | } |
| 75 | return PluginEntry{}, false, nil |
| 76 | } |
| 77 | |
| 78 | // specsToEntries converts an mcpServers map to PluginEntry values, sorted by name |
| 79 | // for a stable connection order. Names in skip are dropped (used for v0.x's |
| 80 | // mcpDisabled list). |
| 81 | func specsToEntries(specs map[string]mcpServerSpec, skip map[string]bool) []PluginEntry { |
| 82 | names := make([]string, 0, len(specs)) |
| 83 | for name := range specs { |
| 84 | if !skip[name] { |
| 85 | names = append(names, name) |
| 86 | } |
| 87 | } |
| 88 | sort.Strings(names) |
| 89 | entries := make([]PluginEntry, 0, len(names)) |
| 90 | for _, name := range names { |
| 91 | entry := pluginEntryFromMCPSpec(name, specs[name]) |
| 92 | entry.Source = MCPSourceProjectMCPJSON |
| 93 | entries = append(entries, entry) |
| 94 | } |
| 95 | return entries |
| 96 | } |
| 97 | |
| 98 | // legacyConfigPath is the v0.x (TypeScript line) config file, ~/.reasonix/config.json. |
| 99 | func legacyConfigPath() string { |
| 100 | if IsolatedHomeDir() != "" { |
| 101 | return "" |
| 102 | } |
| 103 | home, err := os.UserHomeDir() |
| 104 | if err != nil { |
| 105 | return "" |
| 106 | } |
| 107 | return filepath.Join(home, ".reasonix", "config.json") |
| 108 | } |
| 109 | |
| 110 | // loadLegacyMCP reads the v0.x ~/.reasonix/config.json and returns its enabled |
| 111 | // MCP servers as PluginEntry values — both the canonical mcpServers map and the |
| 112 | // older `mcp` string list (mcpServers wins on a name collision, matching v0.x; |
| 113 | // servers listed in mcpDisabled are skipped) — so upgrading from v0.x keeps MCP |
| 114 | // servers working without rewriting them as [[plugins]]. Absent or malformed → |
| 115 | // nil: a stale legacy file must never block startup, and it is the |
| 116 | // lowest-priority source anyway (the v2 config and .mcp.json win on a name |
| 117 | // collision — see Load). |
| 118 | func loadLegacyMCP(path string) []PluginEntry { |
| 119 | if path == "" { |
| 120 | return nil |
| 121 | } |
| 122 | b, err := fileencoding.ReadFileUTF8(path) |
| 123 | if err != nil { |
| 124 | return nil |
| 125 | } |
| 126 | var doc struct { |
| 127 | MCP []string `json:"mcp"` |
| 128 | MCPServers map[string]mcpServerSpec `json:"mcpServers"` |
| 129 | MCPEnv map[string]map[string]string `json:"mcpEnv"` |
| 130 | MCPDisabled []string `json:"mcpDisabled"` |
| 131 | } |
| 132 | if err := json.Unmarshal(b, &doc); err != nil { |
| 133 | return nil |
| 134 | } |
| 135 | disabled := make(map[string]bool, len(doc.MCPDisabled)) |
| 136 | for _, n := range doc.MCPDisabled { |
| 137 | disabled[n] = true |
| 138 | } |
| 139 | entries := specsToEntries(doc.MCPServers, disabled) |
| 140 | have := make(map[string]bool, len(entries)) |
| 141 | for _, e := range entries { |
| 142 | have[e.Name] = true |
| 143 | } |
| 144 | for i, raw := range doc.MCP { |
| 145 | pe, ok := parseLegacyMCPSpec(raw) |
| 146 | if !ok || disabled[pe.Name] { |
| 147 | continue |
| 148 | } |
| 149 | if pe.Name == "" { |
| 150 | pe.Name = anonymousMCPName(i) |
| 151 | } else if pe.Command != "" { |
| 152 | pe.Env = doc.MCPEnv[pe.Name] |
| 153 | } |
| 154 | if have[pe.Name] { |
| 155 | continue |
| 156 | } |
| 157 | have[pe.Name] = true |
| 158 | pe, _ = NormalizePluginCommandLine(pe) |
| 159 | entries = append(entries, pe) |
| 160 | } |
| 161 | for i := range entries { |
| 162 | entries[i].Source = MCPSourceLegacyUser |
| 163 | } |
| 164 | return entries |
| 165 | } |
| 166 | |
| 167 | var legacyMCPSpecName = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_-]*)=(.*)$`) |
| 168 | |
| 169 | // parseLegacyMCPSpec parses one v0.x `--mcp`-format string: "name=cmd args...", |
| 170 | // "name=https://url" (SSE), or "name=streamable+https://url" (streamable HTTP); |
| 171 | // the name= prefix is optional. |
| 172 | func parseLegacyMCPSpec(raw string) (PluginEntry, bool) { |
| 173 | body := strings.TrimSpace(raw) |
| 174 | var name string |
| 175 | if m := legacyMCPSpecName.FindStringSubmatch(body); m != nil { |
| 176 | name, body = m[1], strings.TrimSpace(m[2]) |
| 177 | } |
| 178 | if body == "" { |
| 179 | return PluginEntry{}, false |
| 180 | } |
| 181 | lower := strings.ToLower(body) |
| 182 | if strings.HasPrefix(lower, "streamable+http://") || strings.HasPrefix(lower, "streamable+https://") { |
| 183 | return PluginEntry{Name: name, Type: "http", URL: body[len("streamable+"):]}, true |
| 184 | } |
| 185 | if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") { |
| 186 | return PluginEntry{Name: name, Type: "sse", URL: body}, true |
| 187 | } |
| 188 | parts, ok := splitPluginCommandLine(body) |
| 189 | if !ok || len(parts) == 0 { |
| 190 | return PluginEntry{}, false |
| 191 | } |
| 192 | if shouldSplitPluginCommand(body, parts[0]) { |
| 193 | return PluginEntry{Name: name, Command: parts[0], Args: parts[1:]}, true |
| 194 | } |
| 195 | return PluginEntry{Name: name, Command: body}, true |
| 196 | } |
| 197 | |
| 198 | // anonymousMCPName names a v0.x spec that carried no name= prefix (its tools |
| 199 | // registered unprefixed in v0.x; v1+ plugins require a name). |
| 200 | func anonymousMCPName(i int) string { |
| 201 | return fmt.Sprintf("mcp-%d", i+1) |
| 202 | } |
| 203 | |
| 204 | func pluginEntryFromMCPSpec(name string, s mcpServerSpec) PluginEntry { |
| 205 | e := PluginEntry{ |
| 206 | Name: name, |
| 207 | Type: s.Type, |
| 208 | Command: s.Command, |
| 209 | Args: s.Args, |
| 210 | Env: s.Env, |
| 211 | URL: s.URL, |
| 212 | Headers: s.Headers, |
| 213 | StartupTimeoutSeconds: s.StartupTimeoutSeconds, |
| 214 | CallTimeoutSeconds: s.CallTimeoutSeconds, |
| 215 | ToolTimeoutSeconds: s.ToolTimeoutSeconds, |
| 216 | AutoStart: s.AutoStart, |
| 217 | } |
| 218 | e, _ = NormalizePluginCommandLine(e) |
| 219 | return e |
| 220 | } |
| 221 | |
| 222 | // mergeMCPJSON appends servers from .mcp.json that the TOML config did not |
| 223 | // already declare. reasonix.toml's [[plugins]] win on a name collision: it is the |
| 224 | // Reasonix-specific, more explicit of the two, so it overrides the shared, |
| 225 | // checked-in .mcp.json rather than the other way round. |
| 226 | func (c *Config) mergeMCPJSON(entries []PluginEntry) { |
| 227 | index := make(map[string]int, len(c.Plugins)) |
| 228 | for i, p := range c.Plugins { |
| 229 | index[p.Name] = i |
| 230 | } |
| 231 | for _, e := range entries { |
| 232 | if i, exists := index[e.Name]; exists { |
| 233 | // Project configuration always wins over user-global configuration. |
| 234 | // Within one project, reasonix.toml remains more specific than the |
| 235 | // Claude-compatible .mcp.json file. |
| 236 | if e.Source == MCPSourceProjectMCPJSON && !c.Plugins[i].Source.ProjectScoped() { |
| 237 | c.Plugins[i] = e |
| 238 | } |
| 239 | continue |
| 240 | } |
| 241 | index[e.Name] = len(c.Plugins) |
| 242 | c.Plugins = append(c.Plugins, e) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | // UpsertMCPJSONPlugin writes one MCP server to a Claude-compatible .mcp.json |
| 247 | // file, preserving unrelated top-level fields and unknown per-server fields. |
| 248 | func UpsertMCPJSONPlugin(path string, entry PluginEntry) (bool, error) { |
| 249 | entry, _ = NormalizePluginCommandLine(entry) |
| 250 | if err := validatePlugin(entry); err != nil { |
| 251 | return false, err |
| 252 | } |
| 253 | root, servers, err := readMCPJSONRaw(path) |
| 254 | if err != nil { |
| 255 | return false, err |
| 256 | } |
| 257 | raw, existed := servers[entry.Name] |
| 258 | server := map[string]json.RawMessage{} |
| 259 | if existed && len(raw) > 0 { |
| 260 | if err := json.Unmarshal(raw, &server); err != nil || server == nil { |
| 261 | return false, fmt.Errorf("mcp config %s: server %q is not an object", path, entry.Name) |
| 262 | } |
| 263 | } |
| 264 | if err := applyPluginEntryToMCPJSONServer(server, entry); err != nil { |
| 265 | return false, fmt.Errorf("mcp config %s: server %q: %w", path, entry.Name, err) |
| 266 | } |
| 267 | updatedRaw, err := json.Marshal(server) |
| 268 | if err != nil { |
| 269 | return false, fmt.Errorf("mcp config %s: server %q: %w", path, entry.Name, err) |
| 270 | } |
| 271 | servers[entry.Name] = updatedRaw |
| 272 | if err := writeMCPJSONServers(path, root, servers); err != nil { |
| 273 | return false, err |
| 274 | } |
| 275 | return !existed, nil |
| 276 | } |
| 277 | |
| 278 | // RemoveMCPJSONPlugin removes one MCP server from a Claude-compatible .mcp.json |
| 279 | // file. Missing files or missing entries are reported as unchanged. |
| 280 | func RemoveMCPJSONPlugin(path, name string) (bool, error) { |
| 281 | root, servers, err := readMCPJSONRaw(path) |
| 282 | if os.IsNotExist(err) { |
| 283 | return false, nil |
| 284 | } |
| 285 | if err != nil { |
| 286 | return false, err |
| 287 | } |
| 288 | if _, ok := servers[name]; !ok { |
| 289 | return false, nil |
| 290 | } |
| 291 | delete(servers, name) |
| 292 | if err := writeMCPJSONServers(path, root, servers); err != nil { |
| 293 | return false, err |
| 294 | } |
| 295 | return true, nil |
| 296 | } |
| 297 | |
| 298 | func readMCPJSONRaw(path string) (map[string]json.RawMessage, map[string]json.RawMessage, error) { |
| 299 | root := map[string]json.RawMessage{} |
| 300 | servers := map[string]json.RawMessage{} |
| 301 | resolved, err := resolveConfigAccessPath(path, false) |
| 302 | if err != nil { |
| 303 | return nil, nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 304 | } |
| 305 | b, err := fileencoding.ReadFileUTF8(resolved) |
| 306 | if os.IsNotExist(err) { |
| 307 | return root, servers, nil |
| 308 | } |
| 309 | if err != nil { |
| 310 | return nil, nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 311 | } |
| 312 | if err := json.Unmarshal(b, &root); err != nil { |
| 313 | return nil, nil, fmt.Errorf("mcp config %s: %w", path, err) |
| 314 | } |
| 315 | raw, ok := root["mcpServers"] |
| 316 | if !ok || len(raw) == 0 || string(raw) == "null" { |
| 317 | return root, servers, nil |
| 318 | } |
| 319 | if err := json.Unmarshal(raw, &servers); err != nil || servers == nil { |
| 320 | return nil, nil, fmt.Errorf("mcp config %s: mcpServers must be an object", path) |
| 321 | } |
| 322 | return root, servers, nil |
| 323 | } |
| 324 | |
| 325 | func applyPluginEntryToMCPJSONServer(server map[string]json.RawMessage, entry PluginEntry) error { |
| 326 | transport := strings.ToLower(strings.TrimSpace(entry.Type)) |
| 327 | if transport == "" { |
| 328 | transport = "stdio" |
| 329 | } |
| 330 | if transport == "stdio" { |
| 331 | delete(server, "type") |
| 332 | setMCPJSONString(server, "command", strings.TrimSpace(entry.Command)) |
| 333 | setMCPJSONStringArray(server, "args", entry.Args) |
| 334 | setMCPJSONStringMap(server, "env", entry.Env) |
| 335 | delete(server, "url") |
| 336 | delete(server, "headers") |
| 337 | } else { |
| 338 | setMCPJSONString(server, "type", transport) |
| 339 | setMCPJSONString(server, "url", strings.TrimSpace(entry.URL)) |
| 340 | setMCPJSONStringMap(server, "headers", entry.Headers) |
| 341 | setMCPJSONStringMap(server, "env", entry.Env) |
| 342 | delete(server, "command") |
| 343 | delete(server, "args") |
| 344 | } |
| 345 | setMCPJSONInt(server, "startup_timeout_seconds", entry.StartupTimeoutSeconds) |
| 346 | setMCPJSONInt(server, "call_timeout_seconds", entry.CallTimeoutSeconds) |
| 347 | setMCPJSONIntMap(server, "tool_timeout_seconds", entry.ToolTimeoutSeconds) |
| 348 | // The removed per-tool reader list is accepted on load for compatibility but |
| 349 | // never persisted. Explicitly delete it when updating an existing shared |
| 350 | // .mcp.json entry so the obsolete setting disappears naturally. |
| 351 | delete(server, "trusted_read_only_tools") |
| 352 | delete(server, "default_tools_approval_mode") |
| 353 | delete(server, "approvals_reviewer") |
| 354 | setMCPJSONBool(server, "auto_start", entry.AutoStart) |
| 355 | if err := removeMCPJSONApprovalModes(server); err != nil { |
| 356 | return err |
| 357 | } |
| 358 | return nil |
| 359 | } |
| 360 | |
| 361 | func removeMCPJSONApprovalModes(server map[string]json.RawMessage) error { |
| 362 | const key = "tools" |
| 363 | tools := map[string]json.RawMessage{} |
| 364 | if raw, ok := server[key]; ok && len(raw) > 0 && strings.TrimSpace(string(raw)) != "null" { |
| 365 | if err := json.Unmarshal(raw, &tools); err != nil || tools == nil { |
| 366 | return fmt.Errorf("%s must be an object", key) |
| 367 | } |
| 368 | } |
| 369 | |
| 370 | // Remove Reasonix's retired approval_mode while preserving tool fields owned |
| 371 | // by other MCP clients. |
| 372 | for name, raw := range tools { |
| 373 | var fields map[string]json.RawMessage |
| 374 | if err := json.Unmarshal(raw, &fields); err != nil || fields == nil { |
| 375 | continue |
| 376 | } |
| 377 | delete(fields, "approval_mode") |
| 378 | if len(fields) == 0 { |
| 379 | delete(tools, name) |
| 380 | continue |
| 381 | } |
| 382 | updated, err := json.Marshal(fields) |
| 383 | if err != nil { |
| 384 | return fmt.Errorf("%s[%q]: %w", key, name, err) |
| 385 | } |
| 386 | tools[name] = updated |
| 387 | } |
| 388 | |
| 389 | if len(tools) == 0 { |
| 390 | delete(server, key) |
| 391 | return nil |
| 392 | } |
| 393 | raw, err := json.Marshal(tools) |
| 394 | if err != nil { |
| 395 | return fmt.Errorf("%s: %w", key, err) |
| 396 | } |
| 397 | server[key] = raw |
| 398 | return nil |
| 399 | } |
| 400 | |
| 401 | func writeMCPJSONServers(path string, root map[string]json.RawMessage, servers map[string]json.RawMessage) error { |
| 402 | serversRaw, err := json.Marshal(servers) |
| 403 | if err != nil { |
| 404 | return fmt.Errorf("mcp config %s: %w", path, err) |
| 405 | } |
| 406 | root["mcpServers"] = serversRaw |
| 407 | return writeMCPJSON(path, root) |
| 408 | } |
| 409 | |
| 410 | func clearMCPJSONAuthentication(path, name string) (PluginEntry, bool, error) { |
| 411 | root, servers, err := readMCPJSONRaw(path) |
| 412 | if err != nil { |
| 413 | return PluginEntry{}, false, err |
| 414 | } |
| 415 | raw, ok := servers[name] |
| 416 | if !ok { |
| 417 | return PluginEntry{}, false, fmt.Errorf("clear plugin authentication: no plugin %q", name) |
| 418 | } |
| 419 | var spec mcpServerSpec |
| 420 | if err := json.Unmarshal(raw, &spec); err != nil { |
| 421 | return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err) |
| 422 | } |
| 423 | cleanHeaders, cleanEnv, cleanURL, changed := mcpdiag.ClearAuthConfig(spec.Headers, spec.Env, spec.URL) |
| 424 | if !changed { |
| 425 | return pluginEntryFromMCPSpec(name, spec), false, nil |
| 426 | } |
| 427 | spec.Headers = cleanHeaders |
| 428 | spec.Env = cleanEnv |
| 429 | spec.URL = cleanURL |
| 430 | |
| 431 | var server map[string]json.RawMessage |
| 432 | if err := json.Unmarshal(raw, &server); err != nil || server == nil { |
| 433 | return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q is not an object", path, name) |
| 434 | } |
| 435 | setMCPJSONStringMap(server, "headers", cleanHeaders) |
| 436 | setMCPJSONStringMap(server, "env", cleanEnv) |
| 437 | setMCPJSONString(server, "url", cleanURL) |
| 438 | updatedRaw, err := json.Marshal(server) |
| 439 | if err != nil { |
| 440 | return PluginEntry{}, false, fmt.Errorf("mcp config %s: server %q: %w", path, name, err) |
| 441 | } |
| 442 | servers[name] = updatedRaw |
| 443 | serversRaw, err := json.Marshal(servers) |
| 444 | if err != nil { |
| 445 | return PluginEntry{}, false, fmt.Errorf("mcp config %s: %w", path, err) |
| 446 | } |
| 447 | root["mcpServers"] = serversRaw |
| 448 | if err := writeMCPJSON(path, root); err != nil { |
| 449 | return PluginEntry{}, false, err |
| 450 | } |
| 451 | return pluginEntryFromMCPSpec(name, spec), true, nil |
| 452 | } |
| 453 | |
| 454 | func setMCPJSONStringMap(server map[string]json.RawMessage, key string, values map[string]string) { |
| 455 | if len(values) == 0 { |
| 456 | delete(server, key) |
| 457 | return |
| 458 | } |
| 459 | raw, err := json.Marshal(values) |
| 460 | if err != nil { |
| 461 | delete(server, key) |
| 462 | return |
| 463 | } |
| 464 | server[key] = raw |
| 465 | } |
| 466 | |
| 467 | func setMCPJSONString(server map[string]json.RawMessage, key, value string) { |
| 468 | if value == "" { |
| 469 | delete(server, key) |
| 470 | return |
| 471 | } |
| 472 | raw, err := json.Marshal(value) |
| 473 | if err != nil { |
| 474 | delete(server, key) |
| 475 | return |
| 476 | } |
| 477 | server[key] = raw |
| 478 | } |
| 479 | |
| 480 | func setMCPJSONStringArray(server map[string]json.RawMessage, key string, values []string) { |
| 481 | if len(values) == 0 { |
| 482 | delete(server, key) |
| 483 | return |
| 484 | } |
| 485 | raw, err := json.Marshal(values) |
| 486 | if err != nil { |
| 487 | delete(server, key) |
| 488 | return |
| 489 | } |
| 490 | server[key] = raw |
| 491 | } |
| 492 | |
| 493 | func setMCPJSONInt(server map[string]json.RawMessage, key string, value int) { |
| 494 | if value <= 0 { |
| 495 | delete(server, key) |
| 496 | return |
| 497 | } |
| 498 | raw, err := json.Marshal(value) |
| 499 | if err != nil { |
| 500 | delete(server, key) |
| 501 | return |
| 502 | } |
| 503 | server[key] = raw |
| 504 | } |
| 505 | |
| 506 | func setMCPJSONIntMap(server map[string]json.RawMessage, key string, values map[string]int) { |
| 507 | clean := make(map[string]int, len(values)) |
| 508 | for k, v := range values { |
| 509 | if strings.TrimSpace(k) == "" || v <= 0 { |
| 510 | continue |
| 511 | } |
| 512 | clean[k] = v |
| 513 | } |
| 514 | if len(clean) == 0 { |
| 515 | delete(server, key) |
| 516 | return |
| 517 | } |
| 518 | raw, err := json.Marshal(clean) |
| 519 | if err != nil { |
| 520 | delete(server, key) |
| 521 | return |
| 522 | } |
| 523 | server[key] = raw |
| 524 | } |
| 525 | |
| 526 | func setMCPJSONBool(server map[string]json.RawMessage, key string, value *bool) { |
| 527 | if value == nil { |
| 528 | delete(server, key) |
| 529 | return |
| 530 | } |
| 531 | raw, err := json.Marshal(*value) |
| 532 | if err != nil { |
| 533 | delete(server, key) |
| 534 | return |
| 535 | } |
| 536 | server[key] = raw |
| 537 | } |
| 538 | |
| 539 | func writeMCPJSON(path string, root map[string]json.RawMessage) error { |
| 540 | out, err := json.MarshalIndent(root, "", " ") |
| 541 | if err != nil { |
| 542 | return fmt.Errorf("mcp config %s: %w", path, err) |
| 543 | } |
| 544 | out = append(out, '\n') |
| 545 | resolved, err := resolveConfigAccessPath(path, false) |
| 546 | if err != nil { |
| 547 | return fmt.Errorf("mcp config %s: %w", path, err) |
| 548 | } |
| 549 | dir := filepath.Dir(resolved) |
| 550 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 551 | return fmt.Errorf("mcp config %s: create dir: %w", path, err) |
| 552 | } |
| 553 | tmp, err := os.CreateTemp(dir, ".mcp.*.json.tmp") |
| 554 | if err != nil { |
| 555 | return fmt.Errorf("mcp config %s: create temp: %w", path, err) |
| 556 | } |
| 557 | tmpPath := tmp.Name() |
| 558 | if _, err := tmp.Write(out); err != nil { |
| 559 | tmp.Close() |
| 560 | os.Remove(tmpPath) |
| 561 | return fmt.Errorf("mcp config %s: write: %w", path, err) |
| 562 | } |
| 563 | if err := tmp.Close(); err != nil { |
| 564 | os.Remove(tmpPath) |
| 565 | return fmt.Errorf("mcp config %s: close temp: %w", path, err) |
| 566 | } |
| 567 | if err := fileutil.ReplaceFile(tmpPath, resolved); err != nil { |
| 568 | os.Remove(tmpPath) |
| 569 | return err |
| 570 | } |
| 571 | return nil |
| 572 | } |
| 573 |