Files
zero/internal/mcp/config.go
1ec7219aee feat: use Exa as default search provider (#926)
* feat: use Exa as default search provider

Amp-Thread-ID: https://ampcode.com/threads/T-01a01522-a5f1-7139-8a55-5e417cba2751
Co-authored-by: Amp <[email protected]>

* fix(config): carry a legacy default's disable across the Exa migration

A user who ran `zero mcp disable firecrawl` chose not to open that outbound
connection. Renaming the built-in default to exa re-opened it under the new
name on upgrade, and because the replacement looked like an untouched default
the startup warning stayed quiet too.

Carry an explicit user-level disable of a retired default onto its successor,
but only when the user never declared the successor themselves — an explicit
exa entry wins either way. The carried-over disable is recorded as a user-level
decision, so the lower-trust project layer cannot lift it while
`zero mcp enable exa` still can.

Regression coverage: the legacy-disabled upgrade path, the explicit-exa
override, a configured-but-enabled legacy entry, the CLI re-enable, the
project-layer re-enable attempt, and the resulting startup behavior
(NormalizeConfig must not hand back an exa server to connect to).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix(config): keep a retired default's transport for partial user entries

A user who added an API key to the firecrawl default wrote only the header —
the seeded default supplied the http type and URL. Once the default stops being
seeded that entry resolves with no type, url, or command, and NormalizeConfig
rejects it, taking down startup for every other server too rather than just
that one.

Record what Zero used to ship for a retired default and re-seed it under the
user's own fields. Only for an entry that named no transport of its own: an
entry with a self-hosted url or a stdio command is a complete definition the
user owns, and grafting the retired http default onto a command produces a
server that is rejected outright.

Regression coverage at both ResolveMCP and NormalizeConfig for the header-only
entry and its failure path, the self-hosted stdio entry that must be left
alone, and a disabled retired entry keeping a usable transport so a later
re-enable resolves.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 20:26:24 +08:00

230 lines
7.0 KiB
Go

package mcp
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/config"
)
type ServerType string
const (
ServerTypeStdio ServerType = "stdio"
ServerTypeHTTP ServerType = "http"
ServerTypeSSE ServerType = "sse"
)
type Server struct {
Name string
Type ServerType
Command string
Args []string
Env map[string]string
URL string
Headers map[string]string
Auth string
OAuth *OAuthConfig
Identity string
// ProjectConfigured is true when project config touched this server. Runtime
// credential lookup uses it to avoid reusing legacy user tokens by name.
ProjectConfigured bool
// UnconfiguredDefault is true when this server is one of Zero's built-in
// defaults (e.g. keyless Exa) that the user never touched in their
// config — no credentials, no overrides. Callers use it to avoid warning
// loudly when a server nobody configured fails to connect.
UnconfiguredDefault bool
}
func NormalizeConfig(cfg config.MCPConfig) ([]Server, error) {
names := make([]string, 0, len(cfg.Servers))
for name := range cfg.Servers {
names = append(names, name)
}
sort.Strings(names)
servers := make([]Server, 0, len(names))
for _, name := range names {
raw := cfg.Servers[name]
if raw.Disabled {
continue
}
server, err := normalizeServer(name, raw)
if err != nil {
return nil, err
}
servers = append(servers, server)
}
return servers, nil
}
func normalizeServer(name string, raw config.MCPServerConfig) (Server, error) {
name = strings.TrimSpace(name)
if err := ValidateServerName(name); err != nil {
return Server{}, err
}
serverType := ServerType(strings.ToLower(strings.TrimSpace(raw.Type)))
if serverType == "" {
// When type is omitted, a URL makes the server use HTTP validation; otherwise
// command-based stdio remains the default.
if strings.TrimSpace(raw.URL) != "" {
serverType = ServerTypeHTTP
} else {
serverType = ServerTypeStdio
}
}
auth := strings.ToLower(strings.TrimSpace(raw.Auth))
server := Server{
Name: name,
Type: serverType,
Command: strings.TrimSpace(raw.Command),
Args: trimStringSlice(raw.Args),
Env: copyStringMap(raw.Env),
URL: strings.TrimSpace(raw.URL),
Headers: copyStringMap(raw.Headers),
Auth: auth,
OAuth: normalizeOAuthConfig(raw.OAuth),
ProjectConfigured: raw.ProjectConfigured,
UnconfiguredDefault: config.IsUnconfiguredDefault(name, raw),
}
switch server.Type {
case ServerTypeStdio:
if server.Command == "" {
return Server{}, fmt.Errorf("MCP server %s requires command for stdio transport", server.Name)
}
if server.URL != "" {
return Server{}, fmt.Errorf("MCP server %s url is only supported for http or sse transports", server.Name)
}
if len(server.Headers) > 0 {
return Server{}, fmt.Errorf("MCP server %s headers are only supported for http or sse transports", server.Name)
}
if server.Auth != "" {
return Server{}, fmt.Errorf("MCP server %s auth is only supported for http or sse transports", server.Name)
}
case ServerTypeHTTP, ServerTypeSSE:
if server.URL == "" {
return Server{}, fmt.Errorf("MCP server %s requires url for %s transport", server.Name, server.Type)
}
if server.Command != "" || len(server.Args) > 0 {
return Server{}, fmt.Errorf("MCP server %s command and args are only supported for stdio transport", server.Name)
}
if len(server.Env) > 0 {
return Server{}, fmt.Errorf("MCP server %s env is only supported for stdio transport", server.Name)
}
if err := validateHTTPURL(server.Name, server.URL); err != nil {
return Server{}, err
}
default:
return Server{}, fmt.Errorf("MCP server %s has unsupported type %q", server.Name, raw.Type)
}
if server.Auth != "" && server.Auth != ServerAuthOAuth {
return Server{}, fmt.Errorf("MCP server %s has unsupported auth %q", server.Name, raw.Auth)
}
server.Identity = computeServerIdentity(server)
return server, nil
}
// normalizeOAuthConfig converts a raw config OAuth block into the mcp package's
// OAuthConfig, trimming endpoint and identifier fields. Credential values are
// preserved verbatim.
func normalizeOAuthConfig(raw *config.MCPOAuthConfig) *OAuthConfig {
if raw == nil {
return nil
}
return &OAuthConfig{
ClientID: strings.TrimSpace(raw.ClientID),
ClientSecret: raw.ClientSecret,
Scopes: trimStringSlice(raw.Scopes),
AuthorizationEndpoint: strings.TrimSpace(raw.AuthorizationEndpoint),
TokenEndpoint: strings.TrimSpace(raw.TokenEndpoint),
RegistrationEndpoint: strings.TrimSpace(raw.RegistrationEndpoint),
IssuerURL: strings.TrimSpace(raw.IssuerURL),
}
}
func validateHTTPURL(serverName string, value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" {
return fmt.Errorf("MCP server %s url must be a valid http or https URL", serverName)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("MCP server %s url must use http or https", serverName)
}
return nil
}
func computeServerIdentity(server Server) string {
canonical := struct {
Type ServerType `json:"type"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Auth string `json:"auth,omitempty"`
OAuth *OAuthConfig `json:"oauth,omitempty"`
}{
Type: server.Type,
Command: server.Command,
Args: append([]string{}, server.Args...),
Env: copyStringMap(server.Env),
URL: server.URL,
Headers: copyStringMap(server.Headers),
Auth: server.Auth,
OAuth: server.OAuth,
}
// Marshal cannot fail for this canonical shape because it only contains
// JSON-serializable primitive, slice, and map fields.
data, _ := json.Marshal(canonical)
sum := sha256.Sum256(data)
// The first 32 hex characters give a stable 128-bit identity while keeping
// permission records compact.
return hex.EncodeToString(sum[:])[:32]
}
func trimStringSlice(values []string) []string {
if values == nil {
return nil
}
trimmed := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
trimmed = append(trimmed, value)
}
return trimmed
}
// copyStringMap trims and filters keys while preserving values verbatim. Env
// and header values may intentionally contain surrounding whitespace, unlike
// scalar fields such as Command and URL.
func copyStringMap(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}
copied := make(map[string]string, len(values))
for key, value := range values {
key = strings.TrimSpace(key)
if key == "" {
continue
}
copied[key] = value
}
if len(copied) == 0 {
return nil
}
return copied
}