diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index 6ee01a0347e..4f539698b95 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -194,6 +194,7 @@ func newApp() *cobra.Command { newStartAtLoginCommand(), newNetworkCommand(), newCloneCommand(), + newMcpCommand(), ) return rootCmd diff --git a/cmd/limactl/mcp.go b/cmd/limactl/mcp.go new file mode 100644 index 00000000000..aa64afaf323 --- /dev/null +++ b/cmd/limactl/mcp.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "runtime" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/mcp/toolset" + "github.com/lima-vm/lima/v2/pkg/store" + "github.com/lima-vm/lima/v2/pkg/version" +) + +func newMcpCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Model Context Protocol", + GroupID: advancedCommand, + } + cmd.AddCommand( + newMcpServeCommand(), + // TODO: `limactl mcp install-gemini` ? + ) + return cmd +} + +func newMcpServeCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "serve INSTANCE", + Short: "Serve MCP over stdio", + Long: `Serve MCP over stdio. + +Expected to be executed via an AI agent, not by a human`, + Args: WrapArgsError(cobra.MaximumNArgs(1)), + RunE: mcpServeAction, + } + return cmd +} + +func mcpServeAction(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + instName := DefaultInstanceName + if len(args) > 0 { + instName = args[0] + } + inst, err := store.Inspect(instName) + if err != nil { + return err + } + ts, err := toolset.New(inst) + if err != nil { + return err + } + impl := &mcp.Implementation{ + Name: "lima", + Title: "Lima VM, for sandboxing local command executions and file I/O operations", + Version: version.Version, + } + serverOpts := &mcp.ServerOptions{ + Instructions: `This MCP server provides tools for sandboxing local command executions and file I/O operations, +by wrapping them in Lima VM (https://lima-vm.io). + +Use these tools to avoid accidentally executing malicious codes directly on the host. +`, + } + if runtime.GOOS != "linux" { + serverOpts.Instructions += fmt.Sprintf(` + +NOTE: the guest OS of the VM is Linux, while the host OS is %s. +`, strings.ToTitle(runtime.GOOS)) + } + server := mcp.NewServer(impl, serverOpts) + if err = ts.RegisterServer(server); err != nil { + return err + } + transport := mcp.NewStdioTransport() + return server.Run(ctx, transport) +} diff --git a/go.mod b/go.mod index eb42eba91a0..cec961b3fe4 100644 --- a/go.mod +++ b/go.mod @@ -31,9 +31,11 @@ require ( github.com/mdlayher/vsock v1.2.1 // gomodjail:unconfined github.com/miekg/dns v1.1.67 // gomodjail:unconfined github.com/mikefarah/yq/v4 v4.45.1 + github.com/modelcontextprotocol/go-sdk v0.2.0 github.com/nxadm/tail v1.4.11 // gomodjail:unconfined github.com/opencontainers/go-digest v1.0.0 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 + github.com/pkg/sftp v1.13.9 github.com/rjeczalik/notify v0.9.3 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/sethvargo/go-password v0.3.1 @@ -107,13 +109,13 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pkg/sftp v1.13.9 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect // gomodjail:unconfined github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 6458eeb10af..39998dab620 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/mikefarah/yq/v4 v4.45.1 h1:EW+HjKEVa55pUYFJseEHEHdQ0+ulunY+q42zF3M7Za github.com/mikefarah/yq/v4 v4.45.1/go.mod h1:djgN2vD749hpjVNGYTShr5Kmv5LYljhCG3lUTuEe3LM= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modelcontextprotocol/go-sdk v0.2.0 h1:PESNYOmyM1c369tRkzXLY5hHrazj8x9CY1Xu0fLCryM= +github.com/modelcontextprotocol/go-sdk v0.2.0/go.mod h1:0sL9zUKKs2FTTkeCCVnKqbLJTw5TScefPAzojjU459E= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -263,6 +265,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= diff --git a/pkg/mcp/msi/filesystem.go b/pkg/mcp/msi/filesystem.go new file mode 100644 index 00000000000..3c5ee5ace06 --- /dev/null +++ b/pkg/mcp/msi/filesystem.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// Portion of AI prompt texts from: +// - https://github.com/google-gemini/gemini-cli/blob/v0.1.12/docs/tools/file-system.md +// +// SPDX-FileCopyrightText: Copyright 2025 Google LLC + +package msi + +import ( + "io/fs" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +var ListDirectory = &mcp.Tool{ + Name: "list_directory", + Description: `Lists the names of files and subdirectories directly within a specified directory path.`, +} + +type ListDirectoryParams struct { + Path string `json:"path" jsonschema:"The absolute path to the directory to list."` +} + +// ListDirectoryResultEntry is similar to [io/fs.FileInfo]. +type ListDirectoryResultEntry struct { + Name string `json:"name" jsonschema:"base name of the file"` + Size *int64 `json:"size,omitempty" jsonschema:"length in bytes for regular files; system-dependent for others"` + Mode *fs.FileMode `json:"mode,omitempty" jsonschema:"file mode bits"` + ModTime *time.Time `json:"time,omitempty" jsonschema:"modification time"` + IsDir *bool `json:"is_dir,omitempty" jsonschema:"true for a directory"` +} + +type ListDirectoryResult struct { + Entries []ListDirectoryResultEntry `json:"entries" jsonschema:"The directory content entries."` +} + +var ReadFile = &mcp.Tool{ + Name: "read_file", + Description: `Reads and returns the content of a specified file.`, +} + +type ReadFileParams struct { + Path string `json:"path" jsonschema:"The absolute path to the file to read."` + // Offset *int `json:"offset,omitempty" jsonschema:"For text files, the 0-based line number to start reading from. Requires limit to be set."` + // 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."` +} diff --git a/pkg/mcp/msi/msi.go b/pkg/mcp/msi/msi.go new file mode 100644 index 00000000000..13958bbf41c --- /dev/null +++ b/pkg/mcp/msi/msi.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package msi provides the "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 +// +// Notable differences from Gemini CLI's built-in tools: +// - the output format is JSON, not a plain text +// - [RunShellCommandParams].Command is a string slice, not a string +// - [RunShellCommandParams].Directory is a absolute path, not a relative path +// - [RunShellCommandParams].Directory must not be empty +// +// Eventually, this package may be split to a separate repository. +package msi diff --git a/pkg/mcp/msi/shell.go b/pkg/mcp/msi/shell.go new file mode 100644 index 00000000000..e53ed1c85f7 --- /dev/null +++ b/pkg/mcp/msi/shell.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// Portion of AI prompt texts from: +// - https://github.com/google-gemini/gemini-cli/blob/v0.1.12/docs/tools/shell.md +// +// SPDX-FileCopyrightText: Copyright 2025 Google LLC + +package msi + +import "github.com/modelcontextprotocol/go-sdk/mcp" + +var RunShellCommand = &mcp.Tool{ + Name: "run_shell_command", + Description: `Executes a given shell command.`, +} + +type RunShellCommandParams struct { + 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."` + Description string `json:"description,omitempty" jsonschema:"A brief description of the command's purpose, which will be potentially shown to the user."` + 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."` +} + +type RunShellCommandResult struct { + Stdout string `json:"stdout" jsonschema:"Output from the standard output stream."` + Stderr string `json:"stderr" jsonschema:"Output from the standard error stream."` + Error string `json:"error,omitempty" jsonschema:"Any error message reported by the subprocess."` + ExitCode *int `json:"exit_code,omitempty" jsonschema:"Exit code of the command."` +} diff --git a/pkg/mcp/toolset/filesystem.go b/pkg/mcp/toolset/filesystem.go new file mode 100644 index 00000000000..6bea1c99f3c --- /dev/null +++ b/pkg/mcp/toolset/filesystem.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package toolset + +import ( + "context" + "encoding/json" + "io" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/lima-vm/lima/v2/pkg/mcp/msi" + "github.com/lima-vm/lima/v2/pkg/ptr" +) + +func (ts *ToolSet) ListDirectory(ctx context.Context, _ *mcp.ServerSession, + params *mcp.CallToolParamsFor[msi.ListDirectoryParams], +) (*mcp.CallToolResultFor[msi.ListDirectoryResult], error) { + args := params.Arguments + guestPath, err := ts.TranslateHostPath(args.Path) + if err != nil { + return nil, err + } + guestEnts, err := ts.sftp.ReadDirContext(ctx, guestPath) + if err != nil { + return nil, err + } + res := msi.ListDirectoryResult{ + Entries: make([]msi.ListDirectoryResultEntry, len(guestEnts)), + } + for i, f := range guestEnts { + res.Entries[i].Name = f.Name() + res.Entries[i].Size = ptr.Of(f.Size()) + res.Entries[i].Mode = ptr.Of(f.Mode()) + res.Entries[i].ModTime = ptr.Of(f.ModTime()) + res.Entries[i].IsDir = ptr.Of(f.IsDir()) + } + resJ, err := json.Marshal(res) + if err != nil { + return nil, err + } + return &mcp.CallToolResultFor[msi.ListDirectoryResult]{ + Content: []mcp.Content{&mcp.TextContent{Text: string(resJ)}}, + }, nil +} + +func (ts *ToolSet) ReadFile(_ context.Context, _ *mcp.ServerSession, + params *mcp.CallToolParamsFor[msi.ReadFileParams], +) (*mcp.CallToolResultFor[any], error) { + args := params.Arguments + guestPath, err := ts.TranslateHostPath(args.Path) + if err != nil { + return nil, err + } + f, err := ts.sftp.Open(guestPath) + if err != nil { + return nil, err + } + defer f.Close() + const limitBytes = 32 * 1024 * 1024 + lr := io.LimitReader(f, limitBytes) + b, err := io.ReadAll(lr) + if err != nil { + return nil, err + } + return &mcp.CallToolResultFor[any]{ + Content: []mcp.Content{&mcp.TextContent{Text: string(b)}}, + }, nil +} diff --git a/pkg/mcp/toolset/shell.go b/pkg/mcp/toolset/shell.go new file mode 100644 index 00000000000..f2a67acdc0b --- /dev/null +++ b/pkg/mcp/toolset/shell.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package toolset + +import ( + "bytes" + "context" + "encoding/json" + "os/exec" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/lima-vm/lima/v2/pkg/mcp/msi" + "github.com/lima-vm/lima/v2/pkg/ptr" +) + +func (ts *ToolSet) RunShellCommand(ctx context.Context, _ *mcp.ServerSession, + params *mcp.CallToolParamsFor[msi.RunShellCommandParams], +) (*mcp.CallToolResultFor[msi.RunShellCommandResult], error) { + args := params.Arguments + guestPath, err := ts.TranslateHostPath(args.Directory) + if err != nil { + return nil, err + } + cmd := exec.CommandContext(ctx, ts.limactl, + append([]string{"shell", "--workdir=" + guestPath, ts.inst.Name}, + args.Command...)...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmdErr := cmd.Run() + res := msi.RunShellCommandResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + } + if cmdErr == nil { + res.ExitCode = ptr.Of(0) + } else { + res.Error = cmdErr.Error() + if st := cmd.ProcessState; st != nil { + res.ExitCode = ptr.Of(st.ExitCode()) + } + } + resJ, err := json.Marshal(res) + if err != nil { + return nil, err + } + return &mcp.CallToolResultFor[msi.RunShellCommandResult]{ + Content: []mcp.Content{&mcp.TextContent{Text: string(resJ)}}, + }, nil +} diff --git a/pkg/mcp/toolset/toolset.go b/pkg/mcp/toolset/toolset.go new file mode 100644 index 00000000000..739486c21fd --- /dev/null +++ b/pkg/mcp/toolset/toolset.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package toolset + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pkg/sftp" + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/instance/hostname" + "github.com/lima-vm/lima/v2/pkg/mcp/msi" + "github.com/lima-vm/lima/v2/pkg/sshutil" + "github.com/lima-vm/lima/v2/pkg/store" + "github.com/lima-vm/lima/v2/pkg/store/filenames" +) + +func New(inst *store.Instance) (*ToolSet, error) { + if inst.Status != store.StatusRunning { + return nil, fmt.Errorf("expected status of instance %q to be %q, got %q", + inst.Name, store.StatusRunning, inst.Status) + } + if len(inst.Config.Mounts) == 0 { + logrus.Warnf("instance %q has no mount", inst.Name) + } + + limactl, err := os.Executable() + if err != nil { + return nil, err + } + + sftpClient, sftpCmd, err := newSFTPClient(inst) + if err != nil { + return nil, err + } + + ts := &ToolSet{ + inst: inst, + limactl: limactl, + sftp: sftpClient, + sftpCmd: sftpCmd, + } + return ts, nil +} + +func newSFTPClient(inst *store.Instance) (*sftp.Client, *exec.Cmd, error) { + ssh0, ssh0Args, err := sshutil.SSHArguments() + if err != nil { + return nil, nil, err + } + ssh := append([]string{ssh0}, ssh0Args...) + ssh = append(ssh, "-F", + filepath.Join(inst.Dir, filenames.SSHConfig), + hostname.FromInstName(inst.Name)) + + cmd := exec.Command(ssh[0], append(ssh[1:], "-s", "sftp")...) + cmd.Stderr = os.Stderr + w, err := cmd.StdinPipe() + if err != nil { + return nil, nil, err + } + r, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, err + } + if err = cmd.Start(); err != nil { + return nil, nil, err + } + client, err := sftp.NewClientPipe(r, w) + if err != nil { + if cmd != nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + } + return client, cmd, err +} + +type ToolSet struct { + inst *store.Instance + limactl string // needed for `limactl shell --workdir` + + sftp *sftp.Client + sftpCmd *exec.Cmd +} + +func (ts *ToolSet) RegisterServer(server *mcp.Server) error { + mcp.AddTool(server, msi.ListDirectory, ts.ListDirectory) + mcp.AddTool(server, msi.ReadFile, ts.ReadFile) + mcp.AddTool(server, msi.RunShellCommand, ts.RunShellCommand) + return nil +} + +func (ts *ToolSet) Close() error { + var err error + if ts.sftp != nil { + err = errors.Join(err, ts.sftp.Close()) + } + if ts.sftpCmd != nil && ts.sftpCmd.Process != nil { + err = errors.Join(err, ts.sftpCmd.Process.Kill()) + } + return err +} + +func (ts *ToolSet) TranslateHostPath(hostPath string) (string, error) { + if hostPath == "" { + return "", errors.New("path is empty") + } + if !filepath.IsAbs(hostPath) { + return "", fmt.Errorf("expected an absolute path, got a relative path: %q", hostPath) + } + // TODO: make sure that hostPath is mounted + return hostPath, nil +} diff --git a/website/content/en/docs/config/ai/_index.md b/website/content/en/docs/config/ai/_index.md new file mode 100644 index 00000000000..69a43cedeb2 --- /dev/null +++ b/website/content/en/docs/config/ai/_index.md @@ -0,0 +1,6 @@ +--- +title: AI +weight: 90 +--- + +Under construction diff --git a/website/content/en/docs/config/ai/ai-in-lima/_index.md b/website/content/en/docs/config/ai/ai-in-lima/_index.md new file mode 100644 index 00000000000..c6024626158 --- /dev/null +++ b/website/content/en/docs/config/ai/ai-in-lima/_index.md @@ -0,0 +1,6 @@ +--- +title: AI in Lima +weight: 10 +--- + +Under construction diff --git a/website/content/en/docs/config/ai/lima-in-ai/_index.md b/website/content/en/docs/config/ai/lima-in-ai/_index.md new file mode 100644 index 00000000000..555dcc9851e --- /dev/null +++ b/website/content/en/docs/config/ai/lima-in-ai/_index.md @@ -0,0 +1,7 @@ +--- +title: Lima in AI (MCP) +weight: 10 +--- + +Starting with Lima v2.0, Lima provides Model Context Protocol (MCP) tools +for reading, writing, and executing local files using a VM sandbox. diff --git a/website/content/en/docs/config/ai/lima-in-ai/gemini.md b/website/content/en/docs/config/ai/lima-in-ai/gemini.md new file mode 100644 index 00000000000..e6793ca0f75 --- /dev/null +++ b/website/content/en/docs/config/ai/lima-in-ai/gemini.md @@ -0,0 +1,47 @@ +--- +title: Gemini +weight: 20 + +--- + +| ⚡ Requirement | Lima >= 2.0 | +|-------------------|-------------| + +This page describes how to use Lima as an sandbox for [Google Gemini CLI](https://github.com/google-gemini/gemini-cli). + +## Configuration +1. Run the default Lima instance: +```bash +limactl start default +``` + +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" + ] + } + } +} +``` + +1. Modify `.gemini/settings.json` so as to disable Gemini CLI's [built-in tools](https://github.com/google-gemini/gemini-cli/tree/main/docs/tools) +except ones that do not relate to local command execution and file I/O: +```json +{ + "coreTools": ["WebFetchTool", "WebSearchTool", "MemoryTool"] +} +``` + +## Usage +Just run `gemini`. + +The project directory must be mounted inside the VM. i.e., typically it must be under the home directory.