Skip to content

Commit 689b928

Browse files
authored
Merge pull request #3744 from AkihiroSuda/mcp
Define "MCP Sandbox Interface" and implement `limactl mcp serve`
2 parents 331bd2d + a4ab936 commit 689b928

File tree

16 files changed

+797
-2
lines changed

16 files changed

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

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

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+
// TODO: Offset *int `json:"offset,omitempty" jsonschema:"For text files, the 0-based line number to start reading from. Requires limit to be set."`
48+
// TODO: 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 by mod time, 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+
// TODO: 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)