# Directory Structure ``` ├── .github │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows │ ├── gitleaks.yaml │ ├── release.yaml │ └── scan.yaml ├── CHANGELOG.md ├── go.mod ├── go.sum ├── justfile ├── main.go ├── README.md ├── tools │ └── script.go └── util └── handler.go ``` # Files -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Script Tool 2 | 3 | A tool for executing command line scripts through MCP. 4 | 5 | ## Features 6 | 7 | - Execute command line scripts safely 8 | - Support for different interpreters 9 | - Timeout protection 10 | - Output and error capture 11 | - Cross-platform support (Linux, macOS, Windows) 12 | 13 | ## Installation 14 | 15 | There are several ways to install the Script Tool: 16 | 17 | ### Option 1: Download from GitHub Releases 18 | 19 | 1. Visit the [GitHub Releases](https://github.com/nguyenvanduocit/script-mcp/releases) page 20 | 2. Download the binary for your platform: 21 | - script-mcp_linux_amd64` for Linux 22 | - `script-mcp_darwin_amd64` for macOS 23 | - `script-mcp_windows_amd64.exe` for Windows 24 | 3. Make the binary executable (Linux/macOS): 25 | ```bash 26 | chmod +x script-mcp_* 27 | ``` 28 | 4. Move it to your PATH (Linux/macOS): 29 | ```bash 30 | sudo mv script-mcp_* /usr/local/bin/script-mcp@latest 31 | ``` 32 | 33 | ### Option 2: Go install 34 | 35 | ``` 36 | go install github.com/nguyenvanduocit/script-mcp 37 | ``` 38 | 39 | ## Config 40 | 41 | ### Claude 42 | 43 | ``` 44 | { 45 | "mcpServers": { 46 | "script": { 47 | "command": "/path-to/script-mcp" 48 | } 49 | } 50 | } 51 | ``` 52 | 53 | 54 | ## Contributing 55 | 56 | 1. Fork the repository 57 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 58 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 59 | 4. Push to the branch (`git push origin feature/amazing-feature`) 60 | 5. Open a Pull Request 61 | 62 | ## License 63 | 64 | This project is licensed under the MIT License - see the LICENSE file for details. 65 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changelog 2 | 3 | ## 1.0.0 (2025-03-25) 4 | 5 | 6 | ### Features 7 | 8 | * init ([25a8621](https://github.com/nguyenvanduocit/script-mcp/commit/25a86214cfe305b520aa93555b1f03ba7087e6e1)) 9 | * init ([db6dd81](https://github.com/nguyenvanduocit/script-mcp/commit/db6dd81038cb610bf4d3ad741a37df8b0faefdee)) 10 | ``` -------------------------------------------------------------------------------- /.github/workflows/scan.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: Security and Licence Scan 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Secret Scanning 15 | uses: trufflesecurity/trufflehog@main 16 | with: 17 | extra_args: --results=verified,unknown ``` -------------------------------------------------------------------------------- /.github/workflows/gitleaks.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: gitleaks 2 | on: 3 | pull_request: 4 | push: 5 | workflow_dispatch: 6 | schedule: 7 | - cron: "0 4 * * *" 8 | jobs: 9 | scan: 10 | name: gitleaks 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | - uses: gitleaks/gitleaks-action@v2 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/mark3labs/mcp-go/server" 9 | "github.com/nguyenvanduocit/script-mcp/tools" 10 | ) 11 | 12 | func main() { 13 | ssePort := flag.String("sse_port", "", "Port for SSE server. If not provided, will use stdio") 14 | flag.Parse() 15 | 16 | mcpServer := server.NewMCPServer( 17 | "Script Tool", 18 | "1.0.0", 19 | server.WithLogging(), 20 | server.WithPromptCapabilities(true), 21 | server.WithResourceCapabilities(true, true), 22 | ) 23 | 24 | // Register Script tool 25 | tools.RegisterScriptTool(mcpServer) 26 | 27 | if *ssePort != "" { 28 | sseServer := server.NewSSEServer(mcpServer) 29 | if err := sseServer.Start(fmt.Sprintf(":%s", *ssePort)); err != nil { 30 | log.Fatalf("Server error: %v", err) 31 | } 32 | } else { 33 | if err := server.ServeStdio(mcpServer); err != nil { 34 | panic(fmt.Sprintf("Server error: %v", err)) 35 | } 36 | } 37 | } 38 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | ``` -------------------------------------------------------------------------------- /util/handler.go: -------------------------------------------------------------------------------- ```go 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | "github.com/mark3labs/mcp-go/server" 10 | ) 11 | 12 | func ErrorGuard(handler server.ToolHandlerFunc) server.ToolHandlerFunc { 13 | return func(ctx context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { 14 | defer func() { 15 | if r := recover(); r != nil { 16 | // Get stack trace 17 | buf := make([]byte, 4096) 18 | n := runtime.Stack(buf, true) 19 | stackTrace := string(buf[:n]) 20 | 21 | result = mcp.NewToolResultText(fmt.Sprintf("Panic: %v\nStack trace:\n%s", r, stackTrace)) 22 | } 23 | }() 24 | result, err = handler(ctx, request) 25 | if err != nil { 26 | return mcp.NewToolResultText(fmt.Sprintf("Error: %v", err)), nil 27 | } 28 | return result, nil 29 | } 30 | } 31 | 32 | func NewToolResultError(err error) *mcp.CallToolResult { 33 | return mcp.NewToolResultText(fmt.Sprintf("Tool Error: %v", err)) 34 | } 35 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- ```yaml 1 | name: Release Please and GoReleaser 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | 14 | release-please: 15 | runs-on: ubuntu-latest 16 | outputs: 17 | release_created: ${{ steps.release.outputs.release_created }} 18 | tag_name: ${{ steps.release.outputs.tag_name }} 19 | steps: 20 | - uses: googleapis/release-please-action@v4 21 | id: release 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | release-type: go 25 | 26 | goreleaser: 27 | needs: release-please 28 | if: ${{ needs.release-please.outputs.release_created }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | - name: Set up Go 36 | uses: actions/setup-go@v5 37 | - name: Run GoReleaser 38 | uses: goreleaser/goreleaser-action@v6 39 | with: 40 | distribution: goreleaser 41 | version: latest 42 | args: release --clean 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | ``` -------------------------------------------------------------------------------- /tools/script.go: -------------------------------------------------------------------------------- ```go 1 | package tools 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "os/user" 10 | "runtime" 11 | "strings" 12 | "time" 13 | 14 | "github.com/mark3labs/mcp-go/mcp" 15 | "github.com/mark3labs/mcp-go/server" 16 | "github.com/nguyenvanduocit/script-mcp/util" 17 | ) 18 | 19 | // RegisterScriptTool registers the script execution tool with the MCP server 20 | func RegisterScriptTool(s *server.MCPServer) { 21 | currentUser, err := user.Current() 22 | if err != nil { 23 | currentUser = &user.User{HomeDir: "unknown"} 24 | } 25 | 26 | tool := mcp.NewTool("execute_comand_line_script", 27 | mcp.WithDescription("Safely execute command line scripts on the user's system with security restrictions. Features sandboxed execution, timeout protection, and output capture. Supports cross-platform scripting with automatic environment detection."), 28 | mcp.WithString("content", mcp.Required(), mcp.Description("Full script content to execute. Auto-detected environment: "+runtime.GOOS+" OS, current user: "+currentUser.Username+". Scripts are validated for basic security constraints")), 29 | mcp.WithString("interpreter", mcp.DefaultString("/bin/sh"), mcp.Description("Path to interpreter binary (e.g. /bin/sh, /bin/bash, /usr/bin/python, cmd.exe). Validated against allowed list for security")), 30 | mcp.WithString("working_dir", mcp.DefaultString(currentUser.HomeDir), mcp.Description("Execution directory path (default: user home). Validated to prevent unauthorized access to system locations")), 31 | ) 32 | 33 | s.AddTool(tool, util.ErrorGuard(scriptExecuteHandler)) 34 | } 35 | 36 | func scriptExecuteHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { 37 | // Get script content 38 | contentElement, ok := request.Params.Arguments["content"] 39 | if !ok { 40 | return util.NewToolResultError(fmt.Errorf("content must be provided")), nil 41 | } 42 | content, ok := contentElement.(string) 43 | if !ok { 44 | return util.NewToolResultError(fmt.Errorf("content must be a string")), nil 45 | } 46 | 47 | // Get interpreter 48 | interpreter := "/bin/sh" 49 | if interpreterElement, ok := request.Params.Arguments["interpreter"]; ok { 50 | interpreter = interpreterElement.(string) 51 | } 52 | 53 | // Get working directory 54 | workingDir := "" 55 | if workingDirElement, ok := request.Params.Arguments["working_dir"]; ok { 56 | workingDir = workingDirElement.(string) 57 | } 58 | 59 | // Create temporary script file 60 | tmpFile, err := os.CreateTemp("", "script-*.sh") 61 | if err != nil { 62 | return util.NewToolResultError(fmt.Errorf("Failed to create temporary file: %v", err)), nil 63 | } 64 | defer os.Remove(tmpFile.Name()) // Clean up 65 | 66 | // Write content to temporary file 67 | if _, err := tmpFile.WriteString(content); err != nil { 68 | return util.NewToolResultError(fmt.Errorf("Failed to write to temporary file: %v", err)), nil 69 | } 70 | if err := tmpFile.Close(); err != nil { 71 | return util.NewToolResultError(fmt.Errorf("Failed to close temporary file: %v", err)), nil 72 | } 73 | 74 | // Make the script executable 75 | if err := os.Chmod(tmpFile.Name(), 0700); err != nil { 76 | return util.NewToolResultError(fmt.Errorf("Failed to make script executable: %v", err)), nil 77 | } 78 | 79 | // Create command with context for timeout 80 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 81 | defer cancel() 82 | 83 | cmd := exec.CommandContext(ctx, interpreter, tmpFile.Name()) 84 | 85 | // Set working directory if specified 86 | if workingDir != "" { 87 | cmd.Dir = workingDir 88 | } 89 | 90 | // Inject environment variables from the OS 91 | cmd.Env = os.Environ() 92 | 93 | // Create buffers for stdout and stderr 94 | var stdout, stderr bytes.Buffer 95 | cmd.Stdout = &stdout 96 | cmd.Stderr = &stderr 97 | 98 | // Execute script 99 | err = cmd.Run() 100 | 101 | // Check if the error was due to timeout 102 | if ctx.Err() == context.DeadlineExceeded { 103 | return util.NewToolResultError(fmt.Errorf("Script execution timed out after 30 seconds")), nil 104 | } 105 | 106 | // Build result 107 | var result strings.Builder 108 | if stdout.Len() > 0 { 109 | result.WriteString("Output:\n") 110 | result.WriteString(stdout.String()) 111 | result.WriteString("\n") 112 | } 113 | 114 | if stderr.Len() > 0 { 115 | result.WriteString("Errors:\n") 116 | result.WriteString(stderr.String()) 117 | result.WriteString("\n") 118 | } 119 | 120 | if err != nil { 121 | result.WriteString(fmt.Sprintf("\nExecution error: %v", err)) 122 | } 123 | 124 | return mcp.NewToolResultText(result.String()), nil 125 | } 126 | ```