226 lines
7.2 KiB
Go
226 lines
7.2 KiB
Go
package mcpclient
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ituoke/ituoke/internal/runtimeassets"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
const filesystemServerID = "builtin-filesystem"
|
|
|
|
func (s *Service) ensureFilesystem() error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.loadErr != nil {
|
|
return s.loadErr
|
|
}
|
|
for _, c := range s.servers {
|
|
if c.Transport == "filesystem" {
|
|
return nil
|
|
}
|
|
}
|
|
dir := filepath.Join(filepath.Dir(s.path), "files")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return err
|
|
}
|
|
c := ServerConfig{ID: filesystemServerID, Name: "Filesystem", Enabled: true, Transport: "filesystem", Args: []string{dir}, TimeoutSeconds: 20}
|
|
next := append(append([]ServerConfig{}, s.servers...), c)
|
|
if err := s.persist(next); err != nil {
|
|
return err
|
|
}
|
|
s.servers = next
|
|
return nil
|
|
}
|
|
|
|
func systemTransport(transport string) bool {
|
|
return transport == "filesystem" || transport == "mobile"
|
|
}
|
|
|
|
func filesystemCommand(c ServerConfig) (ServerConfig, error) {
|
|
name := "node"
|
|
if runtime.GOOS == "windows" {
|
|
name += ".exe"
|
|
}
|
|
node := runtimeassets.Find(filepath.Join("mcp-filesystem", name))
|
|
entry := runtimeassets.Find(filepath.Join("mcp-filesystem", "node_modules", "@modelcontextprotocol", "server-filesystem", "dist", "index.js"))
|
|
if node == "" || entry == "" {
|
|
return c, fmt.Errorf("缺少内置 Filesystem MCP 运行资源,请在构建机执行 npm --prefix runtime/filesystem run stage")
|
|
}
|
|
c.Command = node
|
|
c.Args = append([]string{entry}, c.Args...)
|
|
c.Transport = "stdio"
|
|
c.WorkingDirectory = filepath.Dir(node)
|
|
return c, nil
|
|
}
|
|
|
|
func validateFilesystemDirs(c ServerConfig) error {
|
|
if c.Transport != "filesystem" {
|
|
return nil
|
|
}
|
|
for _, dir := range c.Args {
|
|
info, err := os.Stat(dir)
|
|
if err != nil || !info.IsDir() {
|
|
return fmt.Errorf("允许目录不存在或不可访问: %s", dir)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddFilesystem adds the bundled official server without machine-specific launch
|
|
// paths. A dedicated data directory is used instead of exposing the user's home.
|
|
func (s *Service) AddFilesystem() (ServerView, error) {
|
|
views, err := s.ListServers()
|
|
if err != nil {
|
|
return ServerView{}, err
|
|
}
|
|
for _, view := range views {
|
|
if view.Config.Transport == "filesystem" {
|
|
return view, nil
|
|
}
|
|
}
|
|
if _, err := filesystemCommand(ServerConfig{}); err != nil {
|
|
return ServerView{}, err
|
|
}
|
|
dir := filepath.Join(filepath.Dir(s.path), "files")
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return ServerView{}, err
|
|
}
|
|
return s.SaveServer(ServerInput{Config: ServerConfig{Name: "Filesystem", Enabled: true, Transport: "filesystem", Args: []string{dir}, TimeoutSeconds: 20}})
|
|
}
|
|
|
|
// TestFilesystem uses a temporary subdirectory and removes only its own probe
|
|
// files. Read/write, rename and denied outside-directory access are real calls.
|
|
func (s *Service) TestFilesystem(ctx context.Context, id string) TestResult {
|
|
start := time.Now()
|
|
result := TestResult{Tools: []ToolInfo{}}
|
|
s.mu.Lock()
|
|
var c ServerConfig
|
|
err := s.loadErr
|
|
if i := s.index(id); i >= 0 {
|
|
c = s.servers[i]
|
|
} else {
|
|
err = fmt.Errorf("MCP 服务不存在")
|
|
}
|
|
s.mu.Unlock()
|
|
if err == nil && c.Transport != "filesystem" {
|
|
err = fmt.Errorf("请选择内置 Filesystem 服务")
|
|
}
|
|
if err == nil {
|
|
c, err = normalize(c)
|
|
}
|
|
if err == nil {
|
|
err = validateFilesystemDirs(c)
|
|
}
|
|
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, Secrets{})
|
|
if err == nil {
|
|
err = probeFilesystem(ctx, c)
|
|
}
|
|
}
|
|
result.OK = err == nil
|
|
result.ElapsedMs = time.Since(start).Milliseconds()
|
|
if err != nil {
|
|
result.Message = fmt.Sprintf("文件测试失败: %v", err)
|
|
} else {
|
|
result.Message = "文件测试通过:创建目录、中文文件写入与读取、搜索、重命名、目录外访问拒绝"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func probeFilesystem(ctx context.Context, c ServerConfig) error {
|
|
// The caller explicitly requested the write test; general connection testing
|
|
// remains discovery-only for every service.
|
|
dir, err := os.MkdirTemp(c.Args[0], ".ituoke-mcp-test-")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
outside, err := os.CreateTemp("", "ituoke-mcp-outside-*.txt")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outside.Close()
|
|
defer os.Remove(outside.Name())
|
|
// If the user deliberately allowed the system temp directory, a probe there
|
|
// cannot verify the boundary, so report that limitation explicitly.
|
|
for _, root := range c.Args {
|
|
rel, _ := filepath.Rel(root, outside.Name())
|
|
if rel != "" && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel) {
|
|
return fmt.Errorf("临时目录也在允许范围内,无法验证目录外访问;请缩小允许目录后重试")
|
|
}
|
|
}
|
|
return withSession(ctx, c, Secrets{}, func(session *mcp.ClientSession) error {
|
|
call := func(name string, args map[string]any, denied bool) (string, error) {
|
|
r, err := session.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if r.IsError != denied {
|
|
return "", fmt.Errorf("%s: 意外的工具执行状态", name)
|
|
}
|
|
var text strings.Builder
|
|
for _, content := range r.Content {
|
|
if t, ok := content.(*mcp.TextContent); ok {
|
|
text.WriteString(t.Text)
|
|
}
|
|
}
|
|
if denied && !strings.Contains(strings.ToLower(text.String()), "access denied") {
|
|
return "", fmt.Errorf("目录外测试未返回权限拒绝: %s", text.String())
|
|
}
|
|
return text.String(), nil
|
|
}
|
|
path := filepath.Join(dir, "资料", "测试.txt")
|
|
if _, err := call("create_directory", map[string]any{"path": filepath.Dir(path)}, false); err != nil {
|
|
return err
|
|
}
|
|
if _, err := call("write_file", map[string]any{"path": path, "content": "iTuoke 文件读写测试 ✓"}, false); err != nil {
|
|
return err
|
|
}
|
|
text, err := call("read_text_file", map[string]any{"path": path}, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text != "iTuoke 文件读写测试 ✓" {
|
|
return fmt.Errorf("读回内容与写入不一致")
|
|
}
|
|
found, err := call("search_files", map[string]any{"path": dir, "pattern": "**/*.txt"}, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !strings.Contains(found, "测试.txt") {
|
|
return fmt.Errorf("搜索未找到测试文件")
|
|
}
|
|
if _, err := call("move_file", map[string]any{"source": path, "destination": filepath.Join(dir, "资料", "已重命名.txt")}, false); err != nil {
|
|
return err
|
|
}
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
return fmt.Errorf("重命名后原路径仍存在")
|
|
}
|
|
moved, err := os.ReadFile(filepath.Join(dir, "资料", "已重命名.txt"))
|
|
if err != nil || string(moved) != "iTuoke 文件读写测试 ✓" {
|
|
return fmt.Errorf("重命名后内容不一致: %v", err)
|
|
}
|
|
if _, err := call("read_text_file", map[string]any{"path": outside.Name()}, true); err != nil {
|
|
return err
|
|
}
|
|
if _, err := call("write_file", map[string]any{"path": outside.Name(), "content": "must be denied"}, true); err != nil {
|
|
return err
|
|
}
|
|
untouched, err := os.ReadFile(outside.Name())
|
|
if err != nil || len(untouched) != 0 {
|
|
return fmt.Errorf("目录外文件被更改: %v", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|