Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions pkg/mcp/toolset/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ import (
"github.com/lima-vm/lima/v2/pkg/ptr"
)

const MetaWarnings = "io.lima-vm/warnings"

func (ts *ToolSet) ListDirectory(ctx context.Context,
_ *mcp.CallToolRequest, args msi.ListDirectoryParams,
) (*mcp.CallToolResult, *msi.ListDirectoryResult, error) {
if ts.inst == nil {
return nil, nil, errors.New("instance not registered")
}
guestPath, err := ts.TranslateHostPath(args.Path)
guestPath, warnings, err := ts.TranslateHostPath(args.Path)
if err != nil {
return nil, nil, err
}
Expand All @@ -41,9 +43,13 @@ 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 warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}

func (ts *ToolSet) ReadFile(_ context.Context,
Expand All @@ -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, warnings, err := ts.TranslateHostPath(args.Path)
if err != nil {
return nil, nil, err
}
Expand All @@ -70,12 +76,16 @@ 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 warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}

func (ts *ToolSet) WriteFile(_ context.Context,
Expand All @@ -84,7 +94,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, warnings, err := ts.TranslateHostPath(args.Path)
if err != nil {
return nil, nil, err
}
Expand All @@ -103,12 +113,16 @@ 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 warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}

func (ts *ToolSet) Glob(_ context.Context,
Expand All @@ -124,7 +138,7 @@ func (ts *ToolSet) Glob(_ context.Context,
if args.Path != nil && *args.Path != "" {
pathStr = *args.Path
}
guestPath, err := ts.TranslateHostPath(pathStr)
guestPath, warnings, err := ts.TranslateHostPath(pathStr)
if err != nil {
return nil, nil, err
}
Expand All @@ -139,11 +153,15 @@ 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 warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}

func (ts *ToolSet) SearchFileContent(ctx context.Context,
Expand All @@ -159,7 +177,7 @@ func (ts *ToolSet) SearchFileContent(ctx context.Context,
if args.Path != nil && *args.Path != "" {
pathStr = *args.Path
}
guestPath, err := ts.TranslateHostPath(pathStr)
guestPath, warnings, err := ts.TranslateHostPath(pathStr)
if err != nil {
return nil, nil, err
}
Expand All @@ -176,9 +194,13 @@ 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 warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}
11 changes: 8 additions & 3 deletions pkg/mcp/toolset/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, warnings, err := ts.TranslateHostPath(args.Directory)
if err != nil {
return nil, nil, err
}
Expand All @@ -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 {
Expand All @@ -44,7 +45,11 @@ func (ts *ToolSet) RunShellCommand(ctx context.Context,
res.ExitCode = ptr.Of(st.ExitCode())
}
}
return &mcp.CallToolResult{
callToolRes := &mcp.CallToolResult{
StructuredContent: res,
}, res, nil
}
if warnings != "" {
callToolRes.Meta[MetaWarnings] = warnings
}
return callToolRes, res, nil
}
39 changes: 33 additions & 6 deletions pkg/mcp/toolset/toolset.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pkg/sftp"
Expand Down Expand Up @@ -102,13 +104,38 @@ func (ts *ToolSet) Close() error {
return err
}

func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) {
func (ts *ToolSet) TranslateHostPath(hostPath string) (guestPath, warnings 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)
if !filepath.IsAbs(hostPath) && !strings.HasPrefix(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 {
warnings = fmt.Sprintf("path %q is not under any mounted directory, using as guest path", hostPath)
logrus.Info(warnings)
}
return guestPath, warnings, 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 != ".." {
rel = filepath.ToSlash(rel)
guestPath := path.Join(*mount.MountPoint, rel)
return guestPath, true
}
}

return hostPath, false
}
141 changes: 141 additions & 0 deletions pkg/mcp/toolset/toolset_test.go
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are failing on Windows

Also please squash the commits
https://lima-vm.io/docs/dev/git/#squashing-commits

Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// 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
wantWarnings 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",
wantWarnings: 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",
wantWarnings: 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",
wantWarnings: 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",
wantWarnings: false,
wantErr: false,
},
{
name: "relative path should error",
hostPath: "relative/path",
toolSet: ToolSet{
inst: &limatype.Instance{
Config: &limatype.LimaYAML{
Mounts: []limatype.Mount{
{Location: "/home/user", MountPoint: &mountPoint1},
},
},
},
},
wantGuestPath: "",
wantWarnings: false,
wantErr: true,
},
{
name: "empty path should error",
hostPath: "",
toolSet: ToolSet{
inst: &limatype.Instance{
Config: &limatype.LimaYAML{
Mounts: []limatype.Mount{
{Location: "/home/user", MountPoint: &mountPoint1},
},
},
},
},
wantGuestPath: "",
wantWarnings: false,
wantErr: true,
},
}

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.wantWarnings {
assert.Assert(t, logs != "")
} else {
assert.Equal(t, "", logs)
}
}
})
}
}
Loading