From 88734056f710161960e40aea8230684c34b7c685 Mon Sep 17 00:00:00 2001 From: Praful Khanduri Date: Mon, 1 Dec 2025 09:44:10 +0000 Subject: [PATCH 1/3] added validation for hostPath Signed-off-by: Praful Khanduri --- pkg/mcp/toolset/toolset.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/mcp/toolset/toolset.go b/pkg/mcp/toolset/toolset.go index aa384da6e90..73f0f1a5d9f 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" @@ -109,6 +110,25 @@ func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) { if !filepath.IsAbs(hostPath) { return "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath) } - // TODO: make sure that hostPath is mounted + if !ts.isMounted(hostPath) { + return "", fmt.Errorf("path %q is not mounted", hostPath) + } return hostPath, nil } + +func (ts *ToolSet) isMounted(hostPath string) bool { + for _, mount := range ts.inst.Config.Mounts { + location := filepath.Clean(mount.Location) + cleanPath := filepath.Clean(hostPath) + + if cleanPath == location { + return true + } + + rel, err := filepath.Rel(location, cleanPath) + if err == nil && !strings.HasPrefix(rel, "..") && rel != ".." { + return true + } + } + return false +} From c44939c5513eaafeb4f73325f26011bc3770627d Mon Sep 17 00:00:00 2001 From: Praful Khanduri Date: Mon, 1 Dec 2025 09:44:48 +0000 Subject: [PATCH 2/3] added tests for hostPath validation Signed-off-by: Praful Khanduri --- pkg/mcp/toolset/toolset.go | 18 +++--- pkg/mcp/toolset/toolset_test.go | 99 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 pkg/mcp/toolset/toolset_test.go diff --git a/pkg/mcp/toolset/toolset.go b/pkg/mcp/toolset/toolset.go index 73f0f1a5d9f..d5a7be5838e 100644 --- a/pkg/mcp/toolset/toolset.go +++ b/pkg/mcp/toolset/toolset.go @@ -110,25 +110,29 @@ func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) { if !filepath.IsAbs(hostPath) { return "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath) } - if !ts.isMounted(hostPath) { - return "", fmt.Errorf("path %q is not mounted", hostPath) + + guestPath, isMounted := ts.translateToGuestPath(hostPath) + if !isMounted { + logrus.Warnf("path %q is not under any mounted directory, using as guest path", hostPath) } - return hostPath, nil + return guestPath, nil } -func (ts *ToolSet) isMounted(hostPath string) bool { +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 true + return *mount.MountPoint, true } rel, err := filepath.Rel(location, cleanPath) if err == nil && !strings.HasPrefix(rel, "..") && rel != ".." { - return true + guestPath := filepath.Join(*mount.MountPoint, rel) + return guestPath, true } } - return false + + 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..8aad5a4180b --- /dev/null +++ b/pkg/mcp/toolset/toolset_test.go @@ -0,0 +1,99 @@ +// 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 + 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", + 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", + 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", + 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", + wantErr: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, 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) + } + }) + } +} From 6f59ab8341cc4655c5748935e16b3633133aad8f Mon Sep 17 00:00:00 2001 From: Praful Khanduri Date: Wed, 3 Dec 2025 05:38:35 +0000 Subject: [PATCH 3/3] return path translation logs to agent Signed-off-by: Praful Khanduri --- pkg/mcp/toolset/filesystem.go | 60 ++++++++++++++++++++++++--------- pkg/mcp/toolset/shell.go | 13 +++++-- pkg/mcp/toolset/toolset.go | 11 +++--- pkg/mcp/toolset/toolset_test.go | 12 ++++++- 4 files changed, 72 insertions(+), 24 deletions(-) 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 d5a7be5838e..a9aa464f13d 100644 --- a/pkg/mcp/toolset/toolset.go +++ b/pkg/mcp/toolset/toolset.go @@ -103,19 +103,20 @@ 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) } guestPath, isMounted := ts.translateToGuestPath(hostPath) if !isMounted { - logrus.Warnf("path %q is not under any mounted directory, using as guest path", hostPath) + logs = fmt.Sprintf("path %q is not under any mounted directory, using as guest path", hostPath) + logrus.Info(logs) } - return guestPath, nil + return guestPath, logs, nil } func (ts *ToolSet) translateToGuestPath(hostPath string) (string, bool) { diff --git a/pkg/mcp/toolset/toolset_test.go b/pkg/mcp/toolset/toolset_test.go index 8aad5a4180b..ae9021778b3 100644 --- a/pkg/mcp/toolset/toolset_test.go +++ b/pkg/mcp/toolset/toolset_test.go @@ -20,6 +20,7 @@ func TestTranslateHostPath(t *testing.T) { hostPath string toolSet ToolSet wantGuestPath string + wantLogs bool wantErr bool }{ { @@ -35,6 +36,7 @@ func TestTranslateHostPath(t *testing.T) { }, }, wantGuestPath: "/mnt/home-user/documents/file.txt", + wantLogs: false, wantErr: false, }, { @@ -50,6 +52,7 @@ func TestTranslateHostPath(t *testing.T) { }, }, wantGuestPath: "/other/path/file.txt", + wantLogs: true, wantErr: false, }, { @@ -65,6 +68,7 @@ func TestTranslateHostPath(t *testing.T) { }, }, wantGuestPath: "/home/user2/file.txt", + wantLogs: true, wantErr: false, }, { @@ -81,18 +85,24 @@ func TestTranslateHostPath(t *testing.T) { }, }, wantGuestPath: "/mnt/tmp/myfile", + wantLogs: false, wantErr: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - got, err := test.toolSet.TranslateHostPath(test.hostPath) + 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) + } } }) }