Skip to content

Commit 89a36bc

Browse files
committed
Define "MCP Sandbox Interface" and implement limactl mcp serve
=== Interface === `pkg/mcp/msi` defines "MCP Sandbox Interface" (tentative) that should be reusable for other projects too. MCP Sandbox Interface defines MCP (Model Context Protocol) tools that can be used for reading, writing, and executing local files with an appropriate sandboxing technology. The sandboxing technology can be more secure and/or efficient than the default tools provided by an AI agent. MCP Sandbox Interface was inspired by Gemini CLI's built-in tools. https://github.com/google-gemini/gemini-cli/tree/v0.1.12/docs/tools === Implementation === `limactl mcp serve INSTANCE` launches an MCP server that implements the MCP Sandbox Interface. Use <https://github.com/modelcontextprotocol/inspector> to play around with the server. ```bash limactl start default brew install mcp-inspector mcp-inspector ``` In the web browser, - Set `Command` to `limactl` - Set `Arguments` to `mcp serve default` - Click `▶️Connect` === Usage with Gemni CLI === 1. Create `.gemini/extensions/lima/gemini-extension.json` as follows: ```json { "name": "lima", "version": "2.0.0", "mcpServers": { "lima": { "command": "limactl", "args": [ "mcp", "serve", "default" ] } } } ``` 2. Modify `.gemini/settings.json` so as to disable Gemini CLI's built-in tools except ones that do not relate to local command execution and file I/O: ```json { "coreTools": ["WebFetchTool", "WebSearchTool", "MemoryTool"] } ``` Signed-off-by: Akihiro Suda <[email protected]>
1 parent cc46912 commit 89a36bc

File tree

16 files changed

+800
-2
lines changed

16 files changed

+800
-2
lines changed

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ help-targets:
112112
@echo '- limactl : Build limactl, and lima'
113113
@echo '- lima : Copy lima, and lima.bat'
114114
@echo '- helpers : Copy nerdctl.lima, apptainer.lima, docker.lima, podman.lima, and kubectl.lima'
115+
# TODO: move CLI plugins to _output/libexec/lima/
116+
@echo '- limactl-plugins : Build limactl-* CLI plugins'
115117
@echo
116118
@echo 'Targets for files in _output/share/lima/:'
117119
@echo '- guestagents : Build guestagents'
@@ -174,7 +176,7 @@ CONFIG_GUESTAGENT_COMPRESS=y
174176

175177
################################################################################
176178
.PHONY: binaries
177-
binaries: limactl helpers guestagents \
179+
binaries: limactl helpers limactl-plugins guestagents \
178180
templates template_experimentals \
179181
documentation create-links-in-doc-dir
180182

@@ -280,6 +282,11 @@ ifeq ($(GOOS),darwin)
280282
codesign -f -v --entitlements vz.entitlements -s - $@
281283
endif
282284

285+
limactl-plugins: _output/bin/limactl-mcp$(exe)
286+
287+
_output/bin/limactl-mcp$(exe): $(call dependencies_for_cmd,limactl-mcp) $$(call force_build,$$@)
288+
$(ENVS_$@) $(GO_BUILD) -o $@ ./cmd/limactl-mcp
289+
283290
DRIVER_INSTALL_DIR := _output/libexec/lima
284291

285292
.PHONY: additional-drivers
@@ -516,6 +523,7 @@ uninstall:
516523
"$(DEST)/bin/lima" \
517524
"$(DEST)/bin/lima$(bat)" \
518525
"$(DEST)/bin/limactl$(exe)" \
526+
"$(DEST)/bin/limactl-mcp$(exe)" \
519527
"$(DEST)/bin/nerdctl.lima" \
520528
"$(DEST)/bin/apptainer.lima" \
521529
"$(DEST)/bin/docker.lima" \

