ituoke/internal/mcpclient/service.go

526 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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() }