diff --git a/pkg/mcp/toolset/filesystem.go b/pkg/mcp/toolset/filesystem.go index 92d0dc89638..482a156c540 100644 --- a/pkg/mcp/toolset/filesystem.go +++ b/pkg/mcp/toolset/filesystem.go @@ -23,7 +23,7 @@ func (ts *ToolSet) ListDirectory(ctx context.Context, if ts.inst == nil { return nil, nil, errors.New("instance not registered") } - guestPath, err := ts.TranslateHostPath(args.Path) + guestPath, logs, err := ts.TranslateHostPath(args.Path) if err != nil { return nil, nil, err } @@ -41,9 +41,15 @@ func (ts *ToolSet) ListDirectory(ctx context.Context, res.Entries[i].ModTime = ptr.Of(f.ModTime()) res.Entries[i].IsDir = ptr.Of(f.IsDir()) } - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } func (ts *ToolSet) ReadFile(_ context.Context, @@ -52,7 +58,7 @@ func (ts *ToolSet) ReadFile(_ context.Context, if ts.inst == nil { return nil, nil, errors.New("instance not registered") } - guestPath, err := ts.TranslateHostPath(args.Path) + guestPath, logs, err := ts.TranslateHostPath(args.Path) if err != nil { return nil, nil, err } @@ -70,12 +76,18 @@ func (ts *ToolSet) ReadFile(_ context.Context, res := &msi.ReadFileResult{ Content: string(b), } - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ // Gemini: // For text files: The file content, potentially prefixed with a truncation message // (e.g., [File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...). StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } func (ts *ToolSet) WriteFile(_ context.Context, @@ -84,7 +96,7 @@ func (ts *ToolSet) WriteFile(_ context.Context, if ts.inst == nil { return nil, nil, errors.New("instance not registered") } - guestPath, err := ts.TranslateHostPath(args.Path) + guestPath, logs, err := ts.TranslateHostPath(args.Path) if err != nil { return nil, nil, err } @@ -103,12 +115,18 @@ func (ts *ToolSet) WriteFile(_ context.Context, return nil, nil, err } res := &msi.WriteFileResult{} - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ // Gemini: // A success message, e.g., `Successfully overwrote file: /path/to/your/file.txt` // or `Successfully created and wrote to new file: /path/to/new/file.txt.` StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } func (ts *ToolSet) Glob(_ context.Context, @@ -124,7 +142,7 @@ func (ts *ToolSet) Glob(_ context.Context, if args.Path != nil && *args.Path != "" { pathStr = *args.Path } - guestPath, err := ts.TranslateHostPath(pathStr) + guestPath, logs, err := ts.TranslateHostPath(pathStr) if err != nil { return nil, nil, err } @@ -139,11 +157,17 @@ func (ts *ToolSet) Glob(_ context.Context, res := &msi.GlobResult{ Matches: matches, } - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ // Gemini: // A message like: Found 5 file(s) matching "*.ts" within src, sorted by modification time (newest first):\nsrc/file1.ts\nsrc/subdir/file2.ts... StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } func (ts *ToolSet) SearchFileContent(ctx context.Context, @@ -159,7 +183,7 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context, if args.Path != nil && *args.Path != "" { pathStr = *args.Path } - guestPath, err := ts.TranslateHostPath(pathStr) + guestPath, logs, err := ts.TranslateHostPath(pathStr) if err != nil { return nil, nil, err } @@ -176,9 +200,15 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context, res := &msi.SearchFileContentResult{ GitGrepOutput: cmdRes.Stdout, } - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ // Gemini: // A message like: Found 10 matching lines for regex "function\\s+myFunction" in directory src:\nsrc/file1.js:10:function myFunction() {...}\nsrc/subdir/file2.ts:45: function myFunction(param) {...}... StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } diff --git a/pkg/mcp/toolset/shell.go b/pkg/mcp/toolset/shell.go index 9de4e963e6b..552a639dabc 100644 --- a/pkg/mcp/toolset/shell.go +++ b/pkg/mcp/toolset/shell.go @@ -21,7 +21,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context, if ts.inst == nil { return nil, nil, errors.New("instance not registered") } - guestPath, err := ts.TranslateHostPath(args.Directory) + guestPath, logs, err := ts.TranslateHostPath(args.Directory) if err != nil { return nil, nil, err } @@ -36,6 +36,7 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context, Stdout: stdout.String(), Stderr: stderr.String(), } + if cmdErr == nil { res.ExitCode = ptr.Of(0) } else { @@ -44,7 +45,13 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context, res.ExitCode = ptr.Of(st.ExitCode()) } } - return &mcp.CallToolResult{ + callToolRes := &mcp.CallToolResult{ StructuredContent: res, - }, res, nil + } + if logs != "" { + callToolRes.Meta = map[string]any{ + "io.lima-vm/logs": []string{logs}, + } + } + return callToolRes, res, nil } diff --git a/pkg/mcp/toolset/toolset.go b/pkg/mcp/toolset/toolset.go index aa384da6e90..a9aa464f13d 100644 --- a/pkg/mcp/toolset/toolset.go +++ b/pkg/mcp/toolset/toolset.go @@ -11,6 +11,7 @@ import ( "os/exec" "path/filepath" "slices" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/pkg/sftp" @@ -102,13 +103,37 @@ func (ts *ToolSet) Close() error { return err } -func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) { +func (ts *ToolSet) TranslateHostPath(hostPath string) (guestPath, logs string, err error) { if hostPath == "" { - return "", errors.New("path is empty") + return "", "", errors.New("path is empty") } if !filepath.IsAbs(hostPath) { - return "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath) + return "", "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath) } - // TODO: make sure that hostPath is mounted - return hostPath, nil + + guestPath, isMounted := ts.translateToGuestPath(hostPath) + if !isMounted { + logs = fmt.Sprintf("path %q is not under any mounted directory, using as guest path", hostPath) + logrus.Info(logs) + } + return guestPath, logs, nil +} + +func (ts *ToolSet) translateToGuestPath(hostPath string) (string, bool) { + for _, mount := range ts.inst.Config.Mounts { + location := filepath.Clean(mount.Location) + cleanPath := filepath.Clean(hostPath) + + if cleanPath == location { + return *mount.MountPoint, true + } + + rel, err := filepath.Rel(location, cleanPath) + if err == nil && !strings.HasPrefix(rel, "..") && rel != ".." { + guestPath := filepath.Join(*mount.MountPoint, rel) + return guestPath, true + } + } + + return hostPath, false } diff --git a/pkg/mcp/toolset/toolset_test.go b/pkg/mcp/toolset/toolset_test.go new file mode 100644 index 00000000000..ae9021778b3 --- /dev/null +++ b/pkg/mcp/toolset/toolset_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package toolset + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/lima-vm/lima/v2/pkg/limatype" +) + +func TestTranslateHostPath(t *testing.T) { + mountPoint1 := "/mnt/home-user" + mountPoint2 := "/mnt/tmp" + + tests := []struct { + name string + hostPath string + toolSet ToolSet + wantGuestPath string + wantLogs bool + wantErr bool + }{ + { + name: "file in mounted directory", + hostPath: "/home/user/documents/file.txt", + toolSet: ToolSet{ + inst: &limatype.Instance{ + Config: &limatype.LimaYAML{ + Mounts: []limatype.Mount{ + {Location: "/home/user", MountPoint: &mountPoint1}, + }, + }, + }, + }, + wantGuestPath: "/mnt/home-user/documents/file.txt", + wantLogs: false, + wantErr: false, + }, + { + name: "path outside mount - fallback to guest path", + hostPath: "/other/path/file.txt", + toolSet: ToolSet{ + inst: &limatype.Instance{ + Config: &limatype.LimaYAML{ + Mounts: []limatype.Mount{ + {Location: "/home/user", MountPoint: &mountPoint1}, + }, + }, + }, + }, + wantGuestPath: "/other/path/file.txt", + wantLogs: true, + wantErr: false, + }, + { + name: "similar prefix but not under mount", + hostPath: "/home/user2/file.txt", + toolSet: ToolSet{ + inst: &limatype.Instance{ + Config: &limatype.LimaYAML{ + Mounts: []limatype.Mount{ + {Location: "/home/user", MountPoint: &mountPoint1}, + }, + }, + }, + }, + wantGuestPath: "/home/user2/file.txt", + wantLogs: true, + wantErr: false, + }, + { + name: "multiple mounts", + hostPath: "/tmp/myfile", + toolSet: ToolSet{ + inst: &limatype.Instance{ + Config: &limatype.LimaYAML{ + Mounts: []limatype.Mount{ + {Location: "/home/user", MountPoint: &mountPoint1}, + {Location: "/tmp", MountPoint: &mountPoint2}, + }, + }, + }, + }, + wantGuestPath: "/mnt/tmp/myfile", + wantLogs: false, + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, logs, err := test.toolSet.TranslateHostPath(test.hostPath) + if test.wantErr { + assert.Assert(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, test.wantGuestPath, got) + if test.wantLogs { + assert.Assert(t, logs != "") + } else { + assert.Equal(t, "", logs) + } + } + }) + } +}