package mcpclient import ( "context" "fmt" "os" "path/filepath" "runtime" "strings" "time" "github.com/ituoke/ituoke/internal/runtimeassets" "github.com/modelcontextprotocol/go-sdk/mcp" ) 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 }) }