ituoke/internal/mcpclient/filesystem.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
})
}