Skip to content

Commit 42eb5c5

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 9e3334f commit 42eb5c5

File tree

15 files changed

+751
-2
lines changed

15 files changed

+751
-2
lines changed

Makefile

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ help-targets:
8888
@echo '- limactl : Build limactl, and lima'
8989
@echo '- lima : Copy lima, and lima.bat'
9090
@echo '- helpers : Copy nerdctl.lima, apptainer.lima, docker.lima, podman.lima, and kubectl.lima'
91+
# TODO: move CLI plugins to _output/libexec/lima/
92+
@echo '- limactl-plugins : Build limactl-* CLI plugins'
9193
@echo
9294
@echo 'Targets for files in _output/share/lima/:'
9395
@echo '- guestagents : Build guestagents'
@@ -150,7 +152,7 @@ CONFIG_GUESTAGENT_COMPRESS=y
150152

151153
################################################################################
152154
.PHONY: binaries
153-
binaries: limactl helpers guestagents \
155+
binaries: limactl helpers limactl-plugins guestagents \
154156
templates template_experimentals \
155157
documentation create-links-in-doc-dir
156158

@@ -256,6 +258,11 @@ ifeq ($(GOOS),darwin)
256258
codesign -f -v --entitlements vz.entitlements -s - $@
257259
endif
258260

261+
limactl-plugins: _output/bin/limactl-mcp$(exe)
262+
263+
_output/bin/limactl-mcp$(exe): $(call dependencies_for_cmd,limactl-mcp) $$(call force_build,$$@)
264+
$(ENVS_$@) $(GO_BUILD) -o $@ ./cmd/limactl-mcp
265+
259266
DRIVER_INSTALL_DIR := _output/libexec/lima
260267

261268
.PHONY: additional-drivers

cmd/limactl-mcp/main.go

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

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.4.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.41.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.1-0.20250825175020-748c325cec76 // 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.1-0.20250825175020-748c325cec76 h1:mBlBwtDebdDYr+zdop8N62a44g+Nbv7o2KjWyS1deR4=
122+
github.com/google/jsonschema-go v0.2.1-0.20250825175020-748c325cec76/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.4.0 h1:RJ6kFlneHqzTKPzlQqiunrz9nbudSZcYLmLHLsokfoU=
197+
github.com/modelcontextprotocol/go-sdk v0.4.0/go.mod h1:whv0wHnsTphwq7CTiKYHkLtwLC06WMoY2KpO+RB9yXQ=
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/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

pkg/mcp/msi/msi.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// SPDX-FileCopyrightText: Copyright The Lima Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package msi provides the "MCP Sandbox Interface" (tentative)
5+
// that should be reusable for other projects too.
6+
//
7+
// MCP Sandbox Interface defines MCP (Model Context Protocol) tools
8+
// that can be used for reading, writing, and executing local files
9+
// with an appropriate sandboxing technology. The sandboxing technology
10+
// can be more secure and/or efficient than the default tools provided
11+
// by an AI agent.
12+
//
13+
// MCP Sandbox Interface was inspired by Gemini CLI's built-in tools.
14+
// https://github.com/google-gemini/gemini-cli/tree/v0.1.12/docs/tools
15+
//
16+
// Notable differences from Gemini CLI's built-in tools:
17+
// - the output format is JSON, not a plain text
18+
// - the output of [SearchFileContent] always corresponds to `git grep -n --no-index`
19+
// - [RunShellCommandParams].Command is a string slice, not a string
20+
// - [RunShellCommandParams].Directory is a absolute path, not a relative path
21+
// - [RunShellCommandParams].Directory must not be empty
22+
//
23+
// Eventually, this package may be split to a separate repository.
24+
package msi

pkg/mcp/msi/shell.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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/shell.md
6+
//
7+
// SPDX-FileCopyrightText: Copyright 2025 Google LLC
8+
9+
package msi
10+
11+
import "github.com/modelcontextprotocol/go-sdk/mcp"
12+
13+
var RunShellCommand = &mcp.Tool{
14+
Name: "run_shell_command",
15+
Description: `Executes a given shell command.`,
16+
}
17+
18+
type RunShellCommandParams struct {
19+
Command []string `json:"command" jsonschema:"The exact shell command to execute. Defined as a string slice, unlike Gemini's run_shell_command that defines it as a single string."`
20+
Description string `json:"description,omitempty" jsonschema:"A brief description of the command's purpose, which will be potentially shown to the user."`
21+
Directory string `json:"directory" jsonschema:"The absolute directory in which to execute the command. Unlike Gemini's run_shell_command, this must not be a relative path, and must not be empty."`
22+
}
23+
24+
type RunShellCommandResult struct {
25+
Stdout string `json:"stdout" jsonschema:"Output from the standard output stream."`
26+
Stderr string `json:"stderr" jsonschema:"Output from the standard error stream."`
27+
Error string `json:"error,omitempty" jsonschema:"Any error message reported by the subprocess."`
28+
ExitCode *int `json:"exit_code,omitempty" jsonschema:"Exit code of the command."`
29+
}

0 commit comments

Comments
 (0)