Skip to main content
Glama

Sandbox MCP

MIT License
78
  • Linux
  • Apple
sandbox.go13.1 kB
package sandbox import ( "bytes" "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/mark3labs/mcp-go/mcp" "github.com/pottekkat/sandbox-mcp/internal/config" ) // waitForContainer waits for a container to be in running state with a specified timeout func waitForContainer(ctx context.Context, cli *client.Client, containerID string, timeout time.Duration) error { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() timeoutCh := time.After(timeout) for { select { case <-ctx.Done(): return fmt.Errorf("context cancelled while waiting for container to start") case <-timeoutCh: return fmt.Errorf("container did not reach running state within %v", timeout) case <-ticker.C: inspect, err := cli.ContainerInspect(ctx, containerID) if err != nil { return fmt.Errorf("failed to inspect container: %v", err) } if inspect.State != nil && inspect.State.Running { return nil } } } } // NewSandboxTool creates a sandbox tool from a config func NewSandboxTool(sandboxConfig *config.SandboxConfig) mcp.Tool { options := []mcp.ToolOption{ // All tools have a description and an entrypoint mcp.WithDescription(generateSandboxDescription(sandboxConfig)), withEntrypoint(sandboxConfig.ParamEntrypoint(), fmt.Sprintf("Code to be stored in a file named `%s` and executed with the command `%s`.", sandboxConfig.Entrypoint, strings.Join(sandboxConfig.Command, " "))), mcp.WithTitleAnnotation(sandboxConfig.Name()), mcp.WithReadOnlyHintAnnotation(sandboxConfig.Hints.IsReadOnly(sandboxConfig.Mount.ReadOnly, sandboxConfig.Security.ReadOnly)), mcp.WithDestructiveHintAnnotation(sandboxConfig.Hints.IsDestructive()), mcp.WithIdempotentHintAnnotation(sandboxConfig.Hints.IsIdempotent()), mcp.WithOpenWorldHintAnnotation(sandboxConfig.Hints.IsExternalInteraction(sandboxConfig.Security.Network)), } // Add any specific additional files if provided in the config for _, file := range sandboxConfig.Parameters.Files { options = append(options, withFile(file.ParamName(), file.Description, true)) } // Allow adding more files if enabled if sandboxConfig.Parameters.AdditionalFiles { options = append(options, withAdditionalFiles()) } // Return a new tool with the tool name and provided options return mcp.NewTool(sandboxConfig.Id, options...) } // NewSandboxToolHandler creates a handler function for a sandbox tool func NewSandboxToolHandler(sandboxConfig *config.SandboxConfig) func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Return the handler function that will be run when the tool is called return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { // withEntrypoint ToolOption // Get the contents of the entrypoint file from the request entrypointFile := config.SandboxFile{Name: sandboxConfig.Entrypoint} entrypointParam := entrypointFile.ParamName() entrypointContent, ok := request.Params.Arguments[entrypointParam].(string) if !ok || entrypointContent == "" { return nil, fmt.Errorf("%s file is required", sandboxConfig.Entrypoint) } // Create a temporary directory for the entrypoint file dir, err := os.MkdirTemp("", sandboxConfig.Mount.TmpDirPrefix) if err != nil { return nil, fmt.Errorf("failed to create a temporary directory: %v", err) } defer os.RemoveAll(dir) // Write the entrypoint to script file in the temp directory cmdFile := filepath.Join(dir, sandboxConfig.Entrypoint) if err := os.WriteFile(cmdFile, []byte(entrypointContent), sandboxConfig.Mount.ScriptPerms()); err != nil { return nil, fmt.Errorf("failed to write command file: %v", err) } // withFile ToolOption // Get the contents of the required files from the request for _, file := range sandboxConfig.Parameters.Files { paramName := file.ParamName() content, ok := request.Params.Arguments[paramName].(string) if !ok || content == "" { return nil, fmt.Errorf("%s file is required", file.Name) } filePath := filepath.Join(dir, file.Name) if err := os.WriteFile(filePath, []byte(content), sandboxConfig.Mount.ScriptPerms()); err != nil { return nil, fmt.Errorf("failed to write file %s: %v", file.Name, err) } } // withAdditionalFiles ToolOption // Handle additional files if provided if files, ok := request.Params.Arguments["files"].([]any); ok { for _, file := range files { if fileMap, ok := file.(map[string]any); ok { filename := fileMap["filename"].(string) content := fileMap["content"].(string) filePath := filepath.Join(dir, filename) if err := os.WriteFile(filePath, []byte(content), sandboxConfig.Mount.ScriptPerms()); err != nil { return nil, fmt.Errorf("failed to write file %s: %v", filename, err) } } } } // Initialize Docker client cli, err := client.NewClientWithOpts( // Let the client be configured through environment variables client.FromEnv, // Try to support whatever version of the daemon is available client.WithAPIVersionNegotiation(), ) if err != nil { return nil, fmt.Errorf("failed to create Docker client: %v", err) } defer cli.Close() // Create container config containerConfig := &container.Config{ Image: sandboxConfig.Image, Cmd: sandboxConfig.RunCommand(), WorkingDir: sandboxConfig.Mount.WorkDir, User: sandboxConfig.User, Tty: sandboxConfig.Tty(), } // Create host config hostConfig := &container.HostConfig{ Resources: container.Resources{ Memory: sandboxConfig.Resources.Memory * 1024 * 1024, NanoCPUs: int64(sandboxConfig.Resources.CPU * 1e9), PidsLimit: &sandboxConfig.Resources.Processes, Ulimits: []*container.Ulimit{ { Name: "nofile", Soft: sandboxConfig.Resources.Files, Hard: sandboxConfig.Resources.Files, }, }, }, NetworkMode: container.NetworkMode(sandboxConfig.Security.Network), ReadonlyRootfs: sandboxConfig.Security.ReadOnly, Mounts: []mount.Mount{ { Type: mount.TypeBind, Source: dir, Target: sandboxConfig.Mount.WorkDir, ReadOnly: sandboxConfig.Mount.ReadOnly, }, }, CapDrop: sandboxConfig.Security.CapDrop, SecurityOpt: sandboxConfig.Security.SecurityOpt, } // Create execution context with timeout execCtx, cancel := context.WithTimeout(ctx, sandboxConfig.Timeout()) defer cancel() // Create container resp, err := cli.ContainerCreate(execCtx, containerConfig, hostConfig, nil, nil, "") if err != nil { return nil, fmt.Errorf("failed to create container: %v", err) } // Ensure container cleanup defer func() { killCtx, killCancel := context.WithTimeout(context.Background(), sandboxConfig.Timeout()) defer killCancel() _ = cli.ContainerRemove(killCtx, resp.ID, container.RemoveOptions{ Force: true, RemoveVolumes: true, }) }() // Start the container if err := cli.ContainerStart(execCtx, resp.ID, container.StartOptions{}); err != nil { return nil, fmt.Errorf("failed to start container: %v", err) } // Only exec Command if Before was used to start the container if sandboxConfig.ExecCommand() != nil { // Wait for container to be running if err := waitForContainer(execCtx, cli, resp.ID, 10*time.Second); err != nil { return nil, err } execConfig := container.ExecOptions{ Cmd: sandboxConfig.Command, AttachStdout: true, AttachStderr: true, User: sandboxConfig.User, } execResp, err := cli.ContainerExecCreate(execCtx, resp.ID, execConfig) if err != nil { return nil, fmt.Errorf("failed to create exec: %v", err) } // Attach to the exec command to capture output response, err := cli.ContainerExecAttach(execCtx, execResp.ID, container.ExecStartOptions{}) if err != nil { return nil, fmt.Errorf("failed to attach to exec: %v", err) } defer response.Close() // Read stdout and stderr from the exec command stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) if _, err := stdcopy.StdCopy(stdout, stderr, response.Reader); err != nil { return nil, fmt.Errorf("failed to read exec output: %v", err) } // Wait for the exec command to complete for { inspectResp, err := cli.ContainerExecInspect(execCtx, execResp.ID) if err != nil { return nil, fmt.Errorf("failed to inspect exec: %v", err) } if !inspectResp.Running { // Return error if exec command failed if inspectResp.ExitCode != 0 { if stderr.Len() > 0 { return mcp.NewToolResultError(stderr.String()), nil } return mcp.NewToolResultError(fmt.Sprintf("Command failed with exit code %d", inspectResp.ExitCode)), nil } // Include stderr in stdout if present if stderr.Len() > 0 { stdout.WriteString("\nStderr:\n") stdout.Write(stderr.Bytes()) } return mcp.NewToolResultText(stdout.String()), nil } time.Sleep(100 * time.Millisecond) } } // Wait for execution to finish statusCh, errCh := cli.ContainerWait(execCtx, resp.ID, container.WaitConditionNotRunning) select { case err := <-errCh: if err != nil { return nil, fmt.Errorf("error waiting for container: %v", err) } case status := <-statusCh: // Get container logs logs, err := cli.ContainerLogs(execCtx, resp.ID, container.LogsOptions{ ShowStdout: true, ShowStderr: true, Timestamps: false, Follow: false, }) if err != nil { return nil, fmt.Errorf("failed to get logs: %v", err) } defer logs.Close() // Read stdout and stderr stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil { return nil, fmt.Errorf("failed to read logs: %v", err) } // Return error if command failed if status.StatusCode != 0 { return mcp.NewToolResultError(stderr.String()), nil } // Include stderr in stdout if present if stderr.Len() > 0 { stdout.WriteString("\nStderr:\n") stdout.Write(stderr.Bytes()) } return mcp.NewToolResultText(stdout.String()), nil case <-execCtx.Done(): return nil, fmt.Errorf("execution timeout after %d seconds", int(sandboxConfig.Timeout().Seconds())) } return nil, fmt.Errorf("unexpected error: container wait returned no result") } } // generateSandboxDescription creates a comprehensive description of the sandbox environment func generateSandboxDescription(sandboxConfig *config.SandboxConfig) string { // Start with the base description from the config description := sandboxConfig.Description // Ensure the base description ends with a period if it doesn't already if !strings.HasSuffix(description, ".") { description += "." } // Add a space after the description description += " " // Create a more natural description of the sandbox environment with inline pluralization coreText := "cores" if sandboxConfig.Resources.CPU == 1 { coreText = "core" } description += fmt.Sprintf("This sandbox uses the `%s` Docker image, with %d CPU %s, %d MB RAM, and %d processes.", sandboxConfig.Image, sandboxConfig.Resources.CPU, coreText, sandboxConfig.Resources.Memory, sandboxConfig.Resources.Processes) // Add network and filesystem information if sandboxConfig.Security.Network == "none" { description += " It has no network access" } else { description += fmt.Sprintf(" It has %s network access", sandboxConfig.Security.Network) } if sandboxConfig.Mount.ReadOnly || sandboxConfig.Security.ReadOnly { description += " and read-only filesystem permissions." } else { description += " and read-write filesystem permissions." } // Add information about required files if len(sandboxConfig.Parameters.Files) > 0 { if len(sandboxConfig.Parameters.Files) == 1 { file := sandboxConfig.Parameters.Files[0] description += fmt.Sprintf(" It requires a `%s` file", file.Name) if file.Description != "" { description += fmt.Sprintf(" (%s)", file.Description) } } else { description += " It requires the following files:" for i, file := range sandboxConfig.Parameters.Files { if i > 0 { if i == len(sandboxConfig.Parameters.Files)-1 { description += " and" } else { description += "," } } description += fmt.Sprintf(" `%s`", file.Name) if file.Description != "" { description += fmt.Sprintf(" (%s)", file.Description) } } } if sandboxConfig.Parameters.AdditionalFiles { description += " and supports uploading additional files." } else { description += "." } } else if sandboxConfig.Parameters.AdditionalFiles { description += " It supports uploading additional files." } // Add timeout information description += fmt.Sprintf(" The execution is limited to %d seconds.", sandboxConfig.TimeoutRaw) return description }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pottekkat/sandbox-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server