// Package mcpclient manages MCP server configuration and short-lived discovery sessions. package mcpclient import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "reflect" "strings" "sync" "time" "github.com/zalando/go-keyring" ) type ServerConfig struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` Transport string `json:"transport"` URL string `json:"url"` Command string `json:"command"` Args []string `json:"args"` WorkingDirectory string `json:"workingDirectory"` TimeoutSeconds int `json:"timeoutSeconds"` } // Secrets are write-only and never included in configuration files or views. type Secrets struct { Env map[string]string `json:"env"` Headers map[string]string `json:"headers"` } type ServerInput struct { Config ServerConfig `json:"config"` Secrets Secrets `json:"secrets"` ReplaceSecrets bool `json:"replaceSecrets"` } type ServerView struct { Config ServerConfig `json:"config"` HasSecrets bool `json:"hasSecrets"` } type ToolInfo struct { Name string `json:"name"` Description string `json:"description"` InputSchema string `json:"inputSchema"` } type TestResult struct { OK bool `json:"ok"` Message string `json:"message"` ElapsedMs int64 `json:"elapsedMs"` Tools []ToolInfo `json:"tools"` } type secretStore interface { Get(string) (string, error) Set(string, string) error Delete(string) error } type systemSecrets struct{} func (systemSecrets) Get(id string) (string, error) { return keyring.Get("com.ituoke.mcp", id) } func (systemSecrets) Set(id, value string) error { return keyring.Set("com.ituoke.mcp", id, value) } func (systemSecrets) Delete(id string) error { return keyring.Delete("com.ituoke.mcp", id) } type Service struct { mu sync.Mutex path string secrets secretStore servers []ServerConfig loadErr error agentGate chan struct{} } func NewService() *Service { dir, err := os.UserConfigDir() s := newService(filepath.Join(dir, "iTuoke", "mcp.json"), systemSecrets{}) if err != nil { s.loadErr = fmt.Errorf("无法定位 MCP 配置目录") } if s.loadErr == nil { if err := s.ensureMobile(); err != nil { s.loadErr = err } } if s.loadErr == nil { if err := s.ensureFilesystem(); err != nil { s.loadErr = err } } return s } func newService(path string, secrets secretStore) *Service { s := &Service{path: path, secrets: secrets, servers: []ServerConfig{}} raw, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return s } if err == nil { err = json.Unmarshal(raw, &s.servers) } if err == nil { seen := map[string]bool{} for i, c := range s.servers { if c.ID == "" || seen[c.ID] { err = fmt.Errorf("duplicate ID") break } seen[c.ID] = true s.servers[i], err = normalize(c) if err != nil { break } } } if err != nil { s.loadErr = fmt.Errorf("MCP 配置读取失败,请检查 mcp.json 后重新启动应用") } return s } func normalize(c ServerConfig) (ServerConfig, error) { c.Name = strings.TrimSpace(c.Name) c.URL = strings.TrimSpace(c.URL) c.Command = strings.TrimSpace(c.Command) c.WorkingDirectory = strings.TrimSpace(c.WorkingDirectory) if c.Name == "" || len(c.Name) > 100 { return c, fmt.Errorf("服务名称应为 1–100 字节") } if c.TimeoutSeconds < 3 || c.TimeoutSeconds > 120 { return c, fmt.Errorf("连接超时应为 3–120 秒") } if len(c.ID) > 64 || strings.ContainsAny(c.ID, "/\\\x00") { return c, fmt.Errorf("服务标识无效") } if c.ID == filesystemServerID && c.Transport != "filesystem" { return c, fmt.Errorf("系统内置 Filesystem 的连接方式不可修改") } if c.ID == mobileServerID && c.Transport != "mobile" { return c, fmt.Errorf("系统内置 Mobile Next 的连接方式不可修改") } switch c.Transport { case "mobile": c.Command, c.WorkingDirectory, c.URL = "", "", "" c.Args = []string{} case "filesystem": if len(c.Args) == 0 || len(c.Args) > 20 { return c, fmt.Errorf("请指定 1–20 个允许访问的目录") } for _, dir := range c.Args { if !filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") { return c, fmt.Errorf("允许目录必须为有效的绝对路径") } } c.Command, c.WorkingDirectory, c.URL = "", "", "" case "http": u, err := url.Parse(c.URL) if err != nil || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http") { return c, fmt.Errorf("请填写有效的 MCP HTTP 地址,认证信息请放入请求头") } ip := net.ParseIP(u.Hostname()) if u.Scheme == "http" && u.Hostname() != "localhost" && (ip == nil || (!ip.IsLoopback() && !ip.IsPrivate())) { return c, fmt.Errorf("公网 MCP 地址请使用 HTTPS") } c.Command, c.WorkingDirectory, c.Args = "", "", []string{} case "stdio": if c.Command == "" || len(c.Command) > 4096 || strings.ContainsAny(c.Command+c.WorkingDirectory, "\x00\r\n") { return c, fmt.Errorf("请填写有效的可执行文件路径或命令名称") } if c.WorkingDirectory != "" && !filepath.IsAbs(c.WorkingDirectory) { return c, fmt.Errorf("工作目录请使用绝对路径") } if len(c.Args) > 128 { return c, fmt.Errorf("启动参数不能超过 128 项") } for _, arg := range c.Args { if len(arg) > 8192 || strings.ContainsRune(arg, 0) { return c, fmt.Errorf("启动参数无效或过长") } } if c.Args == nil { c.Args = []string{} } c.URL = "" default: return c, fmt.Errorf("请选择 stdio 或 Streamable HTTP") } return c, nil } func validateSecrets(c ServerConfig, sec Secrets) error { if (c.Transport == "filesystem" || c.Transport == "mobile") && len(sec.Env)+len(sec.Headers) > 0 { return fmt.Errorf("内置 MCP 服务不需要凭据") } raw, _ := json.Marshal(sec) if len(raw) > 16000 || len(sec.Env)+len(sec.Headers) > 100 { return fmt.Errorf("环境变量或请求头配置过大") } if c.Transport == "http" && len(sec.Env) > 0 { return fmt.Errorf("HTTP 服务请配置请求头") } if c.Transport == "stdio" && len(sec.Headers) > 0 { return fmt.Errorf("stdio 服务请配置环境变量") } for k, v := range sec.Env { if k == "" || strings.ContainsAny(k, "=\x00\r\n") || strings.ContainsRune(v, 0) { return fmt.Errorf("环境变量名称或值无效") } } for k, v := range sec.Headers { if k == "" || strings.TrimSpace(k) != k || strings.ContainsAny(v, "\x00\r\n") { return fmt.Errorf("请求头名称或值无效") } for _, r := range k { if !strings.ContainsRune("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", r) { return fmt.Errorf("请求头名称无效") } } switch strings.ToLower(k) { case "host", "content-type", "accept", "content-length", "connection", "transfer-encoding": return fmt.Errorf("请勿覆盖 HTTP 协议请求头") } if strings.HasPrefix(strings.ToLower(k), "mcp-") { return fmt.Errorf("请勿覆盖 MCP 协议请求头") } } return nil } func (s *Service) index(id string) int { for i, c := range s.servers { if c.ID == id { return i } } return -1 } func (s *Service) readSecret(id string) (string, error) { if id == "" { return "", nil } raw, err := s.secrets.Get(id) if errors.Is(err, keyring.ErrNotFound) { return "", nil } if err != nil { return "", fmt.Errorf("无法访问系统凭据库") } return raw, nil } func sameTarget(a, b ServerConfig) bool { return a.Transport == b.Transport && a.URL == b.URL && a.Command == b.Command && a.WorkingDirectory == b.WorkingDirectory && reflect.DeepEqual(a.Args, b.Args) } func (s *Service) resolve(input ServerInput) (ServerConfig, Secrets, string, error) { c, err := normalize(input.Config) if err != nil { return c, Secrets{}, "", err } if i := s.index(c.ID); i >= 0 && systemTransport(s.servers[i].Transport) && c.Transport != s.servers[i].Transport { return c, Secrets{}, "", fmt.Errorf("系统内置 MCP 的连接方式不可修改") } if err := validateFilesystemDirs(c); err != nil { return c, Secrets{}, "", err } i := s.index(c.ID) if c.ID != "" && i < 0 { return c, Secrets{}, "", fmt.Errorf("MCP 服务不存在,请重新加载") } old, err := s.readSecret(c.ID) if err != nil { return c, Secrets{}, "", err } sec := input.Secrets if !input.ReplaceSecrets { if len(sec.Env)+len(sec.Headers) > 0 { return c, sec, old, fmt.Errorf("填写新凭据时请选择替换凭据") } if old != "" && !sameTarget(s.servers[i], c) { return c, sec, old, fmt.Errorf("连接目标已更改,请重新填写或清除凭据") } if old != "" && json.Unmarshal([]byte(old), &sec) != nil { return c, sec, old, fmt.Errorf("MCP 凭据读取失败,请重新填写") } } return c, sec, old, validateSecrets(c, sec) } func (s *Service) ListServers() ([]ServerView, error) { s.mu.Lock() defer s.mu.Unlock() if s.loadErr != nil { return nil, s.loadErr } views := make([]ServerView, 0, len(s.servers)) for _, c := range s.servers { raw, err := s.readSecret(c.ID) if err != nil { return nil, err } c.Args = append([]string{}, c.Args...) views = append(views, ServerView{Config: c, HasSecrets: raw != ""}) } return views, nil } func (s *Service) persist(servers []ServerConfig) error { if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil { return fmt.Errorf("无法创建 MCP 配置目录") } f, err := os.CreateTemp(filepath.Dir(s.path), "mcp-*.tmp") if err != nil { return fmt.Errorf("无法写入 MCP 配置") } defer os.Remove(f.Name()) data, _ := json.MarshalIndent(servers, "", " ") _, writeErr := f.Write(data) closeErr := f.Close() if writeErr != nil || closeErr != nil { return fmt.Errorf("MCP 配置写入失败") } if err := os.Rename(f.Name(), s.path); err != nil { return fmt.Errorf("MCP 配置保存失败") } return nil } func (s *Service) setSecret(id, raw string) error { var err error if raw == "" { err = s.secrets.Delete(id) } else { err = s.secrets.Set(id, raw) } if err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("MCP 凭据保存失败,请检查系统凭据库") } return nil } func (s *Service) SaveServer(input ServerInput) (ServerView, error) { s.mu.Lock() defer s.mu.Unlock() if s.loadErr != nil { return ServerView{}, s.loadErr } c, sec, old, err := s.resolve(input) if err != nil { return ServerView{}, err } if c.ID == "" { if len(s.servers) >= 50 { return ServerView{}, fmt.Errorf("最多添加 50 个 MCP 服务") } var id [16]byte if _, err := rand.Read(id[:]); err != nil { return ServerView{}, fmt.Errorf("无法创建服务标识") } c.ID = hex.EncodeToString(id[:]) } for _, other := range s.servers { if other.ID != c.ID && strings.EqualFold(other.Name, c.Name) { return ServerView{}, fmt.Errorf("服务名称已存在") } } raw := "" if len(sec.Env)+len(sec.Headers) > 0 { b, _ := json.Marshal(sec) raw = string(b) } if raw != old { if err := s.setSecret(c.ID, raw); err != nil { return ServerView{}, err } } next := append([]ServerConfig{}, s.servers...) if i := s.index(c.ID); i >= 0 { next[i] = c } else { next = append(next, c) } if err := s.persist(next); err != nil { if raw != old { if rollback := s.setSecret(c.ID, old); rollback != nil { return ServerView{}, fmt.Errorf("配置保存失败,凭据恢复失败,请重新填写凭据") } } return ServerView{}, err } s.servers = next return ServerView{Config: c, HasSecrets: raw != ""}, nil } func (s *Service) SetEnabled(id string, enabled bool) error { s.mu.Lock() defer s.mu.Unlock() if s.loadErr != nil { return s.loadErr } i := s.index(id) if i < 0 { return fmt.Errorf("MCP 服务不存在") } next := append([]ServerConfig{}, s.servers...) next[i].Enabled = enabled if err := s.persist(next); err != nil { return err } s.servers = next return nil } func (s *Service) DeleteServer(id string) error { s.mu.Lock() defer s.mu.Unlock() if s.loadErr != nil { return s.loadErr } if id == mobileServerID { return fmt.Errorf("系统内置 Mobile Next 可停用,无需删除") } i := s.index(id) if i < 0 { return fmt.Errorf("MCP 服务不存在") } if systemTransport(s.servers[i].Transport) { return fmt.Errorf("系统内置 MCP 可停用或编辑,不允许删除") } old, err := s.readSecret(id) if err != nil { return err } if old != "" { if err := s.setSecret(id, ""); err != nil { return err } } next := append([]ServerConfig{}, s.servers[:i]...) next = append(next, s.servers[i+1:]...) if err := s.persist(next); err != nil { if old != "" { if rollback := s.setSecret(id, old); rollback != nil { return fmt.Errorf("删除失败,凭据恢复失败,请重新填写凭据") } } return err } s.servers = next return nil } // TestServer discovers tools using the draft without saving or executing tools. // Sessions are closed after discovery; enabled is a saved availability preference. func (s *Service) TestServer(ctx context.Context, input ServerInput) TestResult { start := time.Now() result := TestResult{Tools: []ToolInfo{}} s.mu.Lock() c, sec, _, err := s.resolve(input) if s.loadErr != nil { err = s.loadErr } s.mu.Unlock() if err == nil { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, time.Duration(c.TimeoutSeconds)*time.Second) defer cancel() result.Tools, err = discover(ctx, c, sec) if err != nil { switch { case errors.Is(ctx.Err(), context.Canceled): err = fmt.Errorf("连接测试已取消") case errors.Is(ctx.Err(), context.DeadlineExceeded): err = fmt.Errorf("连接超时,请检查服务是否启动以及连接地址") default: err = fmt.Errorf("连接或工具发现失败,请检查启动命令、运行环境、MCP 地址和认证配置") } } } result.ElapsedMs = time.Since(start).Milliseconds() result.OK = err == nil if err != nil { result.Message = err.Error() result.Tools = []ToolInfo{} } else { result.Message = fmt.Sprintf("连接成功,发现 %d 个工具", len(result.Tools)) } return result } // Request headers are attached only to this service's own HTTP client. type headerTransport struct { base http.RoundTripper headers map[string]string ctx context.Context } func (t headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { ctx, cancel := context.WithCancel(req.Context()) stop := context.AfterFunc(t.ctx, cancel) cleanup := func() { stop(); cancel() } clone := req.Clone(ctx) for k, v := range t.headers { clone.Header.Set(k, v) } res, err := t.base.RoundTrip(clone) if err != nil { cleanup() return nil, err } res.Body = &cleanupBody{ReadCloser: res.Body, cleanup: cleanup} return res, nil } type cleanupBody struct { io.ReadCloser cleanup func() } func (b *cleanupBody) Close() error { defer b.cleanup(); return b.ReadCloser.Close() }