mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* feat(mcp): add Model Context Protocol server support Add MCP client and provider packages that allow integrating external MCP tool servers into the review loop. Includes config commands for managing MCP servers, stdio subprocess integration tests, and comprehensive test coverage. * refactor(mcp): rename loop variable in contentToText to avoid shadowing Client receiver * fix(mcp): use platform-specific shell for setup command The MCP server setup command was hardcoded to use `sh -c`, which fails on Windows. Extract a `shellCommand` helper behind build tags to use `cmd /c` on Windows and `sh -c` elsewhere. * docs(mcp): add MCP server documentation to all README locales
607 lines
18 KiB
Go
607 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/open-code-review/open-code-review/internal/llm"
|
|
)
|
|
|
|
// Default config file location: ~/.opencodereview/config.json
|
|
func defaultConfigPath() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot determine home directory: %w", err)
|
|
}
|
|
return filepath.Join(home, ".opencodereview", "config.json"), nil
|
|
}
|
|
|
|
// resolveConfigPath returns OCR_CONFIG_PATH when set, otherwise the default user config path.
|
|
// Intentionally used only by read-only commands (e.g. ocr llm test). Write paths such as
|
|
// config set and review keep defaultConfigPath() so a leaked OCR_CONFIG_PATH cannot redirect writes.
|
|
func resolveConfigPath() (string, error) {
|
|
if p := strings.TrimSpace(os.Getenv("OCR_CONFIG_PATH")); p != "" {
|
|
return p, nil
|
|
}
|
|
return defaultConfigPath()
|
|
}
|
|
|
|
func runConfig(args []string) error {
|
|
if len(args) == 0 {
|
|
printConfigUsage()
|
|
return nil
|
|
}
|
|
|
|
switch args[0] {
|
|
case "provider":
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("config provider does not accept arguments; use 'ocr config set provider <name>' for non-interactive setup")
|
|
}
|
|
return runConfigProvider()
|
|
case "model":
|
|
if len(args) != 1 {
|
|
return fmt.Errorf("config model does not accept arguments; use 'ocr config set model <name>' for non-interactive setup")
|
|
}
|
|
return runConfigModel()
|
|
}
|
|
|
|
action, err := parseConfigArgs(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch action.subCmd {
|
|
case "set":
|
|
return runConfigSet(action.key, action.value)
|
|
case "unset":
|
|
return runConfigUnset(action.key)
|
|
default:
|
|
return fmt.Errorf("unknown config sub-command: %s", action.subCmd)
|
|
}
|
|
}
|
|
|
|
func runConfigSet(key, value string) error {
|
|
configPath, err := defaultConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
if err := setConfigValue(cfg, key, value); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
displayValue := value
|
|
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
|
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
|
|
displayValue = maskKey(value)
|
|
}
|
|
fmt.Printf("Set %s = %s\n", key, displayValue)
|
|
return nil
|
|
}
|
|
|
|
func runConfigUnset(key string) error {
|
|
parts := strings.SplitN(key, ".", 2)
|
|
if len(parts) != 2 || parts[1] == "" {
|
|
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
|
|
}
|
|
|
|
configPath, err := defaultConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch parts[0] {
|
|
case "custom_providers":
|
|
return unsetCustomProvider(configPath, parts[1])
|
|
case "mcp_servers":
|
|
return unsetMCPServer(configPath, parts[1])
|
|
default:
|
|
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
|
|
}
|
|
}
|
|
|
|
func unsetCustomProvider(configPath, name string) error {
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
wasActive, err := deleteCustomProvider(cfg, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Deleted custom provider %q.\n", name)
|
|
if wasActive {
|
|
fmt.Fprintf(os.Stderr, "[ocr] WARNING: active provider was deleted; 'provider' and 'model' have been cleared.\n")
|
|
fmt.Fprintf(os.Stderr, "[ocr] Run 'ocr config provider' to select a new provider.\n")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func unsetMCPServer(configPath, name string) error {
|
|
cfg, err := loadOrCreateConfig(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
if cfg.MCPServers == nil {
|
|
return fmt.Errorf("MCP server %q not found", name)
|
|
}
|
|
if _, exists := cfg.MCPServers[name]; !exists {
|
|
return fmt.Errorf("MCP server %q not found", name)
|
|
}
|
|
|
|
delete(cfg.MCPServers, name)
|
|
if len(cfg.MCPServers) == 0 {
|
|
cfg.MCPServers = nil
|
|
}
|
|
|
|
if err := saveConfig(configPath, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Printf("Deleted MCP server %q.\n", name)
|
|
return nil
|
|
}
|
|
|
|
// deleteCustomProvider removes a custom provider from cfg in memory.
|
|
// Returns true if the deleted provider was the active one.
|
|
func deleteCustomProvider(cfg *Config, name string) (bool, error) {
|
|
if cfg.CustomProviders == nil {
|
|
return false, fmt.Errorf("custom provider %q not found", name)
|
|
}
|
|
if _, exists := cfg.CustomProviders[name]; !exists {
|
|
return false, fmt.Errorf("custom provider %q not found", name)
|
|
}
|
|
|
|
wasActive := cfg.Provider == name
|
|
delete(cfg.CustomProviders, name)
|
|
if len(cfg.CustomProviders) == 0 {
|
|
cfg.CustomProviders = nil
|
|
}
|
|
|
|
if wasActive {
|
|
cfg.Provider = ""
|
|
cfg.Model = ""
|
|
}
|
|
|
|
return wasActive, nil
|
|
}
|
|
|
|
// ProviderEntry holds per-provider configuration in the providers map.
|
|
type ProviderEntry struct {
|
|
APIKey string `json:"api_key,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Models []string `json:"models,omitempty"`
|
|
AuthHeader string `json:"auth_header,omitempty"`
|
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
|
}
|
|
|
|
// MCPServerConfig holds configuration for a single MCP server (stdio transport).
|
|
type MCPServerConfig struct {
|
|
Command string `json:"command"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env []string `json:"env,omitempty"`
|
|
Tools []string `json:"tools,omitempty"`
|
|
Setup string `json:"setup,omitempty"`
|
|
}
|
|
|
|
// Config represents the user-level configuration file (~/.opencodereview/config.json).
|
|
type Config struct {
|
|
Provider string `json:"provider,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Providers map[string]ProviderEntry `json:"providers,omitempty"`
|
|
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
|
|
Llm LlmConfig `json:"llm,omitempty"`
|
|
Language string `json:"language,omitempty"`
|
|
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
|
|
MCPServers map[string]MCPServerConfig `json:"mcp_servers,omitempty"`
|
|
}
|
|
|
|
type LlmConfig struct {
|
|
URL string `json:"url,omitempty"`
|
|
AuthToken string `json:"auth_token,omitempty"`
|
|
AuthHeader string `json:"auth_header,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol
|
|
ExtraBody map[string]any `json:"extra_body,omitempty"`
|
|
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
|
|
}
|
|
|
|
// TelemetryConfig holds telemetry-specific settings.
|
|
type TelemetryConfig struct {
|
|
Enabled bool `json:"enabled,omitempty"` // Master switch for telemetry
|
|
Exporter string `json:"exporter,omitempty"` // "console" or "otlp"
|
|
OTLPEndpoint string `json:"otlp_endpoint,omitempty"` // OTLP collector address
|
|
ContentLog bool `json:"content_logging,omitempty"` // Include prompt/response content
|
|
}
|
|
|
|
func loadOrCreateConfig(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &Config{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// LoadAppConfig loads config from path. Returns nil, nil if file does not exist.
|
|
func LoadAppConfig(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("read app config %s: %w", path, err)
|
|
}
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse app config: %w", err)
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
func setConfigValue(cfg *Config, key, value string) error {
|
|
// Handle providers.<name>.<field> paths.
|
|
if strings.HasPrefix(key, "providers.") {
|
|
return setProviderValue(cfg, key, value)
|
|
}
|
|
if strings.HasPrefix(key, "custom_providers.") {
|
|
return setCustomProviderValue(cfg, key, value)
|
|
}
|
|
if strings.HasPrefix(key, "mcp_servers.") {
|
|
return setMCPServerValue(cfg, key, value)
|
|
}
|
|
|
|
switch key {
|
|
case "provider":
|
|
if cfg.Provider != value {
|
|
cfg.Model = ""
|
|
}
|
|
cfg.Provider = value
|
|
if _, isPreset := llm.LookupProvider(value); isPreset {
|
|
if cfg.Providers == nil {
|
|
cfg.Providers = make(map[string]ProviderEntry)
|
|
}
|
|
if _, exists := cfg.Providers[value]; !exists {
|
|
cfg.Providers[value] = ProviderEntry{}
|
|
}
|
|
} else {
|
|
if cfg.CustomProviders == nil {
|
|
cfg.CustomProviders = make(map[string]ProviderEntry)
|
|
}
|
|
if _, exists := cfg.CustomProviders[value]; !exists {
|
|
cfg.CustomProviders[value] = ProviderEntry{}
|
|
}
|
|
}
|
|
case "model":
|
|
if cfg.Provider != "" {
|
|
if _, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
|
|
if cfg.Providers == nil {
|
|
cfg.Providers = make(map[string]ProviderEntry)
|
|
}
|
|
entry := cfg.Providers[cfg.Provider]
|
|
entry.Model = value
|
|
cfg.Providers[cfg.Provider] = entry
|
|
} else {
|
|
if cfg.CustomProviders == nil {
|
|
cfg.CustomProviders = make(map[string]ProviderEntry)
|
|
}
|
|
entry := cfg.CustomProviders[cfg.Provider]
|
|
entry.Model = value
|
|
cfg.CustomProviders[cfg.Provider] = entry
|
|
}
|
|
} else {
|
|
cfg.Model = value
|
|
}
|
|
case "llm.url", "llm.URL":
|
|
cfg.Llm.URL = value
|
|
case "llm.auth_token", "llm.AuthToken":
|
|
cfg.Llm.AuthToken = value
|
|
case "llm.auth_header", "llm.AuthHeader":
|
|
normalized, err := llm.NormalizeAuthHeader(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Llm.AuthHeader = normalized
|
|
case "llm.extra_headers", "llm.ExtraHeaders":
|
|
parsed, err := llm.ParseExtraHeaders(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Llm.ExtraHeaders = parsed
|
|
case "llm.model", "llm.Model":
|
|
cfg.Llm.Model = value
|
|
case "llm.use_anthropic", "llm.UseAnthropic":
|
|
b, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid boolean for llm.use_anthropic: %w", err)
|
|
}
|
|
cfg.Llm.UseAnthropic = &b
|
|
case "language", "Language":
|
|
cfg.Language = value
|
|
case "telemetry.enabled", "telemetry.Enabled":
|
|
b, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid boolean for telemetry.enabled: %w", err)
|
|
}
|
|
cfg.ensureTelemetry()
|
|
cfg.Telemetry.Enabled = b
|
|
case "telemetry.exporter", "telemetry.Exporter":
|
|
cfg.ensureTelemetry()
|
|
cfg.Telemetry.Exporter = value
|
|
case "telemetry.otlp_endpoint", "telemetry.OTLPEndpoint":
|
|
cfg.ensureTelemetry()
|
|
cfg.Telemetry.OTLPEndpoint = value
|
|
case "telemetry.content_logging", "telemetry.ContentLog":
|
|
b, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid boolean for telemetry.content_logging: %w", err)
|
|
}
|
|
cfg.ensureTelemetry()
|
|
cfg.Telemetry.ContentLog = b
|
|
case "llm.extra_body", "llm.ExtraBody":
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(value), &m); err != nil {
|
|
return fmt.Errorf("invalid JSON for llm.extra_body: %w", err)
|
|
}
|
|
cfg.Llm.ExtraBody = m
|
|
default:
|
|
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nMCP server fields: command, args, env, tools, setup", key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyProviderField(entry *ProviderEntry, field, key, value string) error {
|
|
switch field {
|
|
case "api_key":
|
|
entry.APIKey = value
|
|
case "url":
|
|
entry.URL = value
|
|
case "protocol":
|
|
if value != "anthropic" && value != "openai" {
|
|
return fmt.Errorf("invalid protocol %q: must be \"anthropic\" or \"openai\"", value)
|
|
}
|
|
entry.Protocol = value
|
|
case "model":
|
|
entry.Model = value
|
|
case "models":
|
|
models, err := parseModelListValue(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid model list for %s: %w", key, err)
|
|
}
|
|
entry.Models = models
|
|
case "auth_header":
|
|
normalized, err := llm.NormalizeAuthHeader(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry.AuthHeader = normalized
|
|
case "extra_body":
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(value), &m); err != nil {
|
|
return fmt.Errorf("invalid JSON for %s: %w", key, err)
|
|
}
|
|
entry.ExtraBody = m
|
|
case "extra_headers":
|
|
parsed, err := llm.ParseExtraHeaders(value)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid extra headers for %s: %w", key, err)
|
|
}
|
|
entry.ExtraHeaders = parsed
|
|
default:
|
|
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseModelListValue(value string) ([]string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
if strings.HasPrefix(value, "[") {
|
|
var models []string
|
|
if err := json.Unmarshal([]byte(value), &models); err == nil {
|
|
return normalizeModelList(models), nil
|
|
}
|
|
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
|
|
}
|
|
|
|
return normalizeModelList(strings.Split(value, ",")), nil
|
|
}
|
|
|
|
func activeModelForProvider(cfg *Config, providerName string, entry ProviderEntry) string {
|
|
if entry.Model != "" {
|
|
return entry.Model
|
|
}
|
|
if cfg != nil && cfg.Provider == providerName && cfg.Model != "" {
|
|
return cfg.Model
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeModelList(models []string) []string {
|
|
out := make([]string, 0, len(models))
|
|
seen := make(map[string]struct{}, len(models))
|
|
for _, model := range models {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[model]; ok {
|
|
continue
|
|
}
|
|
seen[model] = struct{}{}
|
|
out = append(out, model)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func mergeModelLists(lists ...[]string) []string {
|
|
var merged []string
|
|
for _, list := range lists {
|
|
merged = append(merged, list...)
|
|
}
|
|
return normalizeModelList(merged)
|
|
}
|
|
|
|
// ensureModelInList appends model to the end when missing; never reorders existing entries.
|
|
func ensureModelInList(models []string, model string) []string {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return models
|
|
}
|
|
if modelListContains(models, model) {
|
|
return models
|
|
}
|
|
out := append([]string(nil), models...)
|
|
return append(out, model)
|
|
}
|
|
|
|
func modelListContains(models []string, target string) bool {
|
|
target = strings.TrimSpace(target)
|
|
for _, model := range models {
|
|
if model == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setProviderValue(cfg *Config, key, value string) error {
|
|
parts := strings.SplitN(key, ".", 3)
|
|
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
|
return fmt.Errorf("invalid provider key %q: expected providers.<name>.<field>", key)
|
|
}
|
|
if _, isPreset := llm.LookupProvider(parts[1]); !isPreset {
|
|
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
|
|
}
|
|
if cfg.Providers == nil {
|
|
cfg.Providers = make(map[string]ProviderEntry)
|
|
}
|
|
entry := cfg.Providers[parts[1]]
|
|
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
|
|
return err
|
|
}
|
|
cfg.Providers[parts[1]] = entry
|
|
return nil
|
|
}
|
|
|
|
func setCustomProviderValue(cfg *Config, key, value string) error {
|
|
parts := strings.SplitN(key, ".", 3)
|
|
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
|
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
|
|
}
|
|
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
|
|
}
|
|
|
|
func setCustomProviderField(cfg *Config, name, field, key, value string) error {
|
|
if cfg.CustomProviders == nil {
|
|
cfg.CustomProviders = make(map[string]ProviderEntry)
|
|
}
|
|
entry := cfg.CustomProviders[name]
|
|
if err := applyProviderField(&entry, field, key, value); err != nil {
|
|
return err
|
|
}
|
|
cfg.CustomProviders[name] = entry
|
|
return nil
|
|
}
|
|
|
|
func setMCPServerValue(cfg *Config, key, value string) error {
|
|
parts := strings.SplitN(key, ".", 3)
|
|
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
|
|
return fmt.Errorf("invalid MCP server key %q: expected mcp_servers.<name>.<field>", key)
|
|
}
|
|
name, field := parts[1], parts[2]
|
|
|
|
if cfg.MCPServers == nil {
|
|
cfg.MCPServers = make(map[string]MCPServerConfig)
|
|
}
|
|
entry := cfg.MCPServers[name]
|
|
|
|
switch field {
|
|
case "command":
|
|
if value == "" {
|
|
return fmt.Errorf("MCP server command cannot be empty")
|
|
}
|
|
entry.Command = value
|
|
case "args":
|
|
var args []string
|
|
if err := json.Unmarshal([]byte(value), &args); err != nil {
|
|
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
|
}
|
|
entry.Args = args
|
|
case "env":
|
|
var env []string
|
|
if err := json.Unmarshal([]byte(value), &env); err != nil {
|
|
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
|
}
|
|
for _, e := range env {
|
|
idx := strings.Index(e, "=")
|
|
if idx <= 0 {
|
|
return fmt.Errorf("invalid env entry %q: must be in KEY=VALUE format", e)
|
|
}
|
|
}
|
|
entry.Env = env
|
|
case "tools":
|
|
var tools []string
|
|
if err := json.Unmarshal([]byte(value), &tools); err != nil {
|
|
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
|
|
}
|
|
seen := make(map[string]struct{}, len(tools))
|
|
filtered := make([]string, 0, len(tools))
|
|
for _, t := range tools {
|
|
if t == "" {
|
|
return fmt.Errorf("tool names in %s must not be empty", key)
|
|
}
|
|
if _, dup := seen[t]; dup {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
filtered = append(filtered, t)
|
|
}
|
|
entry.Tools = filtered
|
|
case "setup":
|
|
entry.Setup = value
|
|
default:
|
|
return fmt.Errorf("unknown MCP server field %q: supported fields are command, args, env, tools, setup", field)
|
|
}
|
|
|
|
cfg.MCPServers[name] = entry
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) ensureTelemetry() {
|
|
if c.Telemetry == nil {
|
|
c.Telemetry = &TelemetryConfig{}
|
|
}
|
|
}
|