mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* fix(mcp): skip an unreachable MCP server at startup instead of aborting RegisterTools connected to every server eagerly and failed the whole batch if any server could not be reached or list its tools — and the caller aborts startup with exit 1 on that error. So a single unreachable MCP server prevented zero from launching at all and disabled every other (working) server. Make registration best-effort across servers but still atomic within a server: each server's tools are validated as a unit before anything commits, and a server that cannot be connected, returns a nameless tool, or conflicts is SKIPPED — recorded on Runtime.Skipped(), not fatal. The caller surfaces each skip as a warning and keeps launching. The "no dead tools" and no-partial-tool-set guarantees are preserved. * feat(mcp): ship keyless Firecrawl as a default web search/scrape server Seed an enabled-by-default Firecrawl MCP server (keyless: 1,000 free credits/month, no API key, no setup) into ResolveMCP before user/project config is merged, so web search and scraping work out of the box for open-source users. A user can override any field (self-hosted URL, bring-your-own-key header) or disable it entirely; `zero mcp disable firecrawl` writes the override even though the default lives in code rather than the config file. The built-in web_fetch tool stays the always-local floor. Firecrawl is AGPL-3.0, but zero only calls it over the network, so the license never reaches zero's own code. README documents disable / self-host / bring-your-own-key / the privacy note that keyless routes through firecrawl.dev. Depends on the resilient-startup change: on an offline machine the default is skipped with a warning, not fatal. * perf(mcp): connect MCP servers concurrently with a per-server startup timeout RegisterTools connected servers serially with a 30s handshake timeout, so startup waited for the SUM of all servers and one slow/unreachable server (e.g. a hosted endpoint blocked by the local network) blocked every other server AND the first model response — often 10-30s of dead time per launch. Connect all servers CONCURRENTLY, each bounded by a per-server ConnectTimeout (default 8s). A server that doesn't connect+list in time is abandoned (its context cancelled to tear down the half-open connection/subprocess) and skipped, so total startup cost is now the slowest REACHABLE server, not the sum. The concurrent phase does only connect+list (no shared state); validation, conflict detection, and registration stay in a deterministic serial phase, so the outcome is identical regardless of completion order. A kept server's context is released at Close — a stdio subprocess is tied to its context, so it must outlive the connect. Pairs with the resilient skip-on-failure behavior already in this branch.
938 lines
27 KiB
Go
938 lines
27 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/redaction"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
type mcpAddOptions struct {
|
|
json bool
|
|
serverName string
|
|
server config.MCPServerConfig
|
|
}
|
|
|
|
type mcpWritableConfig struct {
|
|
file config.FileConfig
|
|
raw map[string]json.RawMessage
|
|
mcpRaw map[string]json.RawMessage
|
|
serverRaw map[string]json.RawMessage
|
|
}
|
|
|
|
func runMCPAdd(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseMCPAddArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPAddHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
configPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
|
}
|
|
cfg, err := readMCPWritableConfig(configPath)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if cfg.file.MCP.Servers == nil {
|
|
cfg.file.MCP.Servers = map[string]config.MCPServerConfig{}
|
|
}
|
|
updated, err := cfg.upsertServer(options.serverName, options.server)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if err := writeMCPWritableConfig(configPath, cfg); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
|
|
if options.json {
|
|
payload := struct {
|
|
ServerName string `json:"serverName"`
|
|
Updated bool `json:"updated"`
|
|
ConfigPath string `json:"configPath"`
|
|
Server config.MCPServerConfig `json:"server"`
|
|
}{
|
|
ServerName: options.serverName,
|
|
Updated: updated,
|
|
ConfigPath: configPath,
|
|
Server: redactMCPServerConfigs(map[string]config.MCPServerConfig{options.serverName: options.server})[options.serverName],
|
|
}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
action := "Added"
|
|
if updated {
|
|
action = "Updated"
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "%s MCP server %s in %s.\n", action, options.serverName, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runMCPRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, positional, help, err := parseMCPConfigPositionalCommand(args, "remove")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPRemoveHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) != 1 {
|
|
return writeExecUsageError(stderr, "usage: zero mcp remove <server> [--json]")
|
|
}
|
|
serverName := positional[0]
|
|
if err := mcp.ValidateServerName(serverName); err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
configPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
|
}
|
|
cfg, err := readMCPWritableConfig(configPath)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
removed, err := cfg.removeServer(serverName)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if removed {
|
|
if err := writeMCPWritableConfig(configPath, cfg); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
}
|
|
|
|
if options.json {
|
|
payload := struct {
|
|
ServerName string `json:"serverName"`
|
|
Removed bool `json:"removed"`
|
|
ConfigPath string `json:"configPath"`
|
|
}{ServerName: serverName, Removed: removed, ConfigPath: configPath}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if removed {
|
|
if _, err := fmt.Fprintf(stdout, "Removed MCP server %s from %s.\n", serverName, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintf(stdout, "No MCP server named %s is configured in %s.\n", serverName, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runMCPToggle(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, disabled bool) int {
|
|
commandName := "enable"
|
|
if disabled {
|
|
commandName = "disable"
|
|
}
|
|
options, positional, help, err := parseMCPConfigPositionalCommand(args, commandName)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPToggleHelp(stdout, commandName); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) != 1 {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("usage: zero mcp %s <server> [--json]", commandName))
|
|
}
|
|
serverName := positional[0]
|
|
if err := mcp.ValidateServerName(serverName); err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
configPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve user config: "+err.Error(), exitCrash)
|
|
}
|
|
cfg, err := readMCPWritableConfig(configPath)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
changed, found, err := cfg.setServerDisabled(serverName, disabled)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
if !found {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("MCP server %s is not configured in user config", serverName))
|
|
}
|
|
if changed {
|
|
if err := writeMCPWritableConfig(configPath, cfg); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
}
|
|
|
|
if options.json {
|
|
payload := struct {
|
|
ServerName string `json:"serverName"`
|
|
Disabled bool `json:"disabled"`
|
|
Changed bool `json:"changed"`
|
|
ConfigPath string `json:"configPath"`
|
|
}{ServerName: serverName, Disabled: disabled, Changed: changed, ConfigPath: configPath}
|
|
if err := writePrettyJSON(stdout, payload); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
state := "enabled"
|
|
if disabled {
|
|
state = "disabled"
|
|
}
|
|
if changed {
|
|
if _, err := fmt.Fprintf(stdout, "MCP server %s is now %s in %s.\n", serverName, state, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else if _, err := fmt.Fprintf(stdout, "MCP server %s was already %s in %s.\n", serverName, state, configPath); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runMCPCheck(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
options, positional, help, err := parseMCPConfigPositionalCommand(args, "check")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeMCPCheckHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if len(positional) != 1 {
|
|
return writeExecUsageError(stderr, "usage: zero mcp check <server> [--json]")
|
|
}
|
|
serverName := positional[0]
|
|
if err := mcp.ValidateServerName(serverName); err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
|
|
cwd, err := deps.getwd()
|
|
if err != nil {
|
|
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), exitCrash)
|
|
}
|
|
cfg, err := deps.resolveMCPConfig(cwd)
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
raw, ok := cfg.Servers[serverName]
|
|
if !ok {
|
|
return writeAppError(stderr, fmt.Sprintf("MCP server %q is not configured", serverName), exitCrash)
|
|
}
|
|
if raw.Disabled {
|
|
return writeAppError(stderr, fmt.Sprintf("MCP server %q is disabled", serverName), exitCrash)
|
|
}
|
|
scoped := config.MCPConfig{Servers: map[string]config.MCPServerConfig{serverName: raw}}
|
|
if _, err := mcp.NormalizeConfig(scoped); err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
|
|
store, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
registry := tools.NewRegistry()
|
|
mcpRuntime, err := deps.registerMCPTools(ctx, registry, scoped, mcp.RegisterOptions{
|
|
PermissionStore: store,
|
|
Autonomy: mcp.AutonomyLow,
|
|
})
|
|
if err != nil {
|
|
closeMCPRuntime(stderr, mcpRuntime)
|
|
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
|
|
}
|
|
defer closeMCPRuntime(stderr, mcpRuntime)
|
|
|
|
items := mcpToolList(registry)
|
|
if options.json {
|
|
payload := struct {
|
|
ServerName string `json:"serverName"`
|
|
Status string `json:"status"`
|
|
ToolCount int `json:"toolCount"`
|
|
Tools []mcpToolListItem `json:"tools"`
|
|
}{ServerName: serverName, Status: "ok", ToolCount: len(items), Tools: items}
|
|
if err := writePrettyJSON(stdout, redaction.RedactValue(payload, redaction.Options{})); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintf(stdout, "MCP server %s is reachable. %d tool(s) available.\n", serverName, len(items)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func parseMCPAddArgs(args []string) (mcpAddOptions, bool, error) {
|
|
options := mcpAddOptions{}
|
|
command := []string{}
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case options.serverName == "":
|
|
if strings.HasPrefix(arg, "-") {
|
|
return options, false, execUsageError{"usage: zero mcp add <server> [flags] -- <command> [args...]"}
|
|
}
|
|
options.serverName = arg
|
|
case arg == "--":
|
|
command = append(command, args[i+1:]...)
|
|
i = len(args)
|
|
case arg == "--json":
|
|
options.json = true
|
|
case arg == "--disabled":
|
|
options.server.Disabled = true
|
|
case arg == "--type":
|
|
value, err := requiredNextMCPFlagValue(args, &i, "--type")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.Type = value
|
|
case strings.HasPrefix(arg, "--type="):
|
|
value, err := requiredInlineFlagValue(arg, "--type")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.Type = value
|
|
case arg == "--url":
|
|
value, err := requiredNextMCPFlagValue(args, &i, "--url")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.URL = value
|
|
case strings.HasPrefix(arg, "--url="):
|
|
value, err := requiredInlineFlagValue(arg, "--url")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.URL = value
|
|
case arg == "--env":
|
|
value, err := requiredNextMCPFlagValue(args, &i, "--env")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addMCPStringMapValue(&options.server.Env, value, "--env"); err != nil {
|
|
return options, false, err
|
|
}
|
|
case strings.HasPrefix(arg, "--env="):
|
|
value, err := requiredInlineFlagValue(arg, "--env")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addMCPStringMapValue(&options.server.Env, value, "--env"); err != nil {
|
|
return options, false, err
|
|
}
|
|
case arg == "--header":
|
|
value, err := requiredNextMCPFlagValue(args, &i, "--header")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addMCPStringMapValue(&options.server.Headers, value, "--header"); err != nil {
|
|
return options, false, err
|
|
}
|
|
case strings.HasPrefix(arg, "--header="):
|
|
value, err := requiredInlineFlagValue(arg, "--header")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
if err := addMCPStringMapValue(&options.server.Headers, value, "--header"); err != nil {
|
|
return options, false, err
|
|
}
|
|
case arg == "--auth":
|
|
value, err := requiredNextMCPFlagValue(args, &i, "--auth")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.Auth = value
|
|
case strings.HasPrefix(arg, "--auth="):
|
|
value, err := requiredInlineFlagValue(arg, "--auth")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.server.Auth = value
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown mcp add flag %q", arg)}
|
|
default:
|
|
command = append(command, args[i:]...)
|
|
i = len(args)
|
|
}
|
|
}
|
|
|
|
options.serverName = strings.TrimSpace(options.serverName)
|
|
if options.serverName == "" {
|
|
return options, false, execUsageError{"usage: zero mcp add <server> [flags] -- <command> [args...]"}
|
|
}
|
|
if err := mcp.ValidateServerName(options.serverName); err != nil {
|
|
return options, false, err
|
|
}
|
|
|
|
options.server.Type = strings.ToLower(strings.TrimSpace(options.server.Type))
|
|
options.server.Auth = strings.ToLower(strings.TrimSpace(options.server.Auth))
|
|
if options.server.Type == "" {
|
|
if strings.TrimSpace(options.server.URL) != "" {
|
|
options.server.Type = string(mcp.ServerTypeHTTP)
|
|
} else {
|
|
options.server.Type = string(mcp.ServerTypeStdio)
|
|
}
|
|
}
|
|
switch mcp.ServerType(options.server.Type) {
|
|
case mcp.ServerTypeStdio:
|
|
if len(options.server.Headers) > 0 {
|
|
return options, false, execUsageError{"headers are only supported for http or sse transports"}
|
|
}
|
|
if len(command) == 0 {
|
|
return options, false, execUsageError{"usage: zero mcp add <server> [flags] -- <command> [args...]"}
|
|
}
|
|
options.server.Command = command[0]
|
|
options.server.Args = append([]string{}, command[1:]...)
|
|
case mcp.ServerTypeHTTP, mcp.ServerTypeSSE:
|
|
if len(options.server.Env) > 0 {
|
|
return options, false, execUsageError{"env is only supported for stdio transport"}
|
|
}
|
|
if strings.TrimSpace(options.server.URL) == "" {
|
|
return options, false, execUsageError{fmt.Sprintf("zero mcp add --type %s requires --url", options.server.Type)}
|
|
}
|
|
if len(command) > 0 {
|
|
return options, false, execUsageError{fmt.Sprintf("zero mcp add --type %s does not accept a command", options.server.Type)}
|
|
}
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unsupported MCP server type %q", options.server.Type)}
|
|
}
|
|
|
|
if options.server.Auth != "" && options.server.Auth != mcp.ServerAuthOAuth {
|
|
return options, false, execUsageError{fmt.Sprintf("MCP server %s has unsupported auth %q", options.serverName, options.server.Auth)}
|
|
}
|
|
if !options.server.Disabled {
|
|
if _, err := mcp.NormalizeConfig(config.MCPConfig{Servers: map[string]config.MCPServerConfig{options.serverName: options.server}}); err != nil {
|
|
return options, false, err
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func requiredNextMCPFlagValue(args []string, index *int, flag string) (string, error) {
|
|
if *index+1 >= len(args) {
|
|
return "", execUsageError{fmt.Sprintf("%s requires a value", flag)}
|
|
}
|
|
*index = *index + 1
|
|
value := args[*index]
|
|
if strings.TrimSpace(value) == "" {
|
|
return "", execUsageError{fmt.Sprintf("%s requires a value", flag)}
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func addMCPStringMapValue(target *map[string]string, raw string, flag string) error {
|
|
key, value, ok := strings.Cut(raw, "=")
|
|
if !ok && flag == "--header" {
|
|
key, value, ok = strings.Cut(raw, ":")
|
|
value = strings.TrimLeft(value, " \t")
|
|
}
|
|
key = strings.TrimSpace(key)
|
|
if !ok || key == "" {
|
|
if flag == "--header" {
|
|
return execUsageError{fmt.Sprintf("%s expects KEY=VALUE or \"Key: Value\"", flag)}
|
|
}
|
|
return execUsageError{fmt.Sprintf("%s expects KEY=VALUE", flag)}
|
|
}
|
|
if *target == nil {
|
|
*target = map[string]string{}
|
|
}
|
|
(*target)[key] = value
|
|
return nil
|
|
}
|
|
|
|
func parseMCPConfigPositionalCommand(args []string, command string) (mcpCommandOptions, []string, bool, error) {
|
|
options := mcpCommandOptions{}
|
|
positional := []string{}
|
|
for _, arg := range args {
|
|
switch arg {
|
|
case "-h", "--help", "help":
|
|
return options, positional, true, nil
|
|
case "--json":
|
|
options.json = true
|
|
default:
|
|
if strings.HasPrefix(arg, "-") {
|
|
return options, positional, false, execUsageError{fmt.Sprintf("unknown mcp %s flag %q", command, arg)}
|
|
}
|
|
positional = append(positional, arg)
|
|
}
|
|
}
|
|
return options, positional, false, nil
|
|
}
|
|
|
|
func readMCPWritableConfig(path string) (mcpWritableConfig, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return mcpWritableConfig{}, fmt.Errorf("config path is required")
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
cfg := mcpWritableConfig{}
|
|
cfg.ensureRaw()
|
|
return cfg, nil
|
|
}
|
|
return mcpWritableConfig{}, fmt.Errorf("read config %s: %w", path, err)
|
|
}
|
|
cfg := mcpWritableConfig{}
|
|
if err := json.Unmarshal(data, &cfg.file); err != nil {
|
|
return mcpWritableConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
if err := json.Unmarshal(data, &cfg.raw); err != nil {
|
|
return mcpWritableConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
cfg.ensureRaw()
|
|
if data, ok := cfg.raw["mcp"]; ok && len(data) > 0 && string(data) != "null" {
|
|
if err := json.Unmarshal(data, &cfg.mcpRaw); err != nil {
|
|
return mcpWritableConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
}
|
|
cfg.ensureRaw()
|
|
if data, ok := cfg.mcpRaw["servers"]; ok && len(data) > 0 && string(data) != "null" {
|
|
if err := json.Unmarshal(data, &cfg.serverRaw); err != nil {
|
|
return mcpWritableConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
|
|
}
|
|
}
|
|
cfg.ensureRaw()
|
|
return cfg, nil
|
|
}
|
|
|
|
func writeMCPWritableConfig(path string, cfg mcpWritableConfig) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return fmt.Errorf("config path is required")
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if dir != "." && dir != "" {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create config directory %s: %w", dir, err)
|
|
}
|
|
}
|
|
data, err := cfg.marshalJSON()
|
|
if err != nil {
|
|
return fmt.Errorf("encode config JSON: %w", err)
|
|
}
|
|
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer os.Remove(tmpPath)
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("secure config permissions %s: %w", path, err)
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
if err := replaceMCPWritableConfigFile(tmpPath, path); err != nil {
|
|
return fmt.Errorf("write config %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) ensureRaw() {
|
|
if cfg.raw == nil {
|
|
cfg.raw = map[string]json.RawMessage{}
|
|
}
|
|
if cfg.mcpRaw == nil {
|
|
cfg.mcpRaw = map[string]json.RawMessage{}
|
|
}
|
|
if cfg.serverRaw == nil {
|
|
cfg.serverRaw = map[string]json.RawMessage{}
|
|
}
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) upsertServer(name string, server config.MCPServerConfig) (bool, error) {
|
|
cfg.ensureRaw()
|
|
if cfg.file.MCP.Servers == nil {
|
|
cfg.file.MCP.Servers = map[string]config.MCPServerConfig{}
|
|
}
|
|
existingRaw, updated := cfg.serverRaw[name]
|
|
existingServer := cfg.file.MCP.Servers[name]
|
|
if !updated {
|
|
legacyRaw, legacyFound, err := cfg.legacyServerRaw(name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if legacyFound {
|
|
existingRaw = legacyRaw
|
|
updated = true
|
|
if len(legacyRaw) > 0 && string(legacyRaw) != "null" {
|
|
_ = json.Unmarshal(legacyRaw, &existingServer)
|
|
}
|
|
}
|
|
}
|
|
mergedServer := overlayMCPServer(existingServer, server)
|
|
cfg.file.MCP.Servers[name] = mergedServer
|
|
data, err := mergeMCPServerRaw(existingRaw, mergedServer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
cfg.serverRaw[name] = data
|
|
if _, err := cfg.removeLegacyServer(name); err != nil {
|
|
return false, err
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func overlayMCPServer(existing config.MCPServerConfig, next config.MCPServerConfig) config.MCPServerConfig {
|
|
existing.Type = next.Type
|
|
existing.Command = next.Command
|
|
existing.Args = append([]string{}, next.Args...)
|
|
existing.Env = cloneStringMap(next.Env)
|
|
existing.URL = next.URL
|
|
existing.Headers = cloneStringMap(next.Headers)
|
|
if strings.TrimSpace(next.Auth) != "" {
|
|
existing.Auth = next.Auth
|
|
}
|
|
existing.Disabled = next.Disabled
|
|
return existing
|
|
}
|
|
|
|
func cloneStringMap(values map[string]string) map[string]string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
cloned[key] = value
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func mergeMCPServerRaw(existing json.RawMessage, server config.MCPServerConfig) (json.RawMessage, error) {
|
|
typed, err := json.Marshal(server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(existing) == 0 || string(existing) == "null" {
|
|
return typed, nil
|
|
}
|
|
var merged map[string]json.RawMessage
|
|
if err := json.Unmarshal(existing, &merged); err != nil {
|
|
return nil, err
|
|
}
|
|
if merged == nil {
|
|
merged = map[string]json.RawMessage{}
|
|
}
|
|
for _, key := range []string{"type", "command", "args", "env", "url", "headers", "auth", "disabled"} {
|
|
delete(merged, key)
|
|
}
|
|
var typedRaw map[string]json.RawMessage
|
|
if err := json.Unmarshal(typed, &typedRaw); err != nil {
|
|
return nil, err
|
|
}
|
|
_, preserveRawOAuth := merged["oauth"]
|
|
for key, value := range typedRaw {
|
|
if key == "oauth" && preserveRawOAuth {
|
|
continue
|
|
}
|
|
merged[key] = value
|
|
}
|
|
return json.Marshal(merged)
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) removeServer(name string) (bool, error) {
|
|
cfg.ensureRaw()
|
|
removed := false
|
|
if cfg.file.MCP.Servers != nil {
|
|
if _, ok := cfg.file.MCP.Servers[name]; ok {
|
|
delete(cfg.file.MCP.Servers, name)
|
|
removed = true
|
|
}
|
|
}
|
|
if _, ok := cfg.serverRaw[name]; ok {
|
|
delete(cfg.serverRaw, name)
|
|
removed = true
|
|
}
|
|
removedLegacy, err := cfg.removeLegacyServer(name)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return removed || removedLegacy, nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) setServerDisabled(name string, disabled bool) (bool, bool, error) {
|
|
cfg.ensureRaw()
|
|
raw, found := cfg.serverRaw[name]
|
|
if !found {
|
|
legacyRaw, legacyFound, err := cfg.takeLegacyServer(name)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
switch {
|
|
case legacyFound:
|
|
raw = legacyRaw
|
|
found = true
|
|
case config.IsDefaultMCPServer(name):
|
|
// A built-in default server isn't written to the file until the user
|
|
// overrides it. Treat it as present with an empty base so disabling it
|
|
// writes a minimal {"disabled":true} entry that merges over the default —
|
|
// letting `zero mcp disable <default>` work even though it lives in code.
|
|
raw = nil
|
|
found = true
|
|
default:
|
|
return false, false, nil
|
|
}
|
|
}
|
|
var server map[string]json.RawMessage
|
|
if len(raw) > 0 && string(raw) != "null" {
|
|
if err := json.Unmarshal(raw, &server); err != nil {
|
|
return false, false, err
|
|
}
|
|
}
|
|
if server == nil {
|
|
server = map[string]json.RawMessage{}
|
|
}
|
|
|
|
current := false
|
|
if rawDisabled, ok := server["disabled"]; ok && len(rawDisabled) > 0 && string(rawDisabled) != "null" {
|
|
if err := json.Unmarshal(rawDisabled, ¤t); err != nil {
|
|
return false, false, err
|
|
}
|
|
}
|
|
changed := current != disabled
|
|
if disabled {
|
|
data, err := json.Marshal(true)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
server["disabled"] = data
|
|
} else {
|
|
delete(server, "disabled")
|
|
}
|
|
data, err := json.Marshal(server)
|
|
if err != nil {
|
|
return false, false, err
|
|
}
|
|
cfg.serverRaw[name] = data
|
|
|
|
if cfg.file.MCP.Servers == nil {
|
|
cfg.file.MCP.Servers = map[string]config.MCPServerConfig{}
|
|
}
|
|
typed := cfg.file.MCP.Servers[name]
|
|
if len(raw) > 0 && string(raw) != "null" {
|
|
var decoded config.MCPServerConfig
|
|
if err := json.Unmarshal(raw, &decoded); err == nil {
|
|
typed = decoded
|
|
}
|
|
}
|
|
typed.Disabled = disabled
|
|
cfg.file.MCP.Servers[name] = typed
|
|
return changed, true, nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) takeLegacyServer(name string) (json.RawMessage, bool, error) {
|
|
cfg.ensureRaw()
|
|
for _, key := range []string{"mcpServers", "mcp_servers"} {
|
|
data, ok := cfg.raw[key]
|
|
if !ok || len(data) == 0 || string(data) == "null" {
|
|
continue
|
|
}
|
|
var servers map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &servers); err != nil {
|
|
return nil, false, err
|
|
}
|
|
raw, ok := servers[name]
|
|
if !ok {
|
|
continue
|
|
}
|
|
delete(servers, name)
|
|
if len(servers) == 0 {
|
|
delete(cfg.raw, key)
|
|
} else {
|
|
next, err := json.Marshal(servers)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
cfg.raw[key] = next
|
|
}
|
|
return raw, true, nil
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) legacyServerRaw(name string) (json.RawMessage, bool, error) {
|
|
cfg.ensureRaw()
|
|
for _, key := range []string{"mcpServers", "mcp_servers"} {
|
|
data, ok := cfg.raw[key]
|
|
if !ok || len(data) == 0 || string(data) == "null" {
|
|
continue
|
|
}
|
|
var servers map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &servers); err != nil {
|
|
return nil, false, err
|
|
}
|
|
raw, ok := servers[name]
|
|
if ok {
|
|
return raw, true, nil
|
|
}
|
|
}
|
|
return nil, false, nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) removeLegacyServer(name string) (bool, error) {
|
|
cfg.ensureRaw()
|
|
removed := false
|
|
for _, key := range []string{"mcpServers", "mcp_servers"} {
|
|
data, ok := cfg.raw[key]
|
|
if !ok || len(data) == 0 || string(data) == "null" {
|
|
continue
|
|
}
|
|
var servers map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &servers); err != nil {
|
|
return false, err
|
|
}
|
|
if _, ok := servers[name]; !ok {
|
|
continue
|
|
}
|
|
delete(servers, name)
|
|
removed = true
|
|
if len(servers) == 0 {
|
|
delete(cfg.raw, key)
|
|
continue
|
|
}
|
|
next, err := json.Marshal(servers)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
cfg.raw[key] = next
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
func (cfg *mcpWritableConfig) marshalJSON() ([]byte, error) {
|
|
cfg.ensureRaw()
|
|
if len(cfg.serverRaw) > 0 {
|
|
data, err := json.Marshal(cfg.serverRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.mcpRaw["servers"] = data
|
|
} else {
|
|
delete(cfg.mcpRaw, "servers")
|
|
}
|
|
if len(cfg.mcpRaw) > 0 {
|
|
data, err := json.Marshal(cfg.mcpRaw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.raw["mcp"] = data
|
|
} else {
|
|
delete(cfg.raw, "mcp")
|
|
}
|
|
data, err := json.MarshalIndent(cfg.raw, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
func replaceMCPWritableConfigFile(tmpPath string, path string) error {
|
|
if err := os.Rename(tmpPath, path); err == nil || runtime.GOOS != "windows" {
|
|
return err
|
|
}
|
|
backupPath := tmpPath + ".bak"
|
|
if err := os.Rename(path, backupPath); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
if _, statErr := os.Stat(backupPath); statErr == nil {
|
|
if restoreErr := os.Rename(backupPath, path); restoreErr != nil {
|
|
return fmt.Errorf("%w; failed to restore original config: %v", err, restoreErr)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
_ = os.Remove(backupPath)
|
|
return nil
|
|
}
|
|
|
|
func writeMCPAddHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp add <server> [flags] -- <command> [args...]
|
|
zero mcp add <server> --url <url> [flags]
|
|
|
|
Flags:
|
|
--auth <auth> Authentication mode for remote servers (for example: oauth)
|
|
--disabled Persist the server as disabled
|
|
--env KEY=VALUE Add stdio environment variable (repeatable)
|
|
--header KEY=VALUE Add HTTP/SSE header (repeatable)
|
|
--json Print command result as JSON
|
|
--type <type> MCP transport: stdio, http, or sse
|
|
--url <url> Remote MCP endpoint URL
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPRemoveHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp remove <server> [flags]
|
|
|
|
Flags:
|
|
--json Print command result as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeMCPToggleHelp(w io.Writer, commandName string) error {
|
|
_, err := fmt.Fprintf(w, `Usage:
|
|
zero mcp %s <server> [flags]
|
|
|
|
Flags:
|
|
--json Print command result as JSON
|
|
-h, --help Show this help
|
|
`, commandName)
|
|
return err
|
|
}
|
|
|
|
func writeMCPCheckHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero mcp check <server> [flags]
|
|
|
|
Flags:
|
|
--json Print command result as JSON
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|