cmd/limactl-mcp/main.go

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package main
5+
6+
import (
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"runtime"
11+
"strings"
12+
13+
"github.com/modelcontextprotocol/go-sdk/mcp"
14+
"github.com/sirupsen/logrus"
15+
"github.com/spf13/cobra"
16+
17+
"github.com/lima-vm/lima/v2/pkg/limactlutil"
18+
"github.com/lima-vm/lima/v2/pkg/mcp/toolset"
19+
"github.com/lima-vm/lima/v2/pkg/version"
20+
)
21+
22+
func main() {
23+
if err := newApp().Execute(); err != nil {
24+
logrus.Fatal(err)
25+
}
26+
}
27+
28+
func newApp() *cobra.Command {
29+
cmd := &cobra.Command{
30+
Use: "limactl-mcp",
31+
Short: "Model Context Protocol plugin for Lima (EXPERIMENTAL)",
32+
Version: strings.TrimPrefix(version.Version, "v"),
33+
SilenceUsage: true,
34+
SilenceErrors: true,
35+
}
36+
cmd.AddCommand(
37+
newMcpInfoCommand(),
38+
newMcpServeCommand(),
39+
// TODO: `limactl-mcp install-gemini` ?
40+
)
41+
return cmd
42+
}
43+
44+
func newServer() *mcp.Server {
45+
impl := &mcp.Implementation{
46+
Name: "lima",
47+
Title: "Lima VM, for sandboxing local command executions and file I/O operations",
48+
Version: version.Version,
49+
}
50+
serverOpts := &mcp.ServerOptions{
51+
Instructions: `This MCP server provides tools for sandboxing local command executions and file I/O operations,
52+
by wrapping them in Lima VM (https://lima-vm.io).
53+
54+
Use these tools to avoid accidentally executing malicious codes directly on the host.
55+
`,
56+
}
57+
if runtime.GOOS != "linux" {
58+
serverOpts.Instructions += fmt.Sprintf(`
59+
60+
NOTE: the guest OS of the VM is Linux, while the host OS is %s.
61+
`, strings.ToTitle(runtime.GOOS))
62+
}
63+
return mcp.NewServer(impl, serverOpts)
64+
}
65+
66+
func newMcpInfoCommand() *cobra.Command {
67+
cmd := &cobra.Command{
68+
Use: "info",
69+
Short: "Show information about the MCP server",
70+
Args: cobra.NoArgs,
71+
RunE: mcpInfoAction,
72+
}
73+
return cmd
74+
}
75+
76+
func mcpInfoAction(cmd *cobra.Command, _ []string) error {
77+
ctx := cmd.Context()
78+
limactl, err := limactlutil.Path()
79+
if err != nil {
80+
return err
81+
}
82+
ts, err := toolset.New(limactl)
83+
if err != nil {
84+
return err
85+
}
86+
server := newServer()
87+
if err = ts.RegisterServer(server); err != nil {
88+
return err
89+
}
90+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
91+
serverSession, err := server.Connect(ctx, serverTransport, nil)
92+
if err != nil {
93+
return err
94+
}
95+
client := mcp.NewClient(&mcp.Implementation{Name: "client"}, nil)
96+
clientSession, err := client.Connect(ctx, clientTransport, nil)
97+
if err != nil {
98+
return err
99+
}
100+
toolsResult, err := clientSession.ListTools(ctx, &mcp.ListToolsParams{})
101+
if err != nil {
102+
return err
103+
}
104+
if err = clientSession.Close(); err != nil {
105+
return err
106+
}
107+
if err = serverSession.Wait(); err != nil {
108+
return err
109+
}
110+
info := &Info{
111+
Tools: toolsResult.Tools,
112+
}
113+
j, err := json.MarshalIndent(info, "", " ")
114+
if err != nil {
115+
return err
116+
}
117+
_, err = fmt.Fprint(cmd.OutOrStdout(), string(j))
118+
return err
119+
}
120+
121+
type Info struct {
122+
Tools []*mcp.Tool `json:"tools"`
123+
}
124+
125+
func newMcpServeCommand() *cobra.Command {
126+
cmd := &cobra.Command{
127+
Use: "serve INSTANCE",
128+
Short: "Serve MCP over stdio",
129+
Long: `Serve MCP over stdio.
130+
131+
Expected to be executed via an AI agent, not by a human`,
132+
Args: cobra.MaximumNArgs(1),
133+
RunE: mcpServeAction,
134+
}
135+
return cmd
136+
}
137+
138+
func mcpServeAction(cmd *cobra.Command, args []string) error {
139+
ctx := cmd.Context()
140+
instName := "default"
141+
if len(args) > 0 {
142+
instName = args[0]
143+
}
144+
limactl, err := limactlutil.Path()
145+
if err != nil {
146+
return err
147+
}
148+
// FIXME: We can not use store.Inspect() here because it requires VM drivers to be compiled in.
149+
// https://github.com/lima-vm/lima/pull/3744#issuecomment-3289274347
150+
inst, err := limactlutil.Inspect(ctx, limactl, instName)
151+
if err != nil {
152+
return err
153+
}
154+
if len(inst.Errors) != 0 {
155+
return errors.Join(inst.Errors...)
156+
}
157+
ts, err := toolset.New(limactl)
158+
if err != nil {
159+
return err
160+
}
161+
server := newServer()
162+
if err = ts.RegisterServer(server); err != nil {
163+
return err
164+
}
165+
if err = ts.RegisterInstance(ctx, inst); err != nil {
166+
return err
167+
}
168+
transport := &mcp.StdioTransport{}
169+
return server.Run(ctx, transport)
170+
}

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@ require (
3131
github.com/mdlayher/vsock v1.2.1 // gomodjail:unconfined
3232
github.com/miekg/dns v1.1.68 // gomodjail:unconfined
3333
github.com/mikefarah/yq/v4 v4.47.2
34+
github.com/modelcontextprotocol/go-sdk v0.5.0
3435
github.com/nxadm/tail v1.4.11 // gomodjail:unconfined
3536
github.com/opencontainers/go-digest v1.0.0
3637
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
38+
github.com/pkg/sftp v1.13.9
3739
github.com/rjeczalik/notify v0.9.3
3840
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
3941
github.com/sethvargo/go-password v0.3.1
@@ -105,13 +107,13 @@ require (
105107
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
106108
github.com/pierrec/lz4/v4 v4.1.22 // indirect
107109
github.com/pkg/errors v0.9.1 // indirect
108-
github.com/pkg/sftp v1.13.9 // indirect
109110
github.com/rivo/uniseg v0.4.7 // indirect
110111
github.com/russross/blackfriday/v2 v2.1.0 // indirect
111112
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
112113
// gomodjail:unconfined
113114
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
114115
github.com/x448/float16 v0.8.4 // indirect
116+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
115117
github.com/yuin/gopher-lua v1.1.1 // indirect
116118
golang.org/x/crypto v0.42.0 // indirect
117119
golang.org/x/mod v0.27.0 // indirect
@@ -135,6 +137,7 @@ require (
135137

136138
require (
137139
github.com/go-ini/ini v1.67.0 // indirect
140+
github.com/google/jsonschema-go v0.2.3 // indirect
138141
github.com/pmezard/go-difflib v1.0.0 // indirect
139142
go.yaml.in/yaml/v2 v2.4.2 // indirect
140143
go.yaml.in/yaml/v3 v3.0.4 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
118118
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
119119
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
120120
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
121+
github.com/google/jsonschema-go v0.2.3 h1:dkP3B96OtZKKFvdrUSaDkL+YDx8Uw9uC4Y+eukpCnmM=
122+
github.com/google/jsonschema-go v0.2.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
121123
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
122124
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
123125
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
@@ -191,6 +193,8 @@ github.com/mikefarah/yq/v4 v4.47.2 h1:Jb5fHlvgK5eeaPbreG9UJs1E5w6l5hUzXjeaY6LTTW
191193
github.com/mikefarah/yq/v4 v4.47.2/go.mod h1:ulYbZUzGJsBDDwO5ohvk/KOW4vW5Iddd/DBeAY1Q09g=
192194
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
193195
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
196+
github.com/modelcontextprotocol/go-sdk v0.5.0 h1:WXRHx/4l5LF5MZboeIJYn7PMFCrMNduGGVapYWFgrF8=
197+
github.com/modelcontextprotocol/go-sdk v0.5.0/go.mod h1:degUj7OVKR6JcYbDF+O99Fag2lTSTbamZacbGTRTSGU=
194198
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
195199
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
196200
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -266,6 +270,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
266270
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
267271
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
268272
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
273+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
274+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
269275
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
270276
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
271277
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=

pkg/limactlutil/limactlutil.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package limactlutil
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"encoding/json"
10+
"fmt"
11+
"os"
12+
"os/exec"
13+
14+
"github.com/lima-vm/lima/v2/pkg/limatype"
15+
)
16+
17+
// Path returns the path to the `limactl` executable.
18+
func Path() (string, error) {
19+
limactl := os.Getenv("LIMACTL")
20+
if limactl == "" {
21+
limactl = "limactl"
22+
}
23+
return exec.LookPath(limactl)
24+
}
25+
26+
// Inspect runs `limactl list --json INST` and parses the output.
27+
func Inspect(ctx context.Context, limactl, instName string) (*limatype.Instance, error) {
28+
var stdout, stderr bytes.Buffer
29+
cmd := exec.CommandContext(ctx, limactl, "list", "--json", instName)
30+
cmd.Stdout = &stdout
31+
cmd.Stderr = &stderr
32+
if err := cmd.Run(); err != nil {
33+
return nil, fmt.Errorf("failed to run %v: stdout=%q, stderr=%q: %w", cmd.Args, stdout.String(), stderr.String(), err)
34+
}
35+
var inst limatype.Instance
36+
if err := json.Unmarshal(stdout.Bytes(), &inst); err != nil {
37+
return nil, err
38+
}
39+
return &inst, nil
40+
}

pkg/mcp/msi/filesystem.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Portion of AI prompt texts from:
5+
// - https://github.com/google-gemini/gemini-cli/blob/v0.1.12/docs/tools/file-system.md
6+
//
7+
// SPDX-FileCopyrightText: Copyright 2025 Google LLC
8+
9+
package msi
10+
11+
import (
12+
"io/fs"
13+
"time"
14+
15+
"github.com/modelcontextprotocol/go-sdk/mcp"
16+
)
17+
18+
var ListDirectory = &mcp.Tool{
19+
Name: "list_directory",
20+
Description: `Lists the names of files and subdirectories directly within a specified directory path.`,
21+
}
22+
23+
type ListDirectoryParams struct {
24+
Path string `json:"path" jsonschema:"The absolute path to the directory to list."`
25+
}
26+
27+
// ListDirectoryResultEntry is similar to [io/fs.FileInfo].
28+
type ListDirectoryResultEntry struct {
29+
Name string `json:"name" jsonschema:"base name of the file"`
30+
Size *int64 `json:"size,omitempty" jsonschema:"length in bytes for regular files; system-dependent for others"`
31+
Mode *fs.FileMode `json:"mode,omitempty" jsonschema:"file mode bits"`
32+
ModTime *time.Time `json:"time,omitempty" jsonschema:"modification time"`
33+
IsDir *bool `json:"is_dir,omitempty" jsonschema:"true for a directory"`
34+
}
35+
36+
type ListDirectoryResult struct {
37+
Entries []ListDirectoryResultEntry `json:"entries" jsonschema:"The directory content entries."`
38+
}
39+
40+
var ReadFile = &mcp.Tool{
41+
Name: "read_file",
42+
Description: `Reads and returns the content of a specified file.`,
43+
}
44+
45+
type ReadFileParams struct {
46+
Path string `json:"path" jsonschema:"The absolute path to the file to read."`
47+
// Offset *int `json:"offset,omitempty" jsonschema:"For text files, the 0-based line number to start reading from. Requires limit to be set."`
48+
// Limit *int `json:"limit,omitempty" jsonschema:"For text files, the maximum number of lines to read. If omitted, reads a default maximum (e.g., 2000 lines) or the entire file if feasible."`
49+
}
50+
51+
var WriteFile = &mcp.Tool{
52+
Name: "write_file",
53+
Description: `Writes content to a specified file. If the file exists, it will be overwritten. If the file doesn't exist, it (and any necessary parent directories) will be created.`,
54+
}
55+
56+
type WriteFileParams struct {
57+
Path string `json:"path" jsonschema:"The absolute path to the file to write to."`
58+
Content string `json:"content" jsonschema:"The content to write into the file."`
59+
}
60+
61+
var Glob = &mcp.Tool{
62+
Name: "glob",
63+
Description: `Finds files matching specific glob patterns (e.g., src/**/*.ts, *.md)`, // Not sorted yet, unlike Gemini
64+
}
65+
66+
type GlobParams struct {
67+
Pattern string `json:"pattern" jsonschema:"The glob pattern to match against (e.g., '*.py', 'src/**/*.js')."`
68+
Path *string `json:"path,omitempty" jsonschema:"The absolute path to the directory to search within. If omitted, searches the tool's root directory."`
69+
// CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema:": Whether the search should be case-sensitive. Defaults to false."`
70+
}
71+
72+
var SearchFileContent = &mcp.Tool{
73+
Name: "search_file_content",
74+
Description: `searches for a regular expression pattern within the content of files in a specified directory. Internally calls 'git grep -n --no-index'.`,
75+
}
76+
77+
type SearchFileContentParams struct {
78+
Pattern string `json:"pattern" jsonschema:"The regular expression (regex) to search for (e.g., 'function\\s+myFunction')."`
79+
Path *string `json:"path,omitempty" jsonschema:"The absolute path to the directory to search within. Defaults to the current working directory."`
80+
Include *string `json:"include,omitempty" jsonschema:"A glob pattern to filter which files are searched (e.g., '*.js', 'src/**/*.{ts,tsx}'). If omitted, searches most files (respecting common ignores)."`
81+
}
82+
83+
// TODO: implement Replace

0 commit comments

Comments
 (0)