mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 17:57:54 -10:00
* 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]>
230 lines
7.0 KiB
Go
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
|
|
}
|