├── docs ├── logo.png ├── cursor-config-1.png ├── usage-claude-desktop.md ├── usage-codex-cli.md ├── development.md ├── security.md ├── usage-cursor.md └── usage-vscode.md ├── examples ├── chat-example-1.png ├── chat-example-2.png ├── config-defaults.yaml ├── prompts-example.yaml ├── README.md ├── sandbox-config.yaml └── advanced-templates.yaml ├── tests ├── common │ ├── test_response.json │ ├── test_prompt.json │ └── common.sh ├── exe │ ├── test_exe_config.yaml │ ├── test_exe_timeout.yaml │ ├── test_exe_constraints.sh │ ├── test_exe_empty_file.sh │ ├── test_exe.sh │ └── test_exe_timeout.sh ├── runners │ ├── test_runner_docker.yaml │ ├── test_runner_docker.sh │ ├── test_runner_sandbox_exec.yaml │ └── test_runner_sandbox_exec.sh ├── examples │ ├── test_disk-diagnostics-ro.sh │ ├── test_github-cli-ro.sh │ ├── README.md │ └── test_config.sh ├── run_tests.sh ├── agent │ ├── test_agent_stdin.sh │ ├── tools │ │ └── test_agent.yaml │ └── test_agent_config.sh └── README.md ├── cmd ├── daemon_unix.go ├── daemon_windows.go ├── daemon.go ├── mcp_test.go ├── validate.go ├── mcp.go └── root.go ├── .github ├── workflows │ ├── release.yml │ ├── test.yml │ └── ci.yml └── renovate.json ├── .gitignore ├── pkg ├── common │ ├── panic.go │ ├── prerequisites.go │ ├── prompts.go │ ├── templates.go │ ├── prerequisites_test.go │ ├── downloads.go │ ├── types.go │ └── logging.go ├── command │ ├── platform_unix.go │ ├── runner_firejail_profile.tpl │ ├── platform_windows.go │ ├── runner_sandbox_profile.tpl │ ├── runner.go │ ├── runner_test.go │ ├── runner_firejail_test.go │ └── runner_exec_test.go ├── agent │ ├── config_sample.yaml │ ├── prompts │ │ └── orchestrator.md │ └── cagent_mcp_tool.go ├── utils │ ├── home_test.go │ ├── home.go │ ├── tests_test.go │ ├── tools.go │ └── tools_test.go ├── server │ ├── description.go │ └── server_test.go └── config │ └── tools_config_test.go ├── LICENSE ├── main.go ├── AGENTS.md ├── .goreleaser.yml ├── go.mod └── Makefile /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inercia/MCPShell/HEAD/docs/logo.png -------------------------------------------------------------------------------- /docs/cursor-config-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inercia/MCPShell/HEAD/docs/cursor-config-1.png -------------------------------------------------------------------------------- /examples/chat-example-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inercia/MCPShell/HEAD/examples/chat-example-1.png -------------------------------------------------------------------------------- /examples/chat-example-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inercia/MCPShell/HEAD/examples/chat-example-2.png -------------------------------------------------------------------------------- /docs/usage-claude-desktop.md: -------------------------------------------------------------------------------- 1 | # Configuration File 2 | 3 | **Claude Desktop** from Anthropic can talk to MCPShell running in http mode on localhost. 4 | 5 | ## Prerequisites 6 | 7 | None. 8 | 9 | ## Basic Structure 10 | 11 | The configuration file for **Claude Desktop** is typically located in ~/.config/Claude as **claude_desktop_config.json**. Below are the basic elements required in the file: 12 | 13 | ``` 14 | { 15 | "mcpServers": { 16 | "mcpshell-http": { 17 | "command": "npx", 18 | "args": ["-y", "mcp-remote", "http://localhost:3333/sse"] 19 | } 20 | } 21 | } 22 | 23 | ``` 24 | -------------------------------------------------------------------------------- /tests/common/test_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test_id", 3 | "object": "chat.completion", 4 | "created": 1698433638, 5 | "model": "test-model", 6 | "choices": [ 7 | { 8 | "index": 0, 9 | "message": { 10 | "role": "assistant", 11 | "content": null, 12 | "tool_calls": [ 13 | { 14 | "id": "call_abc123", 15 | "type": "function", 16 | "function": { 17 | "name": "create_test_file", 18 | "arguments": "{\"filename\":\"agent_test_output.txt\",\"content\":\"This is a test file created by the agent.\"}" 19 | } 20 | } 21 | ] 22 | }, 23 | "finish_reason": "tool_calls" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /cmd/daemon_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package root 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | // daemonize forks the process to run in the background 12 | func daemonize() error { 13 | cmd, err := prepareDaemonCommand() 14 | if err != nil { 15 | return err 16 | } 17 | 18 | // Set up process attributes for daemon behavior 19 | cmd.SysProcAttr = &syscall.SysProcAttr{ 20 | Setsid: true, // Create new session 21 | } 22 | 23 | // Start the process 24 | if err := cmd.Start(); err != nil { 25 | return fmt.Errorf("failed to start daemon process: %w", err) 26 | } 27 | 28 | // Exit the parent process 29 | os.Exit(0) 30 | 31 | // This line should never be reached, but Go requires it 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.25' 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: '~> v2' 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /cmd/daemon_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package root 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "syscall" 9 | 10 | "golang.org/x/sys/windows" 11 | ) 12 | 13 | // daemonize forks the process to run in the background 14 | func daemonize() error { 15 | cmd, err := prepareDaemonCommand() 16 | if err != nil { 17 | return err 18 | } 19 | 20 | // On Windows, use DETACHED_PROCESS flag to run in the background 21 | // without being attached to the parent's console. 22 | cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS} 23 | 24 | // Start the process 25 | if err := cmd.Start(); err != nil { 26 | return fmt.Errorf("failed to start daemon process: %w", err) 27 | } 28 | 29 | // Exit the parent process 30 | os.Exit(0) 31 | 32 | // This line should never be reached, but Go requires it 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /cmd/daemon.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func prepareDaemonCommand() (*exec.Cmd, error) { 10 | // Get the current executable path 11 | executable, err := os.Executable() 12 | if err != nil { 13 | return nil, fmt.Errorf("failed to get executable path: %w", err) 14 | } 15 | 16 | // Get current working directory 17 | workDir, err := os.Getwd() 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to get working directory: %w", err) 20 | } 21 | 22 | // Build command arguments, excluding the daemon flag 23 | args := os.Args[1:] 24 | var newArgs []string 25 | for _, arg := range args { 26 | if arg != "--daemon" { 27 | newArgs = append(newArgs, arg) 28 | } 29 | } 30 | 31 | // Create the command 32 | cmd := exec.Command(executable, newArgs...) 33 | cmd.Dir = workDir 34 | cmd.Env = os.Environ() 35 | 36 | return cmd, nil 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries and build directories 2 | /build/ 3 | /bin/ 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | *.out 10 | /blog 11 | MCPShell 12 | 13 | # Test binary, built with `go test -c` 14 | *.test 15 | 16 | # Output of the go coverage tool 17 | *.out 18 | *.cov 19 | coverage.txt 20 | 21 | # Go specific 22 | .DS_Store 23 | /vendor/ 24 | /Godeps/ 25 | 26 | # IDE files 27 | .idea/ 28 | .vscode/* 29 | .cursor/* 30 | *.iml 31 | *.swp 32 | *.swo 33 | *~ 34 | 35 | # Binary distribution files 36 | mcpshell-* 37 | dist/ 38 | 39 | # Debug files 40 | debug 41 | __debug_bin 42 | 43 | # Local configuration files that should not be committed 44 | config.local.yaml 45 | *.local.yaml 46 | .env 47 | .env.* 48 | 49 | # OS specific files 50 | .DS_Store 51 | .DS_Store? 52 | ._* 53 | .Spotlight-V100 54 | .Trashes 55 | ehthumbs.db 56 | Thumbs.db 57 | 58 | # Log files 59 | *.log 60 | logs/ 61 | go.work 62 | go.work.sum 63 | -------------------------------------------------------------------------------- /pkg/common/panic.go: -------------------------------------------------------------------------------- 1 | // Package common provides shared utilities and types used across the MCPShell. 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "runtime/debug" 8 | ) 9 | 10 | // RecoverPanic recovers from a panic and logs it to the provided logger. 11 | // It returns true if a panic was recovered, false otherwise. 12 | // 13 | // This function should be used in deferred calls to catch panics. 14 | func RecoverPanic() bool { 15 | logger := GetLogger() 16 | 17 | if r := recover(); r != nil { 18 | stackTrace := debug.Stack() 19 | 20 | // Log panic information to the logger if provided 21 | if logger != nil { 22 | logger.Error("PANIC RECOVERED: %v", r) 23 | logger.Error("Stack trace:\n%s", stackTrace) 24 | } 25 | 26 | // Always log to stderr for immediate visibility 27 | fmt.Fprintf(os.Stderr, "PANIC RECOVERED: %v\n", r) 28 | 29 | return true 30 | } 31 | 32 | return false 33 | } 34 | -------------------------------------------------------------------------------- /pkg/command/platform_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package command 4 | 5 | import ( 6 | "strings" 7 | 8 | "github.com/inercia/MCPShell/pkg/common" 9 | ) 10 | 11 | // getShellCommandArgs returns the correct arguments for different shell types on Unix systems 12 | func getShellCommandArgs(shell string, command string) (string, []string) { 13 | shellLower := strings.ToLower(shell) 14 | 15 | // Check if this is a PowerShell (might be available on Unix via PowerShell Core) 16 | if strings.Contains(shellLower, "powershell") || 17 | strings.HasSuffix(shellLower, "powershell.exe") || 18 | strings.HasSuffix(shellLower, "pwsh.exe") { 19 | return shell, []string{"-Command", command} 20 | } 21 | 22 | // For Unix-like systems and default fallback 23 | return shell, []string{"-c", command} 24 | } 25 | 26 | // shouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command 27 | func shouldUseUnixTimeoutCommand() bool { 28 | return common.CheckExecutableExists("timeout") 29 | } 30 | -------------------------------------------------------------------------------- /tests/common/test_prompt.json: -------------------------------------------------------------------------------- 1 | { 2 | "messages": [ 3 | { 4 | "role": "system", 5 | "content": "You are a helpful assistant that can create files." 6 | }, 7 | { 8 | "role": "user", 9 | "content": "Please create a file named agent_test_output.txt with the content: This is a test file created by the agent." 10 | } 11 | ], 12 | "tools": [ 13 | { 14 | "type": "function", 15 | "function": { 16 | "name": "create_test_file", 17 | "description": "Create a test file with the given content", 18 | "parameters": { 19 | "type": "object", 20 | "properties": { 21 | "filename": { 22 | "type": "string", 23 | "description": "Name of the file to create" 24 | }, 25 | "content": { 26 | "type": "string", 27 | "description": "Content to write to the file" 28 | } 29 | }, 30 | "required": ["filename", "content"] 31 | } 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alvaro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the entry point for the MCPShell application. 2 | // 3 | // The application implements the Model Context Protocol (MCP) for executing 4 | // command-line tools in a secure and configurable manner, allowing AI-powered 5 | // applications to execute commands on behalf of users. 6 | package main 7 | 8 | import ( 9 | cmdroot "github.com/inercia/MCPShell/cmd" 10 | "github.com/inercia/MCPShell/pkg/common" 11 | ) 12 | 13 | // Build information. These variables are set via ldflags during build. 14 | var ( 15 | version = "dev" 16 | commit = "none" 17 | date = "unknown" 18 | ) 19 | 20 | // main is the entry point of the application. It sets up the panic recovery system 21 | // at the top level and executes the root command, which will process CLI flags and 22 | // execute the selected subcommand. 23 | func main() { 24 | // Setup global panic recovery that will catch any unhandled panics 25 | // and prevent the application from crashing uncleanly 26 | defer func() { 27 | common.RecoverPanic() 28 | }() 29 | 30 | // Set version information from build-time variables 31 | cmdroot.SetVersion(version, commit, date) 32 | 33 | // Execute the root command 34 | cmdroot.Execute() 35 | } 36 | -------------------------------------------------------------------------------- /pkg/common/prerequisites.go: -------------------------------------------------------------------------------- 1 | // Package common provides utility functions and shared components. 2 | package common 3 | 4 | import ( 5 | "os/exec" 6 | "runtime" 7 | ) 8 | 9 | // CheckExecutableExists checks if a command is available in the system PATH. 10 | // 11 | // Parameters: 12 | // - executableName: The name of the executable to check 13 | // 14 | // Returns: 15 | // - true if the executable exists and is accessible, false otherwise 16 | func CheckExecutableExists(executableName string) bool { 17 | _, err := exec.LookPath(executableName) 18 | return err == nil 19 | } 20 | 21 | // CheckOSMatches checks if the current operating system matches the required OS. 22 | // 23 | // Parameters: 24 | // - requiredOS: The required operating system (e.g., "darwin", "linux", "windows") 25 | // Can be empty to skip OS check. 26 | // 27 | // Returns: 28 | // - true if the current OS matches the required OS or if requiredOS is empty, 29 | // false otherwise 30 | func CheckOSMatches(requiredOS string) bool { 31 | // If no OS is specified, consider it a match 32 | if requiredOS == "" { 33 | return true 34 | } 35 | 36 | // Check if the current OS matches the required OS 37 | return runtime.GOOS == requiredOS 38 | } 39 | -------------------------------------------------------------------------------- /docs/usage-codex-cli.md: -------------------------------------------------------------------------------- 1 | # Configuration File 2 | 3 | The **Codex CLI** from OpenAI can talk to MCPShell running in http mode on localhost. By design, **Codex CLI** wants to speak MCP over stdio (which is a mode fully supported by MCPShell). However, if you want to use MCPShell in a centralized manner (as a server), you will need to install mcp-proxy. 4 | 5 | ## Prerequisites 6 | 7 | A working install of **mcp-proxy**. On many systems this can be installed using **npm**. 8 | 9 | ## Basic Structure 10 | 11 | The configuration file for codex is typically located in ~/.codex as **config.toml**. Below are the basic elements required in the file: 12 | 13 | ``` 14 | # (Optional) general preferences 15 | network_access = true 16 | model_reasoning_effort = "medium" 17 | 18 | # MCP servers 19 | [mcp_servers.sse_local] 20 | # Use the proxy as a stdio server that connects to your SSE endpoint 21 | command = "mcp-proxy" 22 | args = ["--transport", "streamablehttp", "http://localhost:3333/sse"] 23 | # If you installed via uv and the command isn't on PATH, use the full path, e.g.: 24 | # command = "/home//.local/bin/mcp-proxy" 25 | # You can also pass headers if your SSE server needs auth: 26 | # env = { API_ACCESS_TOKEN = "your-token" } 27 | ``` 28 | -------------------------------------------------------------------------------- /pkg/agent/config_sample.yaml: -------------------------------------------------------------------------------- 1 | agent: 2 | orchestrator: 3 | model: "gpt-4o" 4 | class: "openai" 5 | name: "orchestrator" 6 | api-key: "${OPENAI_API_KEY}" 7 | api-url: "https://api.openai.com/v1" 8 | prompts: {} 9 | # IMPORTANT: the default system prompts will be used. Override this with your own system prompts if you want to. 10 | # system: 11 | #- "You are an orchestrator agent responsible for planning and coordinating tasks." 12 | #- "Break down complex tasks into steps and delegate tool execution to your tool-runner sub-agent." 13 | #- "Check if tasks are completed and provide clear summaries to the user." 14 | 15 | tool-runner: 16 | model: "gpt-4o-mini" # Can use a lighter/cheaper model for tool execution 17 | class: "openai" 18 | name: "tool-runner" 19 | api-key: "${OPENAI_API_KEY}" 20 | api-url: "https://api.openai.com/v1" 21 | 22 | # If orchestrator/tool-runner are not specified, the first default model is used 23 | models: 24 | - model: "gpt-4o" 25 | class: "openai" 26 | name: "openai" 27 | default: true 28 | api-key: "${OPENAI_API_KEY}" 29 | api-url: "https://api.openai.com/v1" 30 | 31 | - model: "gemma3n" 32 | class: "ollama" 33 | name: "ollama" 34 | -------------------------------------------------------------------------------- /pkg/command/runner_firejail_profile.tpl: -------------------------------------------------------------------------------- 1 | {{ if .CustomProfile }} 2 | {{ .CustomProfile }} 3 | {{ else }} 4 | # Basic profile for firejail 5 | # Applied restrictions based on provided options 6 | 7 | # Network restrictions 8 | {{ if .AllowNetworking }} 9 | # Allow networking 10 | {{ else }} 11 | # Disable networking 12 | net none 13 | {{ end }} 14 | 15 | # File system restrictions 16 | {{ if .AllowUserFolders }} 17 | # Allow access to user folders 18 | {{ else }} 19 | # Deny access to user folders (except home directory structure) 20 | blacklist ${HOME}/Documents 21 | blacklist ${HOME}/Desktop 22 | blacklist ${HOME}/Downloads 23 | blacklist ${HOME}/Pictures 24 | blacklist ${HOME}/Videos 25 | blacklist ${HOME}/Music 26 | {{ end }} 27 | 28 | # Allow specific read folders 29 | {{ range .AllowReadFolders }} 30 | whitelist {{ . }} 31 | read-only {{ . }} 32 | {{ end }} 33 | 34 | # Allow specific read files 35 | {{ range .AllowReadFiles }} 36 | whitelist {{ . }} 37 | read-only {{ . }} 38 | {{ end }} 39 | 40 | # Allow specific write folders 41 | {{ range .AllowWriteFolders }} 42 | whitelist {{ . }} 43 | {{ end }} 44 | 45 | # Allow specific write files 46 | {{ range .AllowWriteFiles }} 47 | whitelist {{ . }} 48 | {{ end }} 49 | 50 | # Always apply basic security features 51 | seccomp 52 | caps.drop all 53 | noroot 54 | {{ end }} -------------------------------------------------------------------------------- /pkg/common/prompts.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // PromptsConfig holds prompt configuration with system and user prompts 4 | type PromptsConfig struct { 5 | System []string `yaml:"system,omitempty"` // System prompts 6 | User []string `yaml:"user,omitempty"` // User prompts 7 | } 8 | 9 | // GetSystemPrompts returns all system prompts joined with newlines 10 | func (p PromptsConfig) GetSystemPrompts() string { 11 | if len(p.System) == 0 { 12 | return "" 13 | } 14 | // Join with newlines 15 | result := "" 16 | for i, prompt := range p.System { 17 | if i > 0 { 18 | result += "\n" 19 | } 20 | result += prompt 21 | } 22 | return result 23 | } 24 | 25 | // GetUserPrompts returns all user prompts joined with newlines 26 | func (p PromptsConfig) GetUserPrompts() string { 27 | if len(p.User) == 0 { 28 | return "" 29 | } 30 | // Join with newlines 31 | result := "" 32 | for i, prompt := range p.User { 33 | if i > 0 { 34 | result += "\n" 35 | } 36 | result += prompt 37 | } 38 | return result 39 | } 40 | 41 | // HasSystemPrompts returns true if there are any system prompts configured 42 | func (p PromptsConfig) HasSystemPrompts() bool { 43 | return len(p.System) > 0 44 | } 45 | 46 | // HasUserPrompts returns true if there are any user prompts configured 47 | func (p PromptsConfig) HasUserPrompts() bool { 48 | return len(p.User) > 0 49 | } 50 | -------------------------------------------------------------------------------- /tests/exe/test_exe_config.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: | 3 | Test configuration for the 'exe' command. 4 | Contains simple tools for testing file creation. 5 | run: 6 | shell: bash 7 | tools: 8 | - name: "create_file" 9 | description: "Create a file with the given path" 10 | params: 11 | filepath: 12 | type: string 13 | description: "Path to the file to create" 14 | required: true 15 | content: 16 | type: string 17 | description: "Content to write to the file" 18 | required: false 19 | constraints: 20 | - "filepath.startsWith('/tmp/')" # Only allow creating files in /tmp for safety 21 | - "filepath.size() > 5" # Ensure we have a reasonable filename 22 | - "!filepath.contains(';')" # Prevent command injection 23 | - "!filepath.contains('&')" # Prevent command injection 24 | - "!filepath.contains('|')" # Prevent command injection 25 | run: 26 | command: | 27 | if [ -n "{{ .content }}" ]; then 28 | echo "{{ .content }}" > {{ .filepath }} 29 | else 30 | echo "Default content for an empty file." > {{ .filepath }} 31 | fi 32 | echo "File created at: {{ .filepath }}" 33 | output: 34 | prefix: | 35 | Operation result: -------------------------------------------------------------------------------- /pkg/utils/home_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestGetHome(t *testing.T) { 10 | home, err := GetHome() 11 | if err != nil { 12 | t.Fatalf("Failed to get home directory: %v", err) 13 | } 14 | 15 | if home == "" { 16 | t.Error("Expected non-empty home directory") 17 | } 18 | 19 | // Verify the directory exists 20 | if _, err := os.Stat(home); os.IsNotExist(err) { 21 | t.Errorf("Home directory does not exist: %s", home) 22 | } 23 | } 24 | 25 | func TestGetMCPShellHome(t *testing.T) { 26 | mcpShellHome, err := GetMCPShellHome() 27 | if err != nil { 28 | t.Fatalf("Failed to get MCPShell home directory: %v", err) 29 | } 30 | 31 | if mcpShellHome == "" { 32 | t.Error("Expected non-empty MCPShell home directory") 33 | } 34 | 35 | // Verify it ends with .mcpshell 36 | expectedSuffix := ".mcpshell" 37 | if filepath.Base(mcpShellHome) != expectedSuffix { 38 | t.Errorf("Expected MCPShell home to end with %s, got %s", expectedSuffix, mcpShellHome) 39 | } 40 | 41 | // Verify it's under the user's home directory 42 | home, err := GetHome() 43 | if err != nil { 44 | t.Fatalf("Failed to get home directory: %v", err) 45 | } 46 | 47 | expectedPath := filepath.Join(home, ".mcpshell") 48 | if mcpShellHome != expectedPath { 49 | t.Errorf("Expected MCPShell home to be %s, got %s", expectedPath, mcpShellHome) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /pkg/command/platform_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package command 4 | 5 | import ( 6 | "runtime" 7 | "strings" 8 | ) 9 | 10 | // getShellCommandArgs returns the correct arguments for different shell types on Windows 11 | func getShellCommandArgs(shell string, command string) (string, []string) { 12 | shellLower := strings.ToLower(shell) 13 | 14 | // Check if this is a cmd shell (Windows) 15 | if strings.Contains(shellLower, "cmd") || 16 | strings.HasSuffix(shellLower, "cmd.exe") || 17 | (shell == "" && runtime.GOOS == "windows") { // Default to cmd on Windows if no shell specified 18 | return shell, []string{"/c", command} 19 | } 20 | 21 | // Check if this is a PowerShell 22 | if strings.Contains(shellLower, "powershell") || 23 | strings.HasSuffix(shellLower, "powershell.exe") || 24 | strings.HasSuffix(shellLower, "pwsh.exe") { 25 | return shell, []string{"-Command", command} 26 | } 27 | 28 | // For WSL, we might have bash or other Unix shells 29 | // For Unix-like systems and default fallback 30 | return shell, []string{"-c", command} 31 | } 32 | 33 | // shouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command 34 | func shouldUseUnixTimeoutCommand() bool { 35 | // On Windows, we don't use Unix-style timeout command even if a 'timeout' command exists 36 | // because Windows 'timeout' is for pausing, not for limiting execution time 37 | return false 38 | } 39 | -------------------------------------------------------------------------------- /pkg/command/runner_sandbox_profile.tpl: -------------------------------------------------------------------------------- 1 | {{ if .CustomProfile }} 2 | {{ .CustomProfile }} 3 | {{ else }} 4 | (version 1) 5 | 6 | (allow default) 7 | 8 | ;; Protect system directories from writes 9 | (deny file-write* (subpath "/bin")) 10 | (deny file-write* (subpath "/sbin")) 11 | (deny file-write* (subpath "/usr/bin")) 12 | (deny file-write* (subpath "/usr/sbin")) 13 | (deny file-write* (subpath "/usr/local/bin")) 14 | (deny file-write* (subpath "/usr/local/sbin")) 15 | (deny file-write* (subpath "/etc")) 16 | (deny file-write* (subpath "/System")) 17 | (deny file-write* (subpath "/Library")) 18 | (deny file-write* (literal "/var/root")) 19 | (deny file-write* (subpath "/var/db")) 20 | (deny file-write* (subpath "/private/etc")) 21 | (deny file-write* (subpath "/private/var/db")) 22 | 23 | {{ if .AllowNetworking }} 24 | (allow network*) 25 | {{ else }} 26 | (deny network*) 27 | {{ end }} 28 | 29 | {{ if .AllowUserFolders }} 30 | (deny file-read* (subpath "/Users")) 31 | {{ else }} 32 | (deny file-read-data (regex "^/Users/.*/(Documents|Desktop|Downloads|Pictures|Movies|Music)")) 33 | {{ end }} 34 | 35 | {{ range .AllowReadFolders }} 36 | (allow file-read* (subpath "{{ . }}")) 37 | {{ end }} 38 | 39 | {{ range .AllowReadFiles }} 40 | (allow file-read* (literal "{{ . }}")) 41 | {{ end }} 42 | 43 | {{ range .AllowWriteFolders }} 44 | (allow file-write* (subpath "{{ . }}")) 45 | {{ end }} 46 | 47 | {{ range .AllowWriteFiles }} 48 | (allow file-write* (literal "{{ . }}")) 49 | {{ end }} 50 | 51 | {{ end }} -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":dependencyDashboard", 5 | ":separateMajorReleases", 6 | ":combinePatchMinorReleases", 7 | ":ignoreUnstable", 8 | ":prImmediately", 9 | ":semanticPrefixFixDepsChoreOthers", 10 | ":updateNotScheduled", 11 | ":automergeDisabled", 12 | ":ignoreModulesAndTests", 13 | "group:monorepos", 14 | "group:recommended", 15 | "helpers:disableTypesNodeMajor", 16 | ":prHourlyLimitNone", 17 | "docker:enableMajor" 18 | ], 19 | "rebaseWhen": "behind-base-branch", 20 | "branchPrefix": "renovate-", 21 | "schedule": "after 02:00 am and before 6:00 am every weekday", 22 | "packageRules": [ 23 | { 24 | "matchDepTypes": [ 25 | "devDependencies" 26 | ], 27 | "matchUpdateTypes": [ 28 | "minor", 29 | "patch", 30 | "pin" 31 | ], 32 | "automerge": true, 33 | "labels": [ 34 | "automerge", 35 | "devDependency" 36 | ] 37 | }, 38 | { 39 | "matchDepTypes": [ 40 | "dependencies" 41 | ], 42 | "matchUpdateTypes": [ 43 | "patch", 44 | "pin" 45 | ], 46 | "automerge": true, 47 | "labels": [ 48 | "automerge", 49 | "dependency" 50 | ] 51 | }, 52 | { 53 | "matchUpdateTypes": [ 54 | "minor", 55 | "patch", 56 | "pin" 57 | ], 58 | "automerge": true 59 | } 60 | ], 61 | "pinDigests": false, 62 | "ignorePaths": [ 63 | "tests/**", 64 | "examples/**" 65 | ], 66 | "prConcurrentLimit": 6 67 | } 68 | -------------------------------------------------------------------------------- /tests/runners/test_runner_docker.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: "Docker runner test configuration" 3 | run: 4 | shell: bash 5 | tools: 6 | - name: "docker_hello" 7 | description: "Simple hello world command running in Alpine container" 8 | run: 9 | command: | 10 | echo "Hello from Docker container" 11 | runners: 12 | - name: docker 13 | requirements: 14 | executables: [docker] 15 | options: 16 | image: "alpine:latest" 17 | output: 18 | format: string 19 | 20 | - name: "docker_with_env" 21 | description: "Echo environment variables in a container" 22 | params: 23 | message: 24 | type: string 25 | description: "Message to echo" 26 | required: true 27 | run: 28 | command: | 29 | echo "Message: ${TEST_MESSAGE}" 30 | env: 31 | - TEST_MESSAGE={{ .message }} 32 | runners: 33 | - name: docker 34 | requirements: 35 | executables: [docker] 36 | options: 37 | image: "alpine:latest" 38 | output: 39 | format: string 40 | 41 | - name: "docker_with_prepare" 42 | description: "Run a command with preparation" 43 | run: 44 | command: | 45 | grep --version 46 | runners: 47 | - name: docker 48 | requirements: 49 | executables: [docker] 50 | options: 51 | image: "alpine:latest" 52 | prepare_command: | 53 | apk add --no-cache grep 54 | output: 55 | format: string -------------------------------------------------------------------------------- /tests/exe/test_exe_timeout.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: | 3 | Test configuration for timeout functionality. 4 | Contains tools with different timeout scenarios. 5 | run: 6 | shell: bash 7 | tools: 8 | - name: "quick_command" 9 | description: "Fast command that completes within timeout" 10 | run: 11 | timeout: "5s" 12 | command: | 13 | echo "Quick command completed successfully" 14 | output: 15 | format: text 16 | 17 | - name: "slow_command_short_timeout" 18 | description: "Slow command that should be killed by timeout" 19 | params: 20 | duration: 21 | type: string 22 | description: "Duration to sleep in seconds" 23 | required: false 24 | default: "10" 25 | run: 26 | timeout: "2s" 27 | command: | 28 | echo "Starting slow command (will timeout)..." 29 | sleep {{ .duration }} 30 | echo "This should never be printed" 31 | runners: 32 | - name: exec 33 | output: 34 | format: text 35 | 36 | - name: "command_with_long_timeout" 37 | description: "Quick command with generous timeout" 38 | run: 39 | timeout: "30s" 40 | command: | 41 | echo "Command with long timeout" 42 | sleep 1 43 | echo "Completed successfully" 44 | output: 45 | format: text 46 | 47 | - name: "no_timeout_command" 48 | description: "Command without timeout specified" 49 | run: 50 | command: | 51 | echo "No timeout specified" 52 | sleep 0.5 53 | echo "Done" 54 | output: 55 | format: text 56 | -------------------------------------------------------------------------------- /tests/examples/test_disk-diagnostics-ro.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for disk-diagnostics-ro.yaml example 3 | # Tests basic disk diagnostic tools 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | ##################################################################################### 11 | # Test configuration 12 | TOOLS_FILE="$SCRIPT_DIR/../../examples/disk-diagnostics-ro.yaml" 13 | TEST_NAME="test_disk_diagnostics_ro" 14 | 15 | ##################################################################################### 16 | # Start the test 17 | 18 | testcase "$TEST_NAME" 19 | 20 | info_blue "Configuration file: $TOOLS_FILE" 21 | separator 22 | 23 | # Make sure we have the CLI binary 24 | check_cli_exists 25 | 26 | ##################################################################################### 27 | # Test 1: storage_overview tool 28 | 29 | info "Test 1: Testing storage_overview tool" 30 | CMD="$CLI_BIN exe --tools $TOOLS_FILE storage_overview" 31 | info "Executing: $CMD" 32 | 33 | OUTPUT=$(eval "$CMD" 2>&1) 34 | RESULT=$? 35 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 1:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 36 | 37 | if [ $RESULT -ne 0 ]; then 38 | failure "Test 1 failed: Command execution failed with exit code: $RESULT" 39 | echo "$OUTPUT" 40 | exit 1 41 | fi 42 | 43 | # Check if output contains expected filesystem information 44 | if echo "$OUTPUT" | grep -q "Filesystem"; then 45 | success "Test 1 passed: storage_overview tool works correctly" 46 | else 47 | failure "Test 1 failed: Output doesn't contain expected filesystem information" 48 | echo "$OUTPUT" 49 | exit 1 50 | fi 51 | 52 | separator 53 | 54 | success "All tests passed for disk-diagnostics-ro.yaml!" 55 | exit 0 56 | 57 | -------------------------------------------------------------------------------- /examples/config-defaults.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: | 3 | Configuration Defaults Example showing how the MCPShell handles 4 | default type values for parameters, allowing for simpler configuration 5 | files with reasonable defaults while supporting explicit type declarations 6 | when needed. 7 | tools: 8 | - name: "hello_world" 9 | description: "Say hello to someone" 10 | params: 11 | name: 12 | # type is omitted, defaults to "string" 13 | description: "Name of the person to greet" 14 | required: true 15 | run: 16 | timeout: "30s" 17 | command: "echo 'Hello, {{ .name }}!'" 18 | 19 | - name: "weather" 20 | description: "Get the weather for a location" 21 | params: 22 | location: 23 | # type is omitted, defaults to "string" 24 | description: "The location to get weather for" 25 | required: true 26 | format: 27 | # type is omitted, defaults to "string" 28 | description: "Output format (simple or detailed)" 29 | run: 30 | timeout: "30s" 31 | command: "echo 'Sunny, 72°F'" 32 | output: 33 | prefix: "The weather in {{ .location }} is" 34 | 35 | - name: "mixed_types" 36 | description: "Example with different parameter types" 37 | params: 38 | text_input: 39 | # type is omitted, defaults to "string" 40 | description: "A text input" 41 | required: true 42 | number_input: 43 | type: number 44 | description: "A numeric input" 45 | required: true 46 | flag: 47 | type: boolean 48 | description: "A boolean flag" 49 | run: 50 | timeout: "30s" 51 | command: "echo '{{ .text_input }}, {{ .number_input }}, {{ .flag }}'" 52 | output: 53 | prefix: "Parameters received:" -------------------------------------------------------------------------------- /pkg/common/templates.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "text/template" 7 | 8 | "github.com/Masterminds/sprig/v3" 9 | ) 10 | 11 | // ProcessTemplate processes a template with the given arguments. 12 | // It uses Go's template engine to substitute variables in the template. 13 | // 14 | // Parameters: 15 | // - text: The template to process 16 | // - args: Map of variable names to their values 17 | // 18 | // Returns: 19 | // - The processed template string with substituted variables 20 | // - An error if template processing fails 21 | func ProcessTemplate(text string, args map[string]interface{}) (string, error) { 22 | // Create a template from the command string 23 | tmpl, err := template.New("command"). 24 | Option("missingkey=zero"). 25 | Funcs(sprig.FuncMap()). 26 | Parse(text) 27 | if err != nil { 28 | return "", err 29 | } 30 | 31 | // Execute the template with the arguments 32 | var buf bytes.Buffer 33 | if err := tmpl.Execute(&buf, args); err != nil { 34 | return "", err 35 | } 36 | 37 | // fix https://github.com/golang/go/issues/24963 38 | res := buf.String() 39 | res = strings.ReplaceAll(res, "", "") 40 | 41 | return res, nil 42 | } 43 | 44 | // ProcessTemplateListFlexible processes a list of templates with the given arguments. 45 | // It uses Go's template engine to substitute variables in the templates. 46 | // If the template processing fails, the original text is added to the result list. 47 | // 48 | // Parameters: 49 | // - list: The list of templates to process 50 | 51 | func ProcessTemplateListFlexible(list []string, args map[string]interface{}) []string { 52 | res := []string{} 53 | for _, item := range list { 54 | processedItem, err := ProcessTemplate(item, args) 55 | if err != nil { 56 | res = append(res, item) 57 | } else { 58 | res = append(res, processedItem) 59 | } 60 | } 61 | return res 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [ main, master ] 6 | push: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | test: 11 | name: Run Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v5 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v6 19 | with: 20 | go-version: '1.25' 21 | 22 | - name: Install dependencies 23 | run: go mod download 24 | 25 | - name: Run tests 26 | shell: bash 27 | run: | 28 | go test -v ./cmd/... ./pkg/... 29 | 30 | - name: Run tests with race detection 31 | shell: bash 32 | run: | 33 | go test -race -v ./cmd/... ./pkg/... 34 | 35 | - name: Check code formatting 36 | shell: bash 37 | run: | 38 | if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then 39 | echo "The following files are not formatted correctly:" 40 | gofmt -l . 41 | exit 1 42 | fi 43 | 44 | - name: Build application 45 | shell: bash 46 | run: | 47 | go build -o mcpshell . 48 | 49 | - name: Validate example configurations 50 | shell: bash 51 | run: | 52 | echo "Validating example YAML configurations..." 53 | find examples -name "*.yaml" -type f | while read file; do 54 | echo "Validating $file..." 55 | ./mcpshell validate --tools "$file" || exit 1 56 | done 57 | echo "All example configurations validated successfully" 58 | 59 | - name: Run end-to-end tests 60 | shell: bash 61 | run: | 62 | make test-e2e 63 | 64 | - name: Run linters 65 | uses: golangci/golangci-lint-action@v8 66 | with: 67 | version: latest 68 | args: --timeout=5m -------------------------------------------------------------------------------- /examples/prompts-example.yaml: -------------------------------------------------------------------------------- 1 | # Example configuration showing how to use prompts in ToolsConfig 2 | # This example demonstrates the new single prompts configuration format 3 | 4 | # Prompts configuration for the tools - this will be provided to clients 5 | prompts: 6 | system: 7 | - "You are a helpful assistant that can create and manage files safely." 8 | - "Always double-check file paths before creating files." 9 | - "Use the available tools to help users with their file management tasks." 10 | user: 11 | - "Please assist me with file operations." 12 | 13 | # MCP server configuration 14 | mcp: 15 | description: | 16 | Example configuration showing how to use prompts with MCPShell tools. 17 | This server provides safe file creation capabilities with proper prompts. 18 | 19 | run: 20 | shell: bash 21 | 22 | tools: 23 | - name: "create_safe_file" 24 | description: "Create a file in a safe location with the given content" 25 | params: 26 | filename: 27 | type: string 28 | description: "Name of the file to create (must be in /tmp or current directory)" 29 | required: true 30 | content: 31 | type: string 32 | description: "Content to write to the file" 33 | required: true 34 | constraints: 35 | - "filename.startsWith('/tmp/') || !filename.contains('/')" # Only allow /tmp or current dir 36 | - "!filename.contains('..')" # Prevent directory traversal 37 | - "filename.size() <= 100" # Limit filename length 38 | - "content.size() <= 1000" # Limit content size 39 | run: 40 | timeout: "30s" 41 | command: | 42 | echo "Creating file: {{ .filename }}" 43 | echo "{{ .content }}" > "{{ .filename }}" 44 | echo "File created successfully: {{ .filename }}" 45 | output: 46 | prefix: "File creation result:" 47 | -------------------------------------------------------------------------------- /tests/exe/test_exe_constraints.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for the mcpshell exe command 3 | # Tests that constraint violations are properly enforced 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | # Test configuration 11 | CONFIG_FILE="$SCRIPT_DIR/test_exe_config.yaml" 12 | TEST_NAME="test_exe_constraints" 13 | 14 | ##################################################################################### 15 | # Start the test 16 | 17 | testcase "$TEST_NAME" 18 | 19 | info_blue "Configuration file: $CONFIG_FILE" 20 | 21 | # Make sure we have the CLI binary 22 | check_cli_exists 23 | 24 | # Test a path that would fail constraint checks 25 | INVALID_PATH="/etc/invalid_test_file.txt" 26 | info "Invalid path (expected to fail): $INVALID_PATH" 27 | separator 28 | 29 | # Command to test with invalid path 30 | CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=$INVALID_PATH" 31 | 32 | info "Executing: $CMD" 33 | OUTPUT=$(eval "$CMD" 2>&1) 34 | RESULT=$? 35 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 36 | 37 | # This should fail, so we expect a non-zero exit code 38 | [ $RESULT -ne 0 ] || fail "Command unexpectedly succeeded with invalid path!" "$OUTPUT" 39 | 40 | success "Command failed as expected. Testing constraint violation for path containing injection character." 41 | 42 | # Test path with shell injection attempt 43 | INJECTION_PATH="/tmp/test;rm -rf /" 44 | CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=\"$INJECTION_PATH\"" 45 | 46 | info "Executing: $CMD" 47 | OUTPUT=$(eval "$CMD" 2>&1) 48 | RESULT=$? 49 | 50 | # This should fail too 51 | [ $RESULT -ne 0 ] || fail "Command unexpectedly succeeded with injection character!" "$OUTPUT" 52 | 53 | success "Test successful! The exe command correctly enforced constraints." 54 | exit 0 -------------------------------------------------------------------------------- /tests/exe/test_exe_empty_file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for the mcpshell exe command 3 | # Tests the creation of an empty file using the create_file tool 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | # Test configuration 11 | CONFIG_FILE="$SCRIPT_DIR/test_exe_config.yaml" 12 | TEST_NAME="test_exe_empty_file_command" 13 | 14 | ##################################################################################### 15 | # Start the test 16 | 17 | testcase "$TEST_NAME" 18 | 19 | info_blue "Configuration file: $CONFIG_FILE" 20 | 21 | # Generate a random test file path 22 | TEST_FILE=$(random_tmpfile "mcpshell_empty_test_file") 23 | info_blue "Test file path: $TEST_FILE" 24 | separator 25 | 26 | # Make sure we have the CLI binary 27 | check_cli_exists 28 | 29 | # Command to test 30 | CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=$TEST_FILE" 31 | OUTPUT=$(eval "$CMD" 2>&1) 32 | RESULT=$? 33 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 34 | 35 | [ $RESULT -eq 0 ] || { 36 | failure "Command execution failed with exit code: $RESULT" 37 | echo "$OUTPUT" 38 | exit 1 39 | } 40 | 41 | # Verify the file was created 42 | [ -f "$TEST_FILE" ] || fail "Test file was not created at $TEST_FILE" 43 | 44 | # Verify the content of the file 45 | CONTENT=$(cat "$TEST_FILE") 46 | DEFAULT_CONTENT="Default content for an empty file." 47 | 48 | [ "$CONTENT" = "$DEFAULT_CONTENT" ] || { 49 | failure "File content does not match expected default content" 50 | info_blue "Expected: $DEFAULT_CONTENT" 51 | info_blue "Actual: $CONTENT" 52 | cleanup_file "$TEST_FILE" 53 | exit 1 54 | } 55 | 56 | success "Empty file test passed, default content was used" 57 | info "Content: $CONTENT" 58 | 59 | # Clean up 60 | cleanup_file "$TEST_FILE" 61 | 62 | exit 0 63 | -------------------------------------------------------------------------------- /tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Source common utilities 4 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 | source "$SCRIPT_DIR/common/common.sh" 6 | 7 | # Test files to run (now in subdirectories) 8 | TEST_FILES=( 9 | "agent/test_agent_config.sh" 10 | "agent/test_agent_info.sh" 11 | "agent/test_agent.sh" 12 | "exe/test_exe.sh" 13 | "exe/test_exe_empty_file.sh" 14 | "exe/test_exe_constraints.sh" 15 | "exe/test_exe_timeout.sh" 16 | "runners/test_runner_docker.sh" 17 | "runners/test_runner_sandbox_exec.sh" 18 | "examples/test_github-cli-ro.sh" 19 | "examples/test_config.sh" 20 | "examples/test_disk-diagnostics-ro.sh" 21 | ) 22 | 23 | echo "===================================" 24 | echo "MCPShell E2E Tests" 25 | echo "===================================" 26 | 27 | # Make test scripts executable 28 | find "$SCRIPT_DIR" -name "*.sh" -exec chmod +x {} \; 29 | 30 | # Track overall test status 31 | PASSED=0 32 | FAILED=0 33 | 34 | export E2E_LOG_FILE="$SCRIPT_DIR/e2e_output.log" 35 | 36 | # Run each test 37 | for test_file in "${TEST_FILES[@]}"; do 38 | echo 39 | warning "Running test: $test_file" 40 | 41 | # Execute the test script 42 | "$SCRIPT_DIR/$test_file" 43 | RESULT=$? 44 | 45 | # Check test result 46 | if [ $RESULT -eq 0 ]; then 47 | success "Test passed: $test_file" 48 | ((PASSED++)) 49 | else 50 | failure "Test failed: $test_file with exit code $RESULT" 51 | ((FAILED++)) 52 | fi 53 | 54 | separator 55 | done 56 | 57 | # Print summary 58 | echo 59 | echo "===================================" 60 | echo "Test Summary:" 61 | success "Tests passed: $PASSED" 62 | [ $FAILED -eq 0 ] || failure "Tests failed: $FAILED" 63 | echo "Total tests: $((PASSED + FAILED))" 64 | echo "===================================" 65 | 66 | # Return non-zero exit code if any tests failed 67 | [ $FAILED -eq 0 ] || exit 1 68 | 69 | exit 0 -------------------------------------------------------------------------------- /pkg/common/prerequisites_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | ) 7 | 8 | func TestCheckOSMatches(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | requiredOS string 12 | expected bool 13 | }{ 14 | { 15 | name: "Empty OS should match", 16 | requiredOS: "", 17 | expected: true, 18 | }, 19 | { 20 | name: "Current OS should match", 21 | requiredOS: runtime.GOOS, 22 | expected: true, 23 | }, 24 | { 25 | name: "Different OS should not match", 26 | requiredOS: "non-existent-os", 27 | expected: false, 28 | }, 29 | { 30 | name: "Case sensitivity - uppercase current OS should not match", 31 | requiredOS: "DARWIN", 32 | expected: false, 33 | }, 34 | } 35 | 36 | for _, tt := range tests { 37 | t.Run(tt.name, func(t *testing.T) { 38 | if result := CheckOSMatches(tt.requiredOS); result != tt.expected { 39 | t.Errorf("CheckOSMatches(%q) = %v, expected %v", tt.requiredOS, result, tt.expected) 40 | } 41 | }) 42 | } 43 | } 44 | 45 | func TestCheckExecutableExists(t *testing.T) { 46 | // Common executables that should exist on most systems 47 | commonExecutables := []string{"sh", "bash"} 48 | 49 | // Non-existent executables 50 | nonExistentExecutables := []string{ 51 | "this-executable-does-not-exist-12345", 52 | "another-non-existent-executable-67890", 53 | } 54 | 55 | // Test common executables (at least one should exist) 56 | foundCommon := false 57 | for _, exe := range commonExecutables { 58 | if CheckExecutableExists(exe) { 59 | foundCommon = true 60 | break 61 | } 62 | } 63 | 64 | if !foundCommon { 65 | t.Errorf("None of the common executables %v were found, at least one should exist", commonExecutables) 66 | } 67 | 68 | // Test non-existent executables 69 | for _, exe := range nonExistentExecutables { 70 | if CheckExecutableExists(exe) { 71 | t.Errorf("Non-existent executable %q was reported as existing", exe) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/agent/test_agent_stdin.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Test STDIN support in agent command 4 | 5 | set -e 6 | 7 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 | PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" 9 | MCPSHELL="$PROJECT_ROOT/build/mcpshell" 10 | 11 | # Source common test functions 12 | source "$SCRIPT_DIR/../common/common.sh" 13 | 14 | test_agent_stdin_simple() { 15 | echo "Testing STDIN input with simple echo..." 16 | 17 | # Create a temporary file with log content 18 | local log_content="ERROR: Connection timeout at 10.0.0.1 19 | WARNING: Retrying connection... 20 | ERROR: Max retries exceeded" 21 | 22 | # Test that STDIN is read when '-' is used 23 | # Since we need an actual agent config, we'll just verify the prompt processing 24 | # by checking that the command doesn't fail with invalid arguments 25 | echo "$log_content" | "$MCPSHELL" agent --help > /dev/null 2>&1 26 | 27 | if [ $? -eq 0 ]; then 28 | echo "✓ STDIN handling does not break agent command" 29 | return 0 30 | else 31 | echo "✗ STDIN handling broke agent command" 32 | return 1 33 | fi 34 | } 35 | 36 | test_agent_stdin_mixed_args() { 37 | echo "Testing STDIN input with mixed arguments..." 38 | 39 | # Test that '-' can be used anywhere in the argument list 40 | local test_input="sample log content" 41 | 42 | # Just verify the help works with various argument patterns 43 | echo "$test_input" | "$MCPSHELL" agent --help > /dev/null 2>&1 44 | 45 | if [ $? -eq 0 ]; then 46 | echo "✓ Mixed arguments with STDIN work correctly" 47 | return 0 48 | else 49 | echo "✗ Mixed arguments with STDIN failed" 50 | return 1 51 | fi 52 | } 53 | 54 | # Run tests 55 | echo "Running agent STDIN tests..." 56 | echo "================================" 57 | 58 | test_agent_stdin_simple 59 | test_agent_stdin_mixed_args 60 | 61 | echo "================================" 62 | echo "All STDIN tests completed!" 63 | -------------------------------------------------------------------------------- /pkg/utils/home.go: -------------------------------------------------------------------------------- 1 | // Package utils provides utility functions for MCPShell 2 | package utils 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "runtime" 9 | ) 10 | 11 | const ( 12 | // MCPShellDirEnv is the environment variable that specifies the configuration directory for MCPShell 13 | MCPShellDirEnv = "MCPSHELL_DIR" 14 | // MCPShellToolsDirEnv is the environment variable that specifies the tools directory for MCPShell 15 | MCPShellToolsDirEnv = "MCPSHELL_TOOLS_DIR" 16 | // MCPShellHome is the name of the configuration directory for MCPShell 17 | MCPShellHome = ".mcpshell" 18 | // MCPShellToolsDir is the name of the tools directory within MCPShell home 19 | MCPShellToolsDir = "tools" 20 | ) 21 | 22 | // GetHome returns the user's home directory in a portable way 23 | func GetHome() (string, error) { 24 | var home string 25 | 26 | if runtime.GOOS == "windows" { 27 | home = os.Getenv("USERPROFILE") 28 | if home == "" { 29 | home = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") 30 | } 31 | } else { 32 | home = os.Getenv("HOME") 33 | } 34 | 35 | if home == "" { 36 | return "", fmt.Errorf("unable to determine home directory") 37 | } 38 | 39 | return home, nil 40 | } 41 | 42 | // GetMCPShellHome returns the MCPShell configuration directory 43 | // This is typically ~/.mcpshell on Unix-like systems or %USERPROFILE%\.mcpshell on Windows 44 | func GetMCPShellHome() (string, error) { 45 | if mcpHome := os.Getenv(MCPShellDirEnv); mcpHome != "" { 46 | return mcpHome, nil 47 | } 48 | 49 | home, err := GetHome() 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | mcpShellHome := filepath.Join(home, MCPShellHome) 55 | return mcpShellHome, nil 56 | } 57 | 58 | // GetMCPShellToolsDir returns the MCPShell tools directory 59 | // This is typically ~/.mcpshell/tools on Unix-like systems or %USERPROFILE%\.mcpshell\tools on Windows 60 | func GetMCPShellToolsDir() (string, error) { 61 | if toolsDir := os.Getenv(MCPShellToolsDirEnv); toolsDir != "" { 62 | return toolsDir, nil 63 | } 64 | 65 | mcpShellHome, err := GetMCPShellHome() 66 | if err != nil { 67 | return "", err 68 | } 69 | 70 | toolsDir := filepath.Join(mcpShellHome, MCPShellToolsDir) 71 | return toolsDir, nil 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | name: Test 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest, macos-latest, windows-latest] 18 | go-version: ['1.25'] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: ${{ matrix.go-version }} 28 | 29 | - name: Cache Go modules 30 | uses: actions/cache@v4 31 | with: 32 | path: | 33 | ~/.cache/go-build 34 | ~/go/pkg/mod 35 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 36 | restore-keys: | 37 | ${{ runner.os }}-go- 38 | 39 | - name: Download dependencies 40 | run: go mod download 41 | 42 | - name: Run tests 43 | shell: bash 44 | run: | 45 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./pkg/... 46 | 47 | - name: Upload coverage to Codecov 48 | uses: codecov/codecov-action@v4 49 | if: matrix.os == 'ubuntu-latest' 50 | with: 51 | files: ./coverage.txt 52 | fail_ci_if_error: false 53 | 54 | lint: 55 | name: Lint 56 | runs-on: ubuntu-latest 57 | 58 | steps: 59 | - name: Checkout code 60 | uses: actions/checkout@v4 61 | 62 | - name: Set up Go 63 | uses: actions/setup-go@v5 64 | with: 65 | go-version: '1.25' 66 | 67 | - name: Run golangci-lint 68 | uses: golangci/golangci-lint-action@v8 69 | with: 70 | version: latest 71 | 72 | build: 73 | name: Build 74 | runs-on: ubuntu-latest 75 | 76 | steps: 77 | - name: Checkout code 78 | uses: actions/checkout@v4 79 | 80 | - name: Set up Go 81 | uses: actions/setup-go@v5 82 | with: 83 | go-version: '1.25' 84 | 85 | - name: Build 86 | run: make build 87 | 88 | - name: Validate examples 89 | run: make validate-examples 90 | -------------------------------------------------------------------------------- /tests/exe/test_exe.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for the mcpshell exe command 3 | # Tests the creation of a file using the create_file tool 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | ##################################################################################### 11 | # Test configuration 12 | TOOLS_FILE="$SCRIPT_DIR/test_exe_config.yaml" 13 | TEST_NAME="test_exe_command" 14 | 15 | ##################################################################################### 16 | # Start the test 17 | 18 | testcase "$TEST_NAME" 19 | 20 | info_blue "Configuration file: $TOOLS_FILE" 21 | 22 | # Generate a random test file path 23 | TEST_FILE=$(random_tmpfile "mcpshell_test_file") 24 | info_blue "Test file path: $TEST_FILE" 25 | separator 26 | 27 | # Make sure the test file doesn't exist yet 28 | [ ! -f "$TEST_FILE" ] || fail "Test file already exists at: $TEST_FILE" 29 | 30 | # Make sure we have the CLI binary 31 | check_cli_exists 32 | 33 | # Command to test 34 | TEST_CONTENT="This is a test file created by the mcpshell exe command test." 35 | CMD="$CLI_BIN exe --tools $TOOLS_FILE create_file filepath=$TEST_FILE content=\"$TEST_CONTENT\"" 36 | info "Executing: $CMD" 37 | 38 | # Run the command 39 | OUTPUT=$(eval "$CMD" 2>&1) 40 | RESULT=$? 41 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 42 | 43 | # Check the result 44 | [ $RESULT -eq 0 ] || { 45 | failure "Command execution failed with exit code: $RESULT" 46 | echo "$OUTPUT" 47 | cleanup_file "$TEST_FILE" 48 | exit 1 49 | } 50 | 51 | # Check if the file was created 52 | [ -f "$TEST_FILE" ] || fail "Test file was not created at: $TEST_FILE" 53 | 54 | # Check file content 55 | CONTENT=$(cat "$TEST_FILE") 56 | [ "$CONTENT" = "$TEST_CONTENT" ] || { 57 | failure "File content doesn't match expected content" 58 | info_blue "Expected: $TEST_CONTENT" 59 | info_blue "Actual: $CONTENT" 60 | cleanup_file "$TEST_FILE" 61 | exit 1 62 | } 63 | 64 | success "Test successful! The exe command correctly created the file." 65 | info "Content: $CONTENT" 66 | 67 | # Cleanup 68 | cleanup_file "$TEST_FILE" 69 | 70 | exit 0 -------------------------------------------------------------------------------- /tests/agent/tools/test_agent.yaml: -------------------------------------------------------------------------------- 1 | prompts: 2 | system: 3 | - "You are a helpful assistant that can create files." 4 | - "Please respond directly to the task requested without unnecessary explanations." 5 | 6 | mcp: 7 | description: "This configuration provides tools for testing the agent functionality." 8 | run: 9 | shell: "bash" 10 | tools: 11 | - name: "create_test_file" 12 | description: "Create a test file with the given content" 13 | params: 14 | filename: 15 | description: "Name of the file to create" 16 | type: "string" 17 | required: true 18 | content: 19 | description: "Content to write to the file" 20 | type: "string" 21 | required: true 22 | run: 23 | runner: "shell" 24 | command: | 25 | # Debug what parameters we actually receive 26 | echo "DEBUG: Received filename='{{ .filename }}'" 27 | echo "DEBUG: Received content='{{ .content }}'" 28 | 29 | # Exit with error if filename is not provided 30 | if [ -z "{{ .filename }}" ]; then 31 | echo "ERROR: filename parameter is required but was empty" 32 | exit 1 33 | else 34 | FILENAME="{{ .filename }}" 35 | fi 36 | 37 | # Write content to file, using a default if content is empty 38 | if [ -z "{{ .content }}" ]; then 39 | echo "WARNING: content parameter was empty, using default content" 40 | CONTENT="Default content created by the MCPShell agent" 41 | else 42 | CONTENT="{{ .content }}" 43 | fi 44 | 45 | # Create the file 46 | echo "${CONTENT}" > "${FILENAME}" 47 | 48 | # Verify the file was created 49 | if [ -f "${FILENAME}" ]; then 50 | echo "SUCCESS: File ${FILENAME} created with content: ${CONTENT}" 51 | echo "File ${FILENAME} created successfully" 52 | else 53 | echo "ERROR: Failed to create file ${FILENAME}" 54 | exit 1 55 | fi 56 | options: 57 | shell: "bash" 58 | output: 59 | format: "text" 60 | template: "File {{ .filename }} has been created with the specified content." -------------------------------------------------------------------------------- /pkg/utils/tests_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestIsOllamaRunning(t *testing.T) { 8 | // This test will check if Ollama is running 9 | // The result will depend on whether Ollama is actually running 10 | running := IsOllamaRunning() 11 | t.Logf("Ollama running: %v", running) 12 | 13 | // We don't assert a specific value since it depends on the environment 14 | // This test is mainly to verify the function doesn't panic 15 | } 16 | 17 | func TestGetAvailableModels(t *testing.T) { 18 | if !IsOllamaRunning() { 19 | t.Skip("Skipping test: Ollama is not running") 20 | } 21 | 22 | models, err := GetAvailableModels() 23 | if err != nil { 24 | t.Fatalf("Failed to get available models: %v", err) 25 | } 26 | 27 | t.Logf("Found %d models", len(models)) 28 | for _, model := range models { 29 | t.Logf("Model: %s (size: %d bytes)", model.Name, model.Size) 30 | } 31 | } 32 | 33 | func TestFindBestAvailableModel(t *testing.T) { 34 | if !IsOllamaRunning() { 35 | t.Skip("Skipping test: Ollama is not running") 36 | } 37 | 38 | modelName, supportsTools, err := FindBestAvailableModel() 39 | if err != nil { 40 | t.Fatalf("Failed to find best available model: %v", err) 41 | } 42 | 43 | t.Logf("Best available model: %s (supports tools: %v)", modelName, supportsTools) 44 | 45 | if modelName == "" { 46 | t.Error("Expected non-empty model name") 47 | } 48 | } 49 | 50 | func TestIsModelToolCapable(t *testing.T) { 51 | tests := []struct { 52 | model string 53 | expected bool 54 | }{ 55 | {"qwen2.5:7b", true}, 56 | {"llama3.1:8b", true}, 57 | {"mistral:7b", true}, 58 | {"gemma:7b", false}, 59 | {"unknown:model", false}, 60 | } 61 | 62 | for _, tt := range tests { 63 | t.Run(tt.model, func(t *testing.T) { 64 | result := IsModelToolCapable(tt.model) 65 | if result != tt.expected { 66 | t.Errorf("IsModelToolCapable(%s) = %v, expected %v", tt.model, result, tt.expected) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestRequireOllamaWithTools(t *testing.T) { 73 | // Create a sub-test that should skip if no tool-capable models are available 74 | t.Run("WithOllamaAndTools", func(t *testing.T) { 75 | modelName := RequireOllamaWithTools(t) 76 | t.Logf("Got tool-capable model: %s", modelName) 77 | 78 | if !IsModelToolCapable(modelName) { 79 | t.Errorf("Model %s should be tool-capable", modelName) 80 | } 81 | }) 82 | } 83 | -------------------------------------------------------------------------------- /pkg/utils/tools.go: -------------------------------------------------------------------------------- 1 | // Package utils provides utility functions for MCPShell 2 | package utils 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | // ResolveToolsFile resolves a tools file path with the following logic: 11 | // 1. If the file path is absolute, use it as-is 12 | // 2. If the file path is relative, first check current directory, then tools directory 13 | // 3. If the file doesn't have an extension, append .yaml 14 | // 4. Return an error if the resolved file doesn't exist 15 | func ResolveToolsFile(toolsFile string) (string, error) { 16 | // Add .yaml extension if no extension is present 17 | if filepath.Ext(toolsFile) == "" { 18 | toolsFile = toolsFile + ".yaml" 19 | } 20 | 21 | // If it's an absolute path, use it directly 22 | if filepath.IsAbs(toolsFile) { 23 | if _, err := os.Stat(toolsFile); err != nil { 24 | if os.IsNotExist(err) { 25 | return "", fmt.Errorf("tools file not found: %s", toolsFile) 26 | } 27 | return "", fmt.Errorf("failed to access tools file %s: %w", toolsFile, err) 28 | } 29 | return toolsFile, nil 30 | } 31 | 32 | // It's a relative path, check current directory first 33 | currentDirPath := toolsFile 34 | if _, err := os.Stat(currentDirPath); err == nil { 35 | // File exists in current directory 36 | absPath, err := filepath.Abs(currentDirPath) 37 | if err != nil { 38 | return "", fmt.Errorf("failed to get absolute path for %s: %w", currentDirPath, err) 39 | } 40 | return absPath, nil 41 | } 42 | 43 | // File not found in current directory, try tools directory 44 | toolsDir, err := GetMCPShellToolsDir() 45 | if err != nil { 46 | return "", fmt.Errorf("failed to get tools directory: %w", err) 47 | } 48 | toolsDirPath := filepath.Join(toolsDir, toolsFile) 49 | 50 | if _, err := os.Stat(toolsDirPath); err == nil { 51 | // File exists in tools directory 52 | return toolsDirPath, nil 53 | } 54 | 55 | // File not found in either location 56 | return "", fmt.Errorf("tools file not found. Searched in:\n%s\n%s", 57 | currentDirPath, toolsDirPath) 58 | } 59 | 60 | // EnsureToolsDir creates the tools directory if it doesn't exist 61 | func EnsureToolsDir() error { 62 | toolsDir, err := GetMCPShellToolsDir() 63 | if err != nil { 64 | return fmt.Errorf("failed to get tools directory: %w", err) 65 | } 66 | 67 | if err := os.MkdirAll(toolsDir, 0755); err != nil { 68 | return fmt.Errorf("failed to create tools directory %s: %w", toolsDir, err) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /cmd/mcp_test.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/inercia/MCPShell/pkg/common" 9 | "github.com/inercia/MCPShell/pkg/server" 10 | ) 11 | 12 | func TestServerStartup(t *testing.T) { 13 | // Initialize logger for test 14 | testLogger, err := common.NewLogger("", "", common.LogLevelNone, false) 15 | if err != nil { 16 | t.Fatalf("Failed to create logger: %v", err) 17 | } 18 | 19 | // Create a temporary config file 20 | tempDir, err := os.MkdirTemp("", "mcpshell-test") 21 | if err != nil { 22 | t.Fatalf("Failed to create temp directory: %v", err) 23 | } 24 | defer func() { 25 | _ = os.RemoveAll(tempDir) 26 | }() 27 | 28 | // Create a basic config file with constraints 29 | testConfigFile := filepath.Join(tempDir, "config.yaml") 30 | configContent := `mcp: 31 | tools: 32 | - name: "hello_world" 33 | description: "Say hello to someone" 34 | params: 35 | name: 36 | type: string 37 | description: "Name of the person to greet" 38 | required: true 39 | constraints: 40 | - "name.size() <= 100" 41 | - "!name.contains('/')" 42 | run: 43 | command: "echo 'Hello, {{ .name }}!'" 44 | 45 | - name: "calculator" 46 | description: "Perform a calculation" 47 | params: 48 | expression: 49 | type: string 50 | description: "The mathematical expression to evaluate" 51 | required: true 52 | constraints: 53 | - "expression.size() <= 200" 54 | - "!expression.matches('.*[;&|].*')" 55 | run: 56 | command: "echo '{{ .expression }}' | bc -l" 57 | output: 58 | prefix: "Result: " 59 | ` 60 | 61 | err = os.WriteFile(testConfigFile, []byte(configContent), 0644) 62 | if err != nil { 63 | t.Fatalf("Failed to write config file: %v", err) 64 | } 65 | 66 | // Create a server instance for testing (we won't actually start it) 67 | srv := server.New(server.Config{ 68 | ConfigFile: testConfigFile, 69 | Logger: testLogger, 70 | Version: "test", 71 | }) 72 | 73 | // Test loading tools (this won't actually start the server) 74 | // Just verify the server can be created successfully 75 | if srv == nil { 76 | t.Fatal("Failed to create server instance") 77 | } 78 | } 79 | 80 | func TestFindToolName(t *testing.T) { 81 | // This test is just a placeholder since findToolByName is now private 82 | // and belongs to the server package 83 | t.Skip("findToolByName functionality is now tested in the server package") 84 | } 85 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # General rules 2 | 3 | - Be casual unless otherwise specified 4 | - Be terse 5 | - Suggest solutions that I didn't think about-anticipate my needs 6 | - Treat me as an expert 7 | - Be accurate and thorough 8 | - Give the answer immediately. Provide detailed explanations and restate my query in your own 9 | words if necessary after giving the answer 10 | - Value good arguments over authorities, the source is irrelevant 11 | - Consider new technologies and contrarian ideas, not just the conventional wisdom 12 | - You may use high levels of speculation or prediction, just flag it for me 13 | - All code you write MUST be fully optimized, while being easy to understand. "Fully optimized" includes: 14 | - maximizing algorithmic big-O efficiency for memory and runtime 15 | - following proper style conventions for the code language (e.g. maximizing code reuse (DRY)) 16 | - no extra code beyond what is absolutely necessary to solve the problem the user provides (i.e. no technical debt) 17 | - Discuss safety only when it's crucial and non-obvious 18 | - If your content policy is an issue, provide the closest acceptable response and explain the content policy issue afterward 19 | - Cite sources whenever possible at the end, not inline 20 | - No need to mention your knowledge cutoff 21 | - No need to disclose you're an AI 22 | - Use @terminal when answering questions about Git. 23 | - Alway fix unit tests after refactorings or changes in the classes that are tested. 24 | - Please respect my formatting preferences when you provide code. 25 | - Please respect all code comments, they're usually there for a reason. Remove them ONLY if they're completely irrelevant after a code change. if unsure, do not remove the comment. 26 | - Split into multiple responses if one response isn't enough to answer the question. 27 | - If I ask for adjustments to code I have provided you, do not repeat all of my code unnecessarily. 28 | Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. 29 | - Multiple code blocks are ok. 30 | 31 | ## Source code versioning 32 | 33 | - It is ok to commit changes or create tags if the user asks for it. 34 | - Do NEVER push changes to any remote. Suggest the command to the user. Let him/her do that. 35 | 36 | ## Other considerations 37 | 38 | - Executables are stored in the "/build" folder. If you build something by yourself, 39 | please place it there. 40 | - After any signicant changes in the code, please run `make format` in order to 41 | indent all the code. 42 | 43 | ## Tool 44 | 45 | - Always try to use the MCP tools available instead of running command tools in a terminal. 46 | Feel free to run any MCP tool available. 47 | -------------------------------------------------------------------------------- /tests/common/common.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Common utilities for MCPShell E2E tests 3 | 4 | ##################################################################################### 5 | 6 | # Set common script directory for relative paths 7 | COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 8 | CLI_BIN="$COMMON_DIR/../../build/mcpshell" 9 | 10 | # ANSI color codes 11 | RED='\033[0;31m' 12 | GREEN='\033[0;32m' 13 | YELLOW='\033[0;33m' 14 | BLUE='\033[0;34m' 15 | NC='\033[0m' # No Color 16 | 17 | # Print a separator line 18 | separator() { 19 | echo -e "${YELLOW}--------------------------------------------------------${NC}" 20 | } 21 | 22 | # Print test case header 23 | testcase() { 24 | local name="$1" 25 | separator 26 | echo -e "${YELLOW}=== Running test: $name ===${NC}" 27 | separator 28 | } 29 | 30 | # Print informational message 31 | info() { 32 | echo "${*}" 33 | } 34 | 35 | # Print blue informational message 36 | info_blue() { 37 | echo -e "${BLUE}${*}${NC}" 38 | } 39 | 40 | # Print a success message 41 | success() { 42 | echo -e "${GREEN}✓ ${*}${NC}" 43 | } 44 | 45 | # Print a failure message 46 | failure() { 47 | echo -e "${RED}✗ ${*}${NC}" 48 | } 49 | 50 | # Print a warning or skip message 51 | warning() { 52 | echo -e "${YELLOW}${*}${NC}" 53 | } 54 | 55 | # Skip a test with a message 56 | skip() { 57 | warning "${*}" 58 | exit 0 59 | } 60 | 61 | # Fail a test with an error message 62 | # If a second argument is provided, it will be displayed as additional output 63 | fail() { 64 | local message="$1" 65 | local output="$2" 66 | 67 | failure "$message" 68 | [ -n "$output" ] && echo "$output" 69 | exit 1 70 | } 71 | 72 | # Check if a command exists 73 | command_exists() { 74 | command -v "$1" &> /dev/null 75 | } 76 | 77 | # Run a command and capture its output and exit code 78 | run_command() { 79 | info "Executing: ${*}" 80 | OUTPUT=$(eval "${*}" 2>&1) 81 | RESULT=$? 82 | echo "$OUTPUT" 83 | return $RESULT 84 | } 85 | 86 | # Default cleanup function for test files 87 | cleanup_file() { 88 | local file="$1" 89 | if [ -f "$file" ]; then 90 | rm -f "$file" 91 | info "Test file removed" 92 | fi 93 | } 94 | 95 | # Generate a random file name in /tmp 96 | random_tmpfile() { 97 | local prefix="$1" 98 | echo "/tmp/${prefix}_$(date +%H%M%S%N | cut -c1-10).txt" 99 | } 100 | 101 | # Check if the CLI binary exists 102 | check_cli_exists() { 103 | if [ ! -f "$CLI_BIN" ]; then 104 | info "Building CLI..." 105 | (cd "$SCRIPT_DIR/.." && go build) 106 | fi 107 | 108 | [ -f "$CLI_BIN" ] || fail "CLI binary not found at: $CLI_BIN" 109 | } -------------------------------------------------------------------------------- /tests/runners/test_runner_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Source common utilities 4 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 6 | source "$TESTS_ROOT/common/common.sh" 7 | 8 | # Configuration file for this test 9 | CONFIG_FILE="$SCRIPT_DIR/test_runner_docker.yaml" 10 | TEST_NAME="test_runner_docker" 11 | 12 | 13 | ##################################################################################### 14 | # Start the test 15 | 16 | testcase "$TEST_NAME" 17 | 18 | info "Testing Docker runner with config: $CONFIG_FILE" 19 | 20 | separator 21 | info "1. Checking if Docker is installed and running" 22 | separator 23 | 24 | # Check if Docker is installed and running 25 | # And try to run a simple docker command to check if the daemon is running 26 | command_exists docker || skip "Docker not installed, skipping test" 27 | docker ps &> /dev/null || skip "Docker daemon not running, skipping test" 28 | success "Docker is available, proceeding with tests" 29 | 30 | # Make sure we have the CLI binary 31 | check_cli_exists 32 | 33 | separator 34 | info "2. Simple hello world in Docker container" 35 | separator 36 | 37 | CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_hello" 38 | info "Executing: $CMD" 39 | OUTPUT=$(eval "$CMD" 2>&1) 40 | RESULT=$? 41 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 42 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 43 | echo "$OUTPUT" | grep -q "Hello from Docker container" || fail "Test failed: Expected output not found" "$OUTPUT" 44 | 45 | success "Test passed" 46 | 47 | separator 48 | info "3. Environment variable passing" 49 | separator 50 | 51 | CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_with_env message=\"Hello from Docker container\"" 52 | info "Executing: $CMD" 53 | OUTPUT=$(eval "$CMD" 2>&1) 54 | RESULT=$? 55 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 56 | 57 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 58 | echo "$OUTPUT" | grep -q "Hello from Docker container" || fail "Test failed: Expected output not found" "$OUTPUT" 59 | 60 | success "Test passed" 61 | 62 | separator 63 | info "4. Prepare command functionality" 64 | separator 65 | 66 | CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_with_prepare" 67 | info "Executing: $CMD" 68 | OUTPUT=$(eval "$CMD" 2>&1) 69 | RESULT=$? 70 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 71 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 72 | echo "$OUTPUT" | grep -q "grep" || fail "Test failed: grep version output not found" "$OUTPUT" 73 | 74 | success "Test passed" 75 | 76 | echo 77 | success "All Docker runner tests passed!" 78 | exit 0 -------------------------------------------------------------------------------- /pkg/command/runner.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/inercia/MCPShell/pkg/common" 9 | ) 10 | 11 | // RunnerType is an identifier for the type of runner to use. 12 | // Each runner has its own set of implicit requirements that are checked 13 | // automatically, so users don't need to explicitly specify common requirements 14 | // in their tool configurations. 15 | type RunnerType string 16 | 17 | const ( 18 | // RunnerTypeExec is the standard command execution runner with no additional requirements 19 | RunnerTypeExec RunnerType = "exec" 20 | 21 | // RunnerTypeSandboxExec is the macOS-specific sandbox-exec runner 22 | // Implicit requirements: OS=darwin, executables=[sandbox-exec] 23 | RunnerTypeSandboxExec RunnerType = "sandbox-exec" 24 | 25 | // RunnerTypeFirejail is the Linux-specific firejail runner 26 | // Implicit requirements: OS=linux, executables=[firejail] 27 | RunnerTypeFirejail RunnerType = "firejail" 28 | 29 | // RunnerTypeDocker is the Docker-based runner 30 | // Implicit requirements: executables=[docker] 31 | RunnerTypeDocker RunnerType = "docker" 32 | ) 33 | 34 | // RunnerOptions is a map of options for the runner 35 | type RunnerOptions map[string]interface{} 36 | 37 | func (ro RunnerOptions) ToJSON() (string, error) { 38 | json, err := json.Marshal(ro) 39 | return string(json), err 40 | } 41 | 42 | // Runner is an interface for running commands 43 | type Runner interface { 44 | Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error) 45 | CheckImplicitRequirements() error 46 | } 47 | 48 | // NewRunner creates a new Runner based on the given type 49 | func NewRunner(runnerType RunnerType, options RunnerOptions, logger *common.Logger) (Runner, error) { 50 | var runner Runner 51 | var err error 52 | 53 | // Create the runner instance based on type 54 | switch runnerType { 55 | case RunnerTypeExec: 56 | runner, err = NewRunnerExec(options, logger) 57 | case RunnerTypeSandboxExec: 58 | runner, err = NewRunnerSandboxExec(options, logger) 59 | case RunnerTypeFirejail: 60 | runner, err = NewRunnerFirejail(options, logger) 61 | case RunnerTypeDocker: 62 | runner, err = NewDockerRunner(options, logger) 63 | default: 64 | return nil, fmt.Errorf("unknown runner type: %s", runnerType) 65 | } 66 | 67 | // Check if runner creation failed 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | // Check implicit requirements for the created runner 73 | if err := runner.CheckImplicitRequirements(); err != nil { 74 | if logger != nil { 75 | logger.Debug("Runner %s failed implicit requirements check: %v", runnerType, err) 76 | } 77 | return nil, err 78 | } 79 | 80 | return runner, nil 81 | } 82 | -------------------------------------------------------------------------------- /cmd/validate.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/inercia/MCPShell/pkg/common" 7 | "github.com/inercia/MCPShell/pkg/config" 8 | "github.com/inercia/MCPShell/pkg/server" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // validateCommand represents the validate command which checks a configuration file 13 | var validateCommand = &cobra.Command{ 14 | Use: "validate", 15 | Short: "Validate an MCPShell tools configuration file", 16 | Long: `Validate an MCPShell tools configuration file. 17 | 18 | This command checks the configuration file for errors including: 19 | 20 | - File format and schema validation 21 | - Tool parameter definitions 22 | - Constraint expression syntax 23 | - Command template syntax`, 24 | PreRunE: func(cmd *cobra.Command, args []string) error { 25 | // Initialize logger 26 | logger, err := initLogger() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // Setup panic handler 32 | defer func() { 33 | if logger != nil { 34 | common.RecoverPanic() 35 | } 36 | }() 37 | 38 | logger.Info("Validating MCP configuration") 39 | 40 | // Check if config file is provided 41 | if len(toolsFiles) == 0 { 42 | logger.Error("Tools configuration file(s) are required") 43 | return fmt.Errorf("tools configuration file(s) are required. Use --tools flag to specify the path(s)") 44 | } 45 | 46 | return nil 47 | }, 48 | RunE: func(cmd *cobra.Command, args []string) error { 49 | // Get the logger 50 | logger := common.GetLogger() 51 | 52 | // Setup panic handler 53 | defer func() { 54 | if logger != nil { 55 | common.RecoverPanic() 56 | } 57 | }() 58 | 59 | // Load the configuration file(s) (local or remote) 60 | localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger) 61 | if err != nil { 62 | logger.Error("Failed to load configuration: %v", err) 63 | return fmt.Errorf("failed to load configuration: %w", err) 64 | } 65 | 66 | // Ensure temporary files are cleaned up 67 | defer cleanup() 68 | 69 | // Create server instance for validation only 70 | srv := server.New(server.Config{ 71 | ConfigFile: localConfigPath, 72 | Logger: logger, 73 | Version: version, 74 | Descriptions: description, 75 | }) 76 | 77 | // Validate the configuration 78 | if err := srv.Validate(); err != nil { 79 | logger.Error("Configuration validation failed: %v", err) 80 | return fmt.Errorf("configuration validation failed: %w", err) 81 | } 82 | 83 | logger.Info("Configuration validation successful") 84 | return nil 85 | }, 86 | } 87 | 88 | // init adds the validate command to the root command 89 | func init() { 90 | // Add validate command to root 91 | rootCmd.AddCommand(validateCommand) 92 | 93 | // Mark required flags 94 | _ = validateCommand.MarkFlagRequired("tools") 95 | } 96 | -------------------------------------------------------------------------------- /pkg/common/downloads.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | // DownloadTimeout is the timeout for downloading content from a URL 12 | const DownloadTimeout = 10 * time.Second 13 | 14 | // List of supported text content types 15 | var SupportedContentTypes = []string{ 16 | "text/", // All text/* types 17 | "application/json", // JSON 18 | "application/xml", // XML 19 | "application/yaml", // YAML 20 | "application/x-yaml", // Alternative YAML 21 | "application/javascript", // JavaScript 22 | "application/ecmascript", // ECMAScript 23 | "application/markdown", // Markdown 24 | "application/x-markdown", // Alternative Markdown 25 | } 26 | 27 | // fetchURLContent downloads content from a URL and verifies it's a text format 28 | // Returns the content as a byte slice if successful, or an error if download fails 29 | // or if the content is not a supported text format. 30 | func FetchURLText(url string) ([]byte, error) { 31 | // Create a new HTTP client with a timeout 32 | client := &http.Client{ 33 | Timeout: DownloadTimeout, 34 | } 35 | 36 | // Send a GET request to the URL 37 | resp, err := client.Get(url) 38 | if err != nil { 39 | return nil, fmt.Errorf("HTTP request failed: %w", err) 40 | } 41 | defer func() { 42 | if closeErr := resp.Body.Close(); closeErr != nil { 43 | // If we already have an error, don't overwrite it 44 | if err == nil { 45 | err = fmt.Errorf("failed to close response body: %w", closeErr) 46 | } 47 | } 48 | }() 49 | 50 | // Check response status code 51 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 52 | return nil, fmt.Errorf("HTTP request returned non-success status: %d %s", 53 | resp.StatusCode, resp.Status) 54 | } 55 | 56 | // Check content type to ensure it's a text format 57 | contentType := resp.Header.Get("Content-Type") 58 | if !isTextContentType(contentType) { 59 | return nil, fmt.Errorf("unsupported content type: %s - only text formats are supported", contentType) 60 | } 61 | 62 | // Read the response body 63 | body, err := io.ReadAll(resp.Body) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to read response body: %w", err) 66 | } 67 | 68 | return body, nil 69 | } 70 | 71 | // isTextContentType checks if a Content-Type header represents a text format 72 | func isTextContentType(contentType string) bool { 73 | // Remove any parameters (like charset) 74 | if idx := strings.Index(contentType, ";"); idx >= 0 { 75 | contentType = contentType[:idx] 76 | } 77 | contentType = strings.TrimSpace(contentType) 78 | 79 | // Check if content type is in the supported list 80 | for _, supported := range SupportedContentTypes { 81 | if supported == contentType || (strings.HasSuffix(supported, "/") && strings.HasPrefix(contentType, supported)) { 82 | return true 83 | } 84 | } 85 | 86 | return false 87 | } 88 | -------------------------------------------------------------------------------- /pkg/agent/prompts/orchestrator.md: -------------------------------------------------------------------------------- 1 | # Orchestrator Agent System Prompt 2 | 3 | You are an orchestrator agent responsible for planning, coordinating, and managing task execution in a multi-agent system. 4 | 5 | ## CRITICAL: Ensure Task Completion 6 | 7 | **DO NOT accept incomplete results from the tool-runner!** When you delegate a task: 8 | 9 | 1. **Verify the tool-runner fully completed the task** before declaring success 10 | 1. **Check that all requested information was gathered** (e.g., if you asked for data from ALL clusters, ensure ALL were checked) 11 | 1. **Request additional work** if the tool-runner stopped prematurely or provided partial results 12 | 1. **Review the output carefully** - does it match what you expected in your task delegation? 13 | 14 | If the tool-runner only executed one tool when the task clearly requires multiple steps, **transfer another task** asking it to continue. 15 | 16 | ## Your Role 17 | 18 | - **Plan and Strategize**: Break down complex user requests into clear, actionable steps 19 | - **Delegate Effectively**: Transfer tool execution tasks to your tool-runner sub-agent using the `transfer_task` tool 20 | - **Monitor Progress**: Track task completion and understand when objectives have been met 21 | - **Communicate Clearly**: Provide clear, concise updates and summaries to the user 22 | - **Think Critically**: Assess whether tasks are complete before declaring success 23 | 24 | ## Working with the Tool-Runner 25 | 26 | When you need to execute tools (commands, queries, diagnostics), you should: 27 | 28 | 1. **Analyze the Request**: Understand what the user needs 29 | 1. **Formulate a Task**: Create a clear task description for the tool-runner 30 | 1. **Set Expectations**: Define what output you expect from the tool-runner 31 | 1. **Transfer the Task**: Use `transfer_task` to delegate to the tool-runner agent 32 | 1. **Review Results**: Evaluate the tool-runner's output and determine next steps 33 | 34 | ## Best Practices 35 | 36 | - **Don't Execute Tools Directly**: You focus on orchestration; let the tool-runner handle tool execution 37 | - **Be Specific**: When transferring tasks, provide clear instructions and expected outcomes 38 | - **Verify Completion**: Check if the task objectives are met before concluding 39 | - **Iterate if Needed**: If initial results are insufficient, request additional information 40 | - **Summarize Findings**: Always provide a clear summary of results to the user 41 | 42 | ## Example Workflow 43 | 44 | ``` 45 | User: "Check disk space and find what's using the most storage" 46 | 47 | Your Response: 48 | 1. Understand the request requires multiple diagnostic steps 49 | 2. Transfer task to tool-runner with clear instructions: 50 | - Check overall disk usage 51 | - Identify large directories/files 52 | - Provide actionable recommendations 53 | 3. Review tool-runner's findings 54 | 4. Summarize results for the user in a clear, actionable format 55 | ``` 56 | 57 | Remember: You are the coordinator, not the executor. Your strength is in planning and delegation. 58 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go test ./... 7 | 8 | builds: 9 | - id: mcpshell 10 | binary: mcpshell 11 | main: ./main.go 12 | env: 13 | - CGO_ENABLED=0 14 | goos: 15 | - linux 16 | - darwin 17 | - windows 18 | goarch: 19 | - amd64 20 | - arm64 21 | ldflags: 22 | - -s -w 23 | - -X main.version={{.Version}} 24 | - -X main.commit={{.Commit}} 25 | - -X main.date={{.Date}} 26 | ignore: 27 | # Skip combinations that don't make sense or aren't commonly used 28 | - goos: windows 29 | goarch: arm64 30 | 31 | archives: 32 | - id: default 33 | name_template: >- 34 | {{ .ProjectName }}_ 35 | {{- title .Os }}_ 36 | {{- if eq .Arch "amd64" }}x86_64 37 | {{- else if eq .Arch "386" }}i386 38 | {{- else }}{{ .Arch }}{{ end }} 39 | {{- if .Arm }}v{{ .Arm }}{{ end }} 40 | format_overrides: 41 | - goos: windows 42 | format: zip 43 | files: 44 | - LICENSE 45 | - README.md 46 | - docs/**/* 47 | - examples/**/* 48 | 49 | checksum: 50 | name_template: 'checksums.txt' 51 | algorithm: sha256 52 | 53 | changelog: 54 | use: github 55 | sort: asc 56 | filters: 57 | exclude: 58 | - '^docs:' 59 | - '^test:' 60 | - '^chore:' 61 | - typo 62 | groups: 63 | - title: Features 64 | regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 65 | order: 0 66 | - title: Bug Fixes 67 | regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 68 | order: 1 69 | - title: Performance Improvements 70 | regexp: '^.*?perf(\([[:word:]]+\))??!?:.+$' 71 | order: 2 72 | - title: Others 73 | order: 999 74 | 75 | release: 76 | github: 77 | owner: inercia 78 | name: MCPShell 79 | draft: false 80 | prerelease: auto 81 | mode: replace 82 | header: | 83 | ## MCPShell {{ .Tag }} 84 | 85 | Download the appropriate binary for your platform below. 86 | 87 | ### Installation 88 | 89 | #### Linux/macOS 90 | ```bash 91 | # Download and extract 92 | tar -xzf mcpshell_*.tar.gz 93 | 94 | # Make executable and move to PATH 95 | chmod +x mcpshell 96 | sudo mv mcpshell /usr/local/bin/ 97 | ``` 98 | 99 | #### Windows 100 | ```powershell 101 | # Extract the zip file and add to PATH 102 | # Or move mcpshell.exe to a directory in your PATH 103 | ``` 104 | 105 | footer: | 106 | **Full Changelog**: https://github.com/inercia/MCPShell/compare/{{ .PreviousTag }}...{{ .Tag }} 107 | 108 | --- 109 | 110 | ### Verify checksums 111 | 112 | Download `checksums.txt` and verify your download: 113 | 114 | ```bash 115 | sha256sum -c checksums.txt 116 | ``` 117 | 118 | snapshot: 119 | name_template: "{{ incpatch .Version }}-next" 120 | 121 | metadata: 122 | mod_timestamp: "{{ .CommitTimestamp }}" 123 | -------------------------------------------------------------------------------- /pkg/common/types.go: -------------------------------------------------------------------------------- 1 | // Package common provides shared utilities and types used across the MCPShell. 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // OutputConfig defines how tool output should be formatted before being returned. 11 | type OutputConfig struct { 12 | // Prefix is a template string that gets prepended to the command output. 13 | // It can use the same template variables as the command itself. 14 | Prefix string `yaml:"prefix,omitempty"` 15 | } 16 | 17 | // ParamConfig defines the configuration for a single parameter in a tool. 18 | type ParamConfig struct { 19 | // Type specifies the parameter data type. Valid values: "string" (default), "number"/"integer", "boolean" 20 | Type string `yaml:"type,omitempty"` 21 | 22 | // Description provides information about the parameter's purpose 23 | Description string `yaml:"description"` 24 | 25 | // Required indicates whether the parameter must be provided 26 | Required bool `yaml:"required,omitempty"` 27 | 28 | // Default specifies a default value to use when the parameter is not provided 29 | Default interface{} `yaml:"default,omitempty"` 30 | } 31 | 32 | // LoggingConfig defines configuration options for application logging. 33 | type LoggingConfig struct { 34 | // File is the path to the log file 35 | File string 36 | 37 | // Level sets the logging verbosity (e.g., "info", "debug", "error") 38 | Level string `yaml:"level,omitempty"` 39 | } 40 | 41 | // ConvertStringToType converts a string value to the appropriate type based on the parameter type. 42 | // This is used when parsing command line arguments for direct tool execution. 43 | // 44 | // Parameters: 45 | // - value: The string value to convert 46 | // - paramType: The parameter type ("string", "number", "integer", "boolean") 47 | // 48 | // Returns: 49 | // - The converted value 50 | // - An error if the conversion fails 51 | func ConvertStringToType(value string, paramType string) (interface{}, error) { 52 | // Default to string if type is not specified 53 | if paramType == "" { 54 | paramType = "string" 55 | } 56 | 57 | switch paramType { 58 | case "string": 59 | return value, nil 60 | case "number": 61 | // Try to parse as float64 62 | floatVal, err := strconv.ParseFloat(value, 64) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to parse '%s' as number: %w", value, err) 65 | } 66 | return floatVal, nil 67 | case "integer": 68 | // Try to parse as int64 69 | intVal, err := strconv.ParseInt(value, 10, 64) 70 | if err != nil { 71 | return nil, fmt.Errorf("failed to parse '%s' as integer: %w", value, err) 72 | } 73 | return intVal, nil 74 | case "boolean": 75 | // Convert to lowercase for consistent comparison 76 | lowerVal := strings.ToLower(value) 77 | 78 | // Check for various boolean representations 79 | switch lowerVal { 80 | case "true", "t", "yes", "y", "1": 81 | return true, nil 82 | case "false", "f", "no", "n", "0": 83 | return false, nil 84 | default: 85 | return nil, fmt.Errorf("failed to parse '%s' as boolean", value) 86 | } 87 | default: 88 | return nil, fmt.Errorf("unsupported parameter type: %s", paramType) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Prerequisites 4 | 5 | - Go 1.18 or higher 6 | - Git 7 | 8 | ## Project Structure 9 | 10 | ```console 11 | . 12 | ├── cmd/ # Command definitions 13 | ├── docs/ # Documentation 14 | ├── pkg/ # Source code 15 | ├── main.go # Entry point 16 | ├── Makefile # Build scripts 17 | └── README.md # This file 18 | ``` 19 | 20 | ## Building from source 21 | 22 | 1. Clone the repository: 23 | 24 | ```console 25 | git clone https://github.com/inercia/MCPShell.git 26 | cd mcpshell 27 | ``` 28 | 29 | 1. Build the application: 30 | 31 | ```console 32 | make build 33 | ``` 34 | 35 | This will create a binary in the `build` directory. 36 | 37 | 1. Alternatively, install the application: 38 | 39 | ```console 40 | make install 41 | ``` 42 | 43 | ### Continuous Integration 44 | 45 | This project uses GitHub Actions for continuous integration: 46 | 47 | - **Pull Request Testing**: Every pull request triggers an automated workflow that: 48 | - Runs all unit tests 49 | - Performs race condition detection 50 | - Checks code formatting 51 | - Runs linters to ensure code quality 52 | 53 | These checks help maintain code quality and prevent regressions as the project evolves. 54 | 55 | ## Releases 56 | 57 | This project uses [GoReleaser](https://goreleaser.com/) and GitHub Actions to automatically build and release binaries for multiple platforms. When a tag is pushed, the workflow: 58 | 59 | 1. Runs all tests 60 | 1. Builds binaries for multiple platforms (Linux, macOS, Windows) and architectures (amd64, arm64) 61 | 1. Creates archives with documentation and examples 62 | 1. Generates checksums 63 | 1. Creates a GitHub release with all artifacts 64 | 1. Generates a changelog from commit messages 65 | 66 | ### Quick Start 67 | 68 | To create a new release: 69 | 70 | ```bash 71 | # Create and tag a new release (updates docs and creates tag) 72 | make release 73 | 74 | # Push the tag to trigger the automated release workflow 75 | git push origin main v1.2.3 76 | ``` 77 | 78 | The release will appear on the GitHub Releases page with binaries for each supported platform. 79 | 80 | ### Testing Releases Locally 81 | 82 | Before creating an official release: 83 | 84 | ```bash 85 | # Validate the GoReleaser configuration 86 | make release-test 87 | 88 | # Build a snapshot release locally (no tag required) 89 | make release-snapshot 90 | ``` 91 | 92 | For detailed information about the release process, see the [Release Process Guide](release-process.md). 93 | 94 | ## Make Targets 95 | 96 | Run `make help` to see all available targets. Key targets include: 97 | 98 | - `make build`: Build the application 99 | - `make clean`: Remove build artifacts 100 | - `make test`: Run unit tests 101 | - `make test-e2e`: Run end-to-end tests 102 | - `make run`: Run the application 103 | - `make install`: Install the application 104 | - `make lint`: Run linting 105 | - `make format`: Format Go code 106 | - `make validate-examples`: Validate all YAML configs 107 | - `make release`: Create a new release (tag + docs update) 108 | - `make release-test`: Validate GoReleaser configuration 109 | - `make release-snapshot`: Build snapshot release locally 110 | - `make help`: Show all available targets 111 | -------------------------------------------------------------------------------- /pkg/command/runner_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | 7 | "github.com/inercia/MCPShell/pkg/common" 8 | ) 9 | 10 | // TestImplicitRequirements tests the implicit requirements checking 11 | // for different runner types 12 | func TestImplicitRequirements(t *testing.T) { 13 | logger, _ := common.NewLogger("test: ", "", common.LogLevelInfo, false) 14 | 15 | // Test cases for the exec runner 16 | t.Run("ExecRunner", func(t *testing.T) { 17 | // Exec runner should always pass since it has no requirements 18 | runner, err := NewRunnerExec(RunnerOptions{}, logger) 19 | if err != nil { 20 | t.Fatalf("Failed to create exec runner: %v", err) 21 | } 22 | 23 | err = runner.CheckImplicitRequirements() 24 | if err != nil { 25 | t.Errorf("Exec runner should have no requirements but failed: %v", err) 26 | } 27 | }) 28 | 29 | // Test cases for the sandbox-exec runner 30 | t.Run("SandboxExecRunner", func(t *testing.T) { 31 | // Skip this test if not on macOS 32 | if runtime.GOOS != "darwin" { 33 | t.Skip("Skipping sandbox-exec test on non-macOS platform") 34 | } 35 | 36 | // Create the runner 37 | runner, err := NewRunnerSandboxExec(RunnerOptions{}, logger) 38 | if err != nil { 39 | t.Fatalf("Failed to create sandbox-exec runner: %v", err) 40 | } 41 | 42 | // Check requirements - expect pass on macOS if executable exists 43 | err = runner.CheckImplicitRequirements() 44 | if err != nil { 45 | // This is expected if sandbox-exec is not available 46 | t.Logf("SandboxExec runner failed as expected if sandbox-exec is not available: %v", err) 47 | } 48 | }) 49 | 50 | // Test cases for the firejail runner 51 | t.Run("FirejailRunner", func(t *testing.T) { 52 | // Skip this test if not on Linux or firejail is not available 53 | if runtime.GOOS != "linux" { 54 | t.Skip("Skipping firejail test on non-Linux platform") 55 | } 56 | if !common.CheckExecutableExists("firejail") { 57 | t.Skip("Skipping firejail test if firejail is not available") 58 | } 59 | 60 | // Create the runner 61 | runner, err := NewRunnerFirejail(RunnerOptions{}, logger) 62 | if err != nil { 63 | t.Fatalf("Failed to create firejail runner: %v", err) 64 | } 65 | 66 | // Check requirements - expect pass on Linux if executable exists 67 | err = runner.CheckImplicitRequirements() 68 | if err != nil { 69 | // This is expected if firejail is not available 70 | t.Logf("Firejail runner failed as expected if firejail is not available: %v", err) 71 | } 72 | }) 73 | 74 | // Test cases for the docker runner 75 | t.Run("DockerRunner", func(t *testing.T) { 76 | // This test is for the implicit requirements only 77 | // The Docker daemon check will be handled in the DockerRunner itself 78 | 79 | // Create a Docker runner with mock options that satisfy its creation requirements 80 | mockOpts := RunnerOptions{ 81 | "image": "alpine:latest", 82 | } 83 | 84 | runner, err := NewDockerRunner(mockOpts, logger) 85 | if err != nil { 86 | t.Fatalf("Failed to create docker runner: %v", err) 87 | } 88 | 89 | // Check requirements - expect pass if Docker is available and running 90 | err = runner.CheckImplicitRequirements() 91 | if err != nil { 92 | // This is expected if Docker is not available or daemon is not running 93 | t.Logf("Docker runner failed requirements check as expected: %v", err) 94 | } 95 | }) 96 | } 97 | -------------------------------------------------------------------------------- /tests/runners/test_runner_sandbox_exec.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: "sandbox-exec runner test configuration" 3 | run: 4 | shell: bash 5 | tools: 6 | - name: "sandbox_hello" 7 | description: "Simple hello world command running in sandbox-exec" 8 | run: 9 | timeout: "5s" 10 | command: | 11 | echo "Hello from sandbox-exec" 12 | runners: 13 | - name: sandbox-exec 14 | requirements: 15 | executables: [sandbox-exec] 16 | options: 17 | allow_network: false 18 | allow_write: false 19 | output: 20 | format: string 21 | 22 | - name: "sandbox_read_file" 23 | description: "Test reading from allowed paths" 24 | run: 25 | timeout: "5s" 26 | command: | 27 | cat /etc/hostname 2>/dev/null || echo "hostname file not accessible" 28 | echo "Can read /etc files" 29 | runners: 30 | - name: sandbox-exec 31 | requirements: 32 | executables: [sandbox-exec] 33 | options: 34 | allow_network: false 35 | allow_write: false 36 | read_paths: 37 | - /etc 38 | output: 39 | format: string 40 | 41 | - name: "sandbox_with_write" 42 | description: "Test writing to /tmp with proper permissions" 43 | params: 44 | filename: 45 | type: string 46 | description: "Filename to create in /tmp" 47 | required: true 48 | run: 49 | timeout: "5s" 50 | command: | 51 | echo "Test content" > /tmp/{{ .filename }} 52 | cat /tmp/{{ .filename }} 53 | rm -f /tmp/{{ .filename }} 54 | runners: 55 | - name: sandbox-exec 56 | requirements: 57 | executables: [sandbox-exec] 58 | options: 59 | allow_network: false 60 | allow_write: true 61 | write_paths: 62 | - /tmp 63 | output: 64 | format: string 65 | 66 | - name: "sandbox_with_timeout" 67 | description: "Test that timeout works with sandbox-exec" 68 | params: 69 | duration: 70 | type: string 71 | description: "Duration to sleep" 72 | required: false 73 | default: "10" 74 | run: 75 | timeout: "2s" 76 | command: | 77 | echo "Starting sleep..." 78 | sleep {{ .duration }} 79 | echo "This should not be printed" 80 | runners: 81 | - name: sandbox-exec 82 | requirements: 83 | executables: [sandbox-exec] 84 | options: 85 | allow_network: false 86 | allow_write: false 87 | output: 88 | format: string 89 | 90 | - name: "sandbox_network_blocked" 91 | description: "Test that network access is blocked by default" 92 | run: 93 | timeout: "5s" 94 | command: | 95 | # Try to access network (should fail or timeout quickly) 96 | curl -s --max-time 1 https://www.google.com 2>&1 || echo "Network access blocked as expected" 97 | runners: 98 | - name: sandbox-exec 99 | requirements: 100 | executables: [sandbox-exec, curl] 101 | options: 102 | allow_network: false 103 | allow_write: false 104 | output: 105 | format: string 106 | -------------------------------------------------------------------------------- /pkg/server/description.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/inercia/MCPShell/pkg/common" 9 | "github.com/inercia/MCPShell/pkg/config" 10 | ) 11 | 12 | // GetDescription returns the description for the MCP server 13 | // It can get the description from: 14 | // 1. The config file 15 | // 2. Command line flags 16 | // 3. Files 17 | // 4. URLs 18 | func GetDescription(cfg Config) (string, error) { 19 | var finalDesc string 20 | 21 | // First, check if we should load the description from the config file 22 | // (unless description override is explicitly requested) 23 | configDesc := "" 24 | if !cfg.DescriptionOverride { 25 | loadedCfg, loadErr := config.NewConfigFromFile(cfg.ConfigFile) 26 | if loadErr == nil && loadedCfg.MCP.Description != "" { 27 | configDesc = loadedCfg.MCP.Description 28 | if cfg.Logger != nil { 29 | cfg.Logger.Debug("Found description in config file: %s", configDesc) 30 | } 31 | finalDesc = configDesc 32 | if cfg.Logger != nil { 33 | cfg.Logger.Debug("Using description from config file: %s", configDesc) 34 | } 35 | } 36 | } 37 | 38 | // Add descriptions from command line flags 39 | if len(cfg.Descriptions) > 0 { 40 | cmdDesc := strings.Join(cfg.Descriptions, "\n") 41 | if finalDesc != "" && !cfg.DescriptionOverride { 42 | finalDesc += "\n" + cmdDesc 43 | if cfg.Logger != nil { 44 | cfg.Logger.Info("Appending descriptions from command line flags") 45 | } 46 | } else { 47 | finalDesc = cmdDesc 48 | if cfg.Logger != nil { 49 | cfg.Logger.Info("Using descriptions from command line flags") 50 | } 51 | } 52 | } 53 | 54 | // Add descriptions from files - handle both local files and URLs 55 | if len(cfg.DescriptionFiles) > 0 { 56 | if cfg.Logger != nil { 57 | cfg.Logger.Info("Reading server description from files/URLs: %v", cfg.DescriptionFiles) 58 | } 59 | var fileDescs []string 60 | 61 | for _, fileOrURL := range cfg.DescriptionFiles { 62 | var content []byte 63 | var err error 64 | 65 | // Check if it's a URL 66 | if strings.HasPrefix(fileOrURL, "http://") || strings.HasPrefix(fileOrURL, "https://") { 67 | // It's a URL, download the content 68 | if cfg.Logger != nil { 69 | cfg.Logger.Info("Downloading description from URL: %s", fileOrURL) 70 | } 71 | content, err = common.FetchURLText(fileOrURL) 72 | if err != nil { 73 | if cfg.Logger != nil { 74 | cfg.Logger.Error("Failed to download content from URL: %s - %v", fileOrURL, err) 75 | } 76 | return "", fmt.Errorf("failed to download content from URL %s: %w", fileOrURL, err) 77 | } 78 | } else { 79 | // It's a local file, read it directly 80 | if cfg.Logger != nil { 81 | cfg.Logger.Info("Reading description from file: %s", fileOrURL) 82 | } 83 | content, err = os.ReadFile(fileOrURL) 84 | if err != nil { 85 | if cfg.Logger != nil { 86 | cfg.Logger.Error("Failed to read description file: %s - %v", fileOrURL, err) 87 | } 88 | return "", fmt.Errorf("failed to read description file %s: %w", fileOrURL, err) 89 | } 90 | } 91 | 92 | fileDescs = append(fileDescs, string(content)) 93 | } 94 | 95 | // Concatenate all file contents 96 | if len(fileDescs) > 0 { 97 | fileContent := strings.Join(fileDescs, "\n") 98 | if finalDesc != "" && !cfg.DescriptionOverride { 99 | finalDesc += "\n" + fileContent 100 | if cfg.Logger != nil { 101 | cfg.Logger.Info("Appending descriptions from files") 102 | } 103 | } else { 104 | finalDesc = fileContent 105 | if cfg.Logger != nil { 106 | cfg.Logger.Info("Using descriptions from files") 107 | } 108 | } 109 | } 110 | } 111 | 112 | return finalDesc, nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/server/server_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/inercia/MCPShell/pkg/common" 9 | "github.com/inercia/MCPShell/pkg/config" 10 | ) 11 | 12 | func TestServer_New(t *testing.T) { 13 | // Create a test logger 14 | logger, err := common.NewLogger("", "", common.LogLevelNone, false) 15 | if err != nil { 16 | t.Fatalf("Failed to create logger: %v", err) 17 | } 18 | 19 | // Test creating a new server instance 20 | srv := New(Config{ 21 | ConfigFile: "test-config.yaml", 22 | Logger: logger, 23 | Version: "test", 24 | Descriptions: []string{"test description"}, 25 | }) 26 | 27 | if srv == nil { 28 | t.Fatal("Failed to create server instance") 29 | } 30 | 31 | // Check that the fields are set correctly 32 | if srv.configFile != "test-config.yaml" { 33 | t.Errorf("Expected configFile to be 'test-config.yaml', got '%s'", srv.configFile) 34 | } 35 | 36 | if srv.version != "test" { 37 | t.Errorf("Expected version to be 'test', got '%s'", srv.version) 38 | } 39 | 40 | if srv.description != "test description" { 41 | t.Errorf("Expected description to be 'test description', got '%s'", srv.description) 42 | } 43 | 44 | if srv.mcpServer != nil { 45 | t.Error("Expected mcpServer to be nil until Start() is called") 46 | } 47 | } 48 | 49 | func TestServer_findToolByName(t *testing.T) { 50 | // Create a test logger 51 | logger, err := common.NewLogger("", "", common.LogLevelNone, false) 52 | if err != nil { 53 | t.Fatalf("Failed to create logger: %v", err) 54 | } 55 | 56 | // Create a mock server instance 57 | srv := New(Config{ 58 | Logger: logger, 59 | }) 60 | 61 | // Set up test tools 62 | tools := []config.MCPToolConfig{ 63 | {Name: "tool1", Description: "Tool 1"}, 64 | {Name: "tool2", Description: "Tool 2"}, 65 | {Name: "tool3", Description: "Tool 3"}, 66 | } 67 | 68 | tests := []struct { 69 | name string 70 | toolName string 71 | want int 72 | }{ 73 | { 74 | name: "First tool", 75 | toolName: "tool1", 76 | want: 0, 77 | }, 78 | { 79 | name: "Middle tool", 80 | toolName: "tool2", 81 | want: 1, 82 | }, 83 | { 84 | name: "Last tool", 85 | toolName: "tool3", 86 | want: 2, 87 | }, 88 | { 89 | name: "Non-existent tool", 90 | toolName: "tool4", 91 | want: -1, 92 | }, 93 | } 94 | 95 | for _, tt := range tests { 96 | t.Run(tt.name, func(t *testing.T) { 97 | got := srv.findToolByName(tools, tt.toolName) 98 | if got != tt.want { 99 | t.Errorf("findToolByName() = %v, want %v", got, tt.want) 100 | } 101 | }) 102 | } 103 | } 104 | 105 | func TestServer_loadTools(t *testing.T) { 106 | // Create a temporary directory and config file 107 | tempDir, err := os.MkdirTemp("", "server-test") 108 | if err != nil { 109 | t.Fatalf("Failed to create temp directory: %v", err) 110 | } 111 | defer func() { 112 | if err := os.RemoveAll(tempDir); err != nil { 113 | t.Logf("Warning: failed to remove temp directory: %v", err) 114 | } 115 | }() 116 | 117 | // Create a minimal config file 118 | testConfigFile := filepath.Join(tempDir, "config.yaml") 119 | configContent := `mcp: 120 | tools: 121 | - name: "test_tool" 122 | description: "Test tool" 123 | params: 124 | param1: 125 | type: string 126 | description: "Test parameter" 127 | run: 128 | command: "echo 'Test'" 129 | ` 130 | 131 | if err := os.WriteFile(testConfigFile, []byte(configContent), 0644); err != nil { 132 | t.Fatalf("Failed to write test config file: %v", err) 133 | } 134 | 135 | // Skip actually loading the tools to avoid running commands 136 | t.Skip("loadTools() is tested in integration tests") 137 | } 138 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/inercia/MCPShell 2 | 3 | go 1.25.3 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.3.0 7 | github.com/docker/cagent v1.7.3 8 | github.com/fatih/color v1.18.0 9 | github.com/google/cel-go v0.26.1 10 | github.com/mark3labs/mcp-go v0.43.2 11 | github.com/sashabaranov/go-openai v1.41.2 12 | github.com/spf13/cobra v1.10.1 13 | golang.org/x/sys v0.38.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | ) 16 | 17 | require ( 18 | cel.dev/expr v0.24.0 // indirect 19 | cloud.google.com/go v0.123.0 // indirect 20 | cloud.google.com/go/auth v0.17.0 // indirect 21 | cloud.google.com/go/compute/metadata v0.9.0 // indirect 22 | dario.cat/mergo v1.0.2 // indirect 23 | github.com/Masterminds/goutils v1.1.1 // indirect 24 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 25 | github.com/Microsoft/go-winio v0.6.2 // indirect 26 | github.com/anthropics/anthropic-sdk-go v1.14.0 // indirect 27 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 28 | github.com/bahlo/generic-list-go v0.2.0 // indirect 29 | github.com/buger/jsonparser v1.1.1 // indirect 30 | github.com/dustin/go-humanize v1.0.1 // indirect 31 | github.com/felixge/httpsnoop v1.0.4 // indirect 32 | github.com/go-logr/logr v1.4.3 // indirect 33 | github.com/go-logr/stdr v1.2.2 // indirect 34 | github.com/goccy/go-yaml v1.18.0 // indirect 35 | github.com/google/go-cmp v0.7.0 // indirect 36 | github.com/google/jsonschema-go v0.3.0 // indirect 37 | github.com/google/s2a-go v0.1.9 // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect 40 | github.com/googleapis/gax-go/v2 v2.15.0 // indirect 41 | github.com/gorilla/websocket v1.5.3 // indirect 42 | github.com/huandu/xstrings v1.5.0 // indirect 43 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 44 | github.com/invopop/jsonschema v0.13.0 // indirect 45 | github.com/mailru/easyjson v0.9.1 // indirect 46 | github.com/mattn/go-colorable v0.1.14 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/mitchellh/copystructure v1.2.0 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 50 | github.com/modelcontextprotocol/go-sdk v1.0.0 // indirect 51 | github.com/ncruces/go-strftime v1.0.0 // indirect 52 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 53 | github.com/shopspring/decimal v1.4.0 // indirect 54 | github.com/spf13/cast v1.10.0 // indirect 55 | github.com/spf13/pflag v1.0.10 // indirect 56 | github.com/stoewer/go-strcase v1.3.1 // indirect 57 | github.com/tidwall/gjson v1.18.0 // indirect 58 | github.com/tidwall/match v1.2.0 // indirect 59 | github.com/tidwall/pretty v1.2.1 // indirect 60 | github.com/tidwall/sjson v1.2.5 // indirect 61 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 62 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 63 | go.opentelemetry.io/auto/sdk v1.2.1 // indirect 64 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect 65 | go.opentelemetry.io/otel v1.38.0 // indirect 66 | go.opentelemetry.io/otel/metric v1.38.0 // indirect 67 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 68 | golang.org/x/crypto v0.45.0 // indirect 69 | golang.org/x/exp v0.0.0-20251017212417-90e834f514db // indirect 70 | golang.org/x/net v0.47.0 // indirect 71 | golang.org/x/text v0.31.0 // indirect 72 | google.golang.org/genai v1.31.0 // indirect 73 | google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect 74 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect 75 | google.golang.org/grpc v1.76.0 // indirect 76 | google.golang.org/protobuf v1.36.10 // indirect 77 | modernc.org/libc v1.66.10 // indirect 78 | modernc.org/mathutil v1.7.1 // indirect 79 | modernc.org/memory v1.11.0 // indirect 80 | modernc.org/sqlite v1.39.1 // indirect 81 | ) 82 | -------------------------------------------------------------------------------- /tests/agent/test_agent_config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Tests the MCPShell agent config functionality 3 | 4 | # Source common utilities 5 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 6 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 7 | source "$TESTS_ROOT/common/common.sh" 8 | 9 | ##################################################################################### 10 | # Configuration for this test 11 | TEST_NAME="test_agent_config" 12 | 13 | ##################################################################################### 14 | # Start the test 15 | 16 | testcase "$TEST_NAME" 17 | 18 | info "Testing MCPShell agent config commands" 19 | 20 | # Make sure we have the CLI binary 21 | check_cli_exists 22 | 23 | separator 24 | info "1. Testing 'agent config show' command" 25 | separator 26 | 27 | OUTPUT=$("$CLI_BIN" agent config show 2>&1) 28 | RESULT=$? 29 | 30 | [ $RESULT -eq 0 ] || fail "Agent config show command failed with exit code: $RESULT" "$OUTPUT" 31 | 32 | # Verify the output contains expected information 33 | echo "$OUTPUT" | grep -q "Configuration file:" || fail "Expected 'Configuration file:' in output" "$OUTPUT" 34 | 35 | # Check if config exists (either shows models or says no config found) 36 | if echo "$OUTPUT" | grep -q "No agent configuration found"; then 37 | info "No agent configuration found (this is acceptable)" 38 | info "Output: $OUTPUT" 39 | else 40 | # If config exists, verify it shows models 41 | echo "$OUTPUT" | grep -q "Agent Configuration:" || fail "Expected 'Agent Configuration:' in output" "$OUTPUT" 42 | echo "$OUTPUT" | grep -q "Model" || fail "Expected 'Model' information in output" "$OUTPUT" 43 | success "Agent config show displayed existing configuration" 44 | fi 45 | 46 | success "Agent config show command passed" 47 | 48 | separator 49 | info "2. Verifying agent configuration file location" 50 | separator 51 | 52 | # Extract config file path from output 53 | CONFIG_PATH=$(echo "$OUTPUT" | grep "Configuration file:" | sed 's/Configuration file: //') 54 | 55 | if [ -f "$CONFIG_PATH" ]; then 56 | success "Agent configuration file exists at: $CONFIG_PATH" 57 | 58 | # Show a sample of the config 59 | info "Configuration file content (first 10 lines):" 60 | head -10 "$CONFIG_PATH" | sed 's/^/ /' 61 | else 62 | info "Agent configuration file not found at: $CONFIG_PATH" 63 | info "Run 'mcpshell agent config create' to create a default configuration" 64 | fi 65 | 66 | separator 67 | info "3. Testing 'agent config show --json' command" 68 | separator 69 | 70 | # Only test JSON output if config exists 71 | if [ -f "$CONFIG_PATH" ]; then 72 | OUTPUT_JSON=$("$CLI_BIN" agent config show --json 2>&1) 73 | RESULT=$? 74 | 75 | [ $RESULT -eq 0 ] || fail "Agent config show --json command failed with exit code: $RESULT" "$OUTPUT_JSON" 76 | 77 | # Verify JSON output is valid 78 | echo "$OUTPUT_JSON" | grep -q '"configuration_file":' || fail "Expected 'configuration_file' in JSON output" "$OUTPUT_JSON" 79 | echo "$OUTPUT_JSON" | grep -q '"models":' || fail "Expected 'models' in JSON output" "$OUTPUT_JSON" 80 | 81 | # Try to parse as JSON (if jq is available) 82 | if command -v jq &> /dev/null; then 83 | echo "$OUTPUT_JSON" | jq . > /dev/null 2>&1 || fail "JSON output is not valid JSON" "$OUTPUT_JSON" 84 | 85 | # Show formatted JSON sample 86 | info "JSON output (formatted):" 87 | echo "$OUTPUT_JSON" | jq . | head -15 | sed 's/^/ /' 88 | 89 | success "Agent config show --json produced valid JSON output" 90 | else 91 | info "jq not available, skipping JSON validation" 92 | success "Agent config show --json command passed" 93 | fi 94 | else 95 | info "Skipping JSON test - no configuration file found" 96 | fi 97 | 98 | separator 99 | success "All agent config tests completed successfully!" 100 | exit 0 101 | -------------------------------------------------------------------------------- /pkg/config/tools_config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "runtime" 5 | "testing" 6 | ) 7 | 8 | func TestCheckToolPrerequisites(t *testing.T) { 9 | // Create test cases 10 | tests := []struct { 11 | name string 12 | runners []MCPToolRunner 13 | expected bool 14 | }{ 15 | { 16 | name: "No runners (default exec runner)", 17 | runners: nil, 18 | expected: true, 19 | }, 20 | { 21 | name: "One runner with matching OS", 22 | runners: []MCPToolRunner{ 23 | { 24 | Name: "compatible-runner", 25 | Requirements: MCPToolRequirements{ 26 | OS: runtime.GOOS, 27 | }, 28 | }, 29 | }, 30 | expected: true, 31 | }, 32 | { 33 | name: "One runner with non-matching OS", 34 | runners: []MCPToolRunner{ 35 | { 36 | Name: "incompatible-runner", 37 | Requirements: MCPToolRequirements{ 38 | OS: "non-existent-os", 39 | }, 40 | }, 41 | }, 42 | expected: false, 43 | }, 44 | { 45 | name: "One runner with existing executable", 46 | runners: []MCPToolRunner{ 47 | { 48 | Name: "compatible-runner", 49 | Requirements: MCPToolRequirements{ 50 | Executables: []string{"sh"}, // should exist on most systems 51 | }, 52 | }, 53 | }, 54 | expected: true, 55 | }, 56 | { 57 | name: "One runner with non-existent executable", 58 | runners: []MCPToolRunner{ 59 | { 60 | Name: "incompatible-runner", 61 | Requirements: MCPToolRequirements{ 62 | Executables: []string{"non-existent-executable-12345"}, 63 | }, 64 | }, 65 | }, 66 | expected: false, 67 | }, 68 | { 69 | name: "Multiple runners with one compatible", 70 | runners: []MCPToolRunner{ 71 | { 72 | Name: "incompatible-runner", 73 | Requirements: MCPToolRequirements{ 74 | OS: "non-existent-os", 75 | }, 76 | }, 77 | { 78 | Name: "compatible-runner", 79 | Requirements: MCPToolRequirements{ 80 | OS: runtime.GOOS, 81 | }, 82 | }, 83 | }, 84 | expected: true, 85 | }, 86 | } 87 | 88 | // Run test cases 89 | for _, tt := range tests { 90 | t.Run(tt.name, func(t *testing.T) { 91 | tool := Tool{ 92 | Config: MCPToolConfig{ 93 | Run: MCPToolRunConfig{ 94 | Runners: tt.runners, 95 | }, 96 | }, 97 | } 98 | result := tool.CheckToolRequirements() 99 | if result != tt.expected { 100 | t.Errorf("CheckToolRequirements() = %v, expected %v", result, tt.expected) 101 | } 102 | }) 103 | } 104 | } 105 | 106 | func TestCreateTools_Prerequisites(t *testing.T) { 107 | // Create a simple config with two tools, one with unmet prerequisites 108 | cfg := &ToolsConfig{ 109 | MCP: MCPConfig{ 110 | Tools: []MCPToolConfig{ 111 | { 112 | Name: "tool1", 113 | Description: "Tool with met prerequisites", 114 | Run: MCPToolRunConfig{ 115 | Command: "echo 'Tool 1'", 116 | Runners: []MCPToolRunner{ 117 | { 118 | Name: "compatible-runner", 119 | Requirements: MCPToolRequirements{ 120 | OS: runtime.GOOS, 121 | Executables: []string{"sh"}, // should exist on most systems 122 | }, 123 | }, 124 | }, 125 | }, 126 | }, 127 | { 128 | Name: "tool2", 129 | Description: "Tool with unmet prerequisites", 130 | Run: MCPToolRunConfig{ 131 | Command: "echo 'Tool 2'", 132 | Runners: []MCPToolRunner{ 133 | { 134 | Name: "incompatible-runner", 135 | Requirements: MCPToolRequirements{ 136 | OS: "non-existent-os", 137 | Executables: []string{"non-existent-executable-12345"}, 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | }, 144 | }, 145 | } 146 | 147 | // Create tools 148 | tools := cfg.GetTools() 149 | 150 | // We should have only one tool 151 | if len(tools) != 1 { 152 | t.Errorf("Expected 1 tool, got %d", len(tools)) 153 | } 154 | 155 | // Check that the correct tool was created 156 | if len(tools) > 0 && tools[0].MCPTool.Name != "tool1" { 157 | t.Errorf("Expected tool named 'tool1', got '%s'", tools[0].MCPTool.Name) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /pkg/agent/cagent_mcp_tool.go: -------------------------------------------------------------------------------- 1 | // Package agent provides cagent integration for MCP tools 2 | package agent 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | 9 | cagentTools "github.com/docker/cagent/pkg/tools" 10 | "github.com/mark3labs/mcp-go/mcp" 11 | 12 | "github.com/inercia/MCPShell/pkg/common" 13 | "github.com/inercia/MCPShell/pkg/server" 14 | ) 15 | 16 | // MCPToolSet wraps MCP server tools for use with cagent 17 | type MCPToolSet struct { 18 | server *server.Server 19 | logger *common.Logger 20 | } 21 | 22 | // NewMCPToolSet creates a new MCP tool set for cagent 23 | func NewMCPToolSet(srv *server.Server, logger *common.Logger) *MCPToolSet { 24 | return &MCPToolSet{ 25 | server: srv, 26 | logger: logger, 27 | } 28 | } 29 | 30 | // GetTools returns all MCP tools as cagent-compatible tools 31 | func (m *MCPToolSet) GetTools() ([]cagentTools.Tool, error) { 32 | // Get MCP tools from the server 33 | mcpTools, err := m.server.GetTools() 34 | if err != nil { 35 | m.logger.Error("Failed to get MCP tools: %v", err) 36 | return nil, fmt.Errorf("failed to get MCP tools: %w", err) 37 | } 38 | 39 | // Convert each MCP tool to a cagent tool 40 | tools := make([]cagentTools.Tool, 0, len(mcpTools)) 41 | for _, mcpTool := range mcpTools { 42 | tool := m.convertMCPToolToCagent(mcpTool) 43 | tools = append(tools, tool) 44 | } 45 | 46 | m.logger.Info("Wrapped %d MCP tools for cagent", len(tools)) 47 | return tools, nil 48 | } 49 | 50 | // convertMCPToolToCagent converts an MCP tool to a cagent Tool struct 51 | func (m *MCPToolSet) convertMCPToolToCagent(mcpTool mcp.Tool) cagentTools.Tool { 52 | // Convert MCP input schema to JSON schema for cagent 53 | schemaMap := map[string]interface{}{ 54 | "type": "object", 55 | "properties": mcpTool.InputSchema.Properties, 56 | "required": mcpTool.InputSchema.Required, 57 | } 58 | 59 | // Create the handler function that executes the MCP tool 60 | // ToolHandler signature: func(ctx context.Context, toolCall ToolCall) (*ToolCallResult, error) 61 | handler := func(ctx context.Context, toolCall cagentTools.ToolCall) (*cagentTools.ToolCallResult, error) { 62 | // Parse the arguments from JSON string 63 | var args map[string]interface{} 64 | 65 | // Handle empty arguments (for tools with all optional parameters) 66 | if toolCall.Function.Arguments == "" { 67 | args = make(map[string]interface{}) 68 | m.logger.Debug("Tool '%s' called with no arguments, using empty map", mcpTool.Name) 69 | } else { 70 | if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { 71 | m.logger.Error("Failed to parse tool arguments for '%s': %v (raw: '%s')", 72 | mcpTool.Name, err, toolCall.Function.Arguments) 73 | 74 | // Return a helpful error message to the agent 75 | return &cagentTools.ToolCallResult{ 76 | Output: fmt.Sprintf("Error: Invalid JSON arguments provided. Expected valid JSON object but got: %s\n\nExample valid call: {}", 77 | toolCall.Function.Arguments), 78 | }, nil 79 | } 80 | } 81 | 82 | m.logger.Debug("Executing MCP tool '%s' with args: %+v", mcpTool.Name, args) 83 | 84 | // Execute the tool through the MCP server 85 | result, err := m.server.ExecuteTool(ctx, mcpTool.Name, args) 86 | if err != nil { 87 | m.logger.Error("Failed to execute MCP tool '%s': %v", mcpTool.Name, err) 88 | 89 | // Return error as output instead of returning error, so agent can see it and retry 90 | return &cagentTools.ToolCallResult{ 91 | Output: fmt.Sprintf("Error executing tool: %v", err), 92 | }, nil 93 | } 94 | 95 | m.logger.Debug("MCP tool '%s' result: %s", mcpTool.Name, result) 96 | return &cagentTools.ToolCallResult{ 97 | Output: result, 98 | }, nil 99 | } 100 | 101 | // Marshal schema to JSON for Parameters field 102 | schemaJSON, err := json.Marshal(schemaMap) 103 | if err != nil { 104 | m.logger.Error("Failed to marshal tool parameters for '%s': %v", mcpTool.Name, err) 105 | // Return minimal valid schema on error 106 | schemaJSON = []byte(`{"type":"object","properties":{}}`) 107 | } 108 | 109 | return cagentTools.Tool{ 110 | Name: mcpTool.Name, 111 | Description: mcpTool.Description, 112 | Parameters: json.RawMessage(schemaJSON), 113 | Handler: handler, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # MCPShell examples 2 | 3 | This directory contains some examples of configurations for running 4 | different tools that can be used by your LLM. 5 | 6 | Some of the examples are: 7 | 8 | - **aws-ro.yaml**, **aws-networking-ro.yaml** and **aws-route53-ro.yaml**: Read-only AWS networking tools for inspecting VPCs, subnets, route53 and network configurations. 9 | - **container-diagnostics-ro.yaml**: Read-only container diagnostics for Docker and containerization tools. 10 | - **disk-diagnostics-ro.yaml**: Tools for analyzing disk usage, finding large files, and checking filesystem health. 11 | - **github-cli-ro.yaml**: Commands for interacting with GitHub repositories, issues, pull requests, and other GitHub resources. 12 | - **kubectl-ro.yaml**: Read-only Kubernetes tools for inspecting cluster resources and configurations. 13 | - **log-analysis-ro.yaml**: Log analysis tools for examining and filtering log files. 14 | - **network-diagnostics-ro.yaml**: Network diagnostic tools for testing connectivity and examining network configurations. 15 | - **security-diagnostics-ro.yaml**: Security-related diagnostic tools for system inspection. 16 | - **system-performance-ro.yaml**: Tools for monitoring and analyzing system performance metrics. 17 | 18 | You can add them in Cursor (or any other LLM client with support for MCP tools), and use 19 | it from your AI Chat. 20 | 21 |

22 | chat example 23 |

24 | 25 | For the Kubernetes example, you can aks questions about your current cluster, pods and so on: 26 | 27 |

28 | chat example 29 |

30 | 31 | Some other examples are just for demonstrating the configuration file format and paramters 32 | (like all the `config*yaml`). 33 | 34 | ## Using STDIN with the Agent 35 | 36 | The agent command supports reading from STDIN as part of the prompt. This is useful when you want to 37 | pipe log files, error messages, or other text content for the LLM to analyze. 38 | 39 | Use `-` as a placeholder in the arguments to represent STDIN content: 40 | 41 | ```bash 42 | # Analyze a log file 43 | cat error.log | mcpshell agent --tools log-analysis-ro.yaml \ 44 | "I'm seeing errors in this log file:" - "Please help me understand what went wrong." 45 | 46 | # Debug Kubernetes issues 47 | kubectl logs my-pod | mcpshell agent --tools kubectl-ro.yaml \ 48 | "Here are the logs from a failing pod:" - "What's causing the failure?" 49 | 50 | # Examine system performance 51 | ps aux | mcpshell agent --tools system-performance-ro.yaml \ 52 | "Current process list:" - "Which processes are using the most resources?" 53 | ``` 54 | 55 | **Note:** When STDIN is used, the agent automatically runs in `--once` mode (single interaction) 56 | since STDIN is no longer available for interactive input. 57 | 58 | ## Creating your own scripts with Cursor 59 | 60 | Most of the examples in this directory have been generated automatically 61 | with Cursor. If you want to create your own toolkit, you can open the Cursor 62 | chat and type something like. 63 | 64 | ```text 65 | Please take a look at the examples found in 66 | https://github.com/inercia/MCPShell/tree/main/examples. 67 | They are YAML files that define groups of tools that can be used by an LLM. 68 | The configuration format is defined in 69 | https://github.com/inercia/MCPShell/blob/main/docs/config.md 70 | Please create a new configuration file for running [YOUR COMMAND]. 71 | Add constraints in order to make the command execution safe, 72 | checking paramters and so on. 73 | Provide only read-only commands, do not allow the execution 74 | of code with side effects. 75 | Validate the example generated with 76 | "go run github.com/inercia/MCPShell@v0.1.8 validate --tools FILENAME" 77 | where FILENAME is the configuration file you have created. 78 | If some errors are detected by the validation process, please try to fix them 79 | until the validation is successful. 80 | ``` 81 | 82 | Once that Cursor has generated a configuration file, run the 83 | `mcpshell validate` command in order to validate the file. 84 | If it doesn't validate, pass the errors to Cursor (or allow 85 | Cursor to run this command automatically). Cursor should be able 86 | to fix these errors. 87 | 88 | Please submit your toolkit to this repository if you consider 89 | it useful for the community. 90 | -------------------------------------------------------------------------------- /pkg/command/runner_firejail_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func TestNewRunnerFirejail(t *testing.T) { 12 | // Skip on non-Linux platforms 13 | if runtime.GOOS != "linux" { 14 | t.Skip("Skipping firejail tests on non-Linux platform") 15 | } 16 | 17 | options := RunnerOptions{ 18 | "allow_networking": true, 19 | } 20 | 21 | runner, err := NewRunnerFirejail(options, nil) 22 | if err != nil { 23 | t.Fatalf("Failed to create firejail runner: %v", err) 24 | } 25 | 26 | if runner == nil { 27 | t.Fatal("Expected non-nil runner") 28 | } 29 | } 30 | 31 | func TestRunnerFirejailRun(t *testing.T) { 32 | // Skip on non-Linux platforms 33 | if runtime.GOOS != "linux" { 34 | t.Skip("Skipping firejail tests on non-Linux platform") 35 | } 36 | 37 | // Skip if firejail is not installed 38 | if _, err := os.Stat("/usr/bin/firejail"); os.IsNotExist(err) { 39 | t.Skip("Skipping test because firejail is not installed") 40 | } 41 | 42 | options := RunnerOptions{ 43 | "allow_networking": true, 44 | } 45 | 46 | runner, err := NewRunnerFirejail(options, nil) 47 | if err != nil { 48 | t.Fatalf("Failed to create firejail runner: %v", err) 49 | } 50 | 51 | ctx := context.Background() 52 | 53 | // Test simple echo command 54 | output, err := runner.Run(ctx, "/bin/sh", "echo hello world", nil, nil, false) // No need for tmpfile here 55 | if err != nil { 56 | t.Fatalf("Failed to run command: %v", err) 57 | } 58 | 59 | if output != "hello world\n" { 60 | t.Errorf("Expected 'hello world\\n', got '%s'", output) 61 | } 62 | } 63 | 64 | func TestRunnerFirejailNetworkRestriction(t *testing.T) { 65 | // Skip on non-Linux platforms 66 | if runtime.GOOS != "linux" { 67 | t.Skip("Skipping firejail tests on non-Linux platform") 68 | } 69 | 70 | // Skip if firejail is not installed 71 | if _, err := os.Stat("/usr/bin/firejail"); os.IsNotExist(err) { 72 | t.Skip("Skipping test because firejail is not installed") 73 | } 74 | 75 | ctx := context.Background() 76 | 77 | // Test with networking enabled 78 | networkEnabledOptions := RunnerOptions{ 79 | "allow_networking": true, 80 | } 81 | 82 | runnerEnabled, err := NewRunnerFirejail(networkEnabledOptions, nil) 83 | if err != nil { 84 | t.Fatalf("Failed to create firejail runner: %v", err) 85 | } 86 | 87 | // This might succeed or fail depending on network connectivity, 88 | // but it should not be blocked by firejail 89 | _, _ = runnerEnabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false) // No need for tmpfile here 90 | 91 | // Test with networking disabled 92 | networkDisabledOptions := RunnerOptions{ 93 | "allow_networking": false, 94 | } 95 | 96 | runnerDisabled, err := NewRunnerFirejail(networkDisabledOptions, nil) 97 | if err != nil { 98 | t.Fatalf("Failed to create firejail runner: %v", err) 99 | } 100 | 101 | // This should fail or timeout due to network restrictions 102 | // Note: We're not asserting the exact behavior as it might vary based on firejail version 103 | _, _ = runnerDisabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false) // No need for tmpfile here 104 | } 105 | 106 | func TestRunnerFirejail_Optimization_SingleExecutable(t *testing.T) { 107 | if runtime.GOOS != "linux" { 108 | t.Skip("Skipping firejail tests on non-Linux platform") 109 | } 110 | if _, err := os.Stat("/usr/bin/firejail"); os.IsNotExist(err) { 111 | t.Skip("Skipping test because firejail is not installed") 112 | } 113 | runner, err := NewRunnerFirejail(RunnerOptions{"allow_networking": true}, nil) 114 | if err != nil { 115 | t.Fatalf("Failed to create firejail runner: %v", err) 116 | } 117 | // Should succeed: /bin/ls is a single executable 118 | output, err := runner.Run(context.Background(), "", "/bin/ls", nil, nil, false) 119 | if err != nil { 120 | t.Errorf("Expected /bin/ls to run without error, got: %v", err) 121 | } 122 | if len(output) == 0 { 123 | t.Errorf("Expected output from /bin/ls, got empty string") 124 | } 125 | // Should NOT optimize: command with arguments 126 | _, err2 := runner.Run(context.Background(), "", "/bin/ls -l", nil, nil, false) 127 | if err2 != nil && !strings.Contains(err2.Error(), "no such file") { 128 | t.Logf("Expected failure for /bin/ls -l as a single executable: %v", err2) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/exe/test_exe_timeout.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for the mcpshell timeout functionality 3 | # Tests that commands respect the configured timeout values 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | ##################################################################################### 11 | # Test configuration 12 | TOOLS_FILE="$SCRIPT_DIR/test_exe_timeout.yaml" 13 | TEST_NAME="test_exe_timeout" 14 | 15 | ##################################################################################### 16 | # Start the test 17 | 18 | testcase "$TEST_NAME" 19 | 20 | info_blue "Configuration file: $TOOLS_FILE" 21 | separator 22 | 23 | # Make sure we have the CLI binary 24 | check_cli_exists 25 | 26 | ##################################################################################### 27 | info "Test 1: Quick command (should complete successfully)" 28 | separator 29 | 30 | CMD="$CLI_BIN exe --tools $TOOLS_FILE quick_command" 31 | info "Executing: $CMD" 32 | 33 | START_TIME=$(date +%s) 34 | OUTPUT=$(eval "$CMD" 2>&1) 35 | RESULT=$? 36 | END_TIME=$(date +%s) 37 | ELAPSED=$((END_TIME - START_TIME)) 38 | 39 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (quick_command):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 40 | 41 | [ $RESULT -eq 0 ] || fail "Quick command failed with exit code: $RESULT" "$OUTPUT" 42 | echo "$OUTPUT" | grep -q "Quick command completed successfully" || fail "Expected output not found" "$OUTPUT" 43 | 44 | success "Quick command completed in ${ELAPSED}s" 45 | 46 | ##################################################################################### 47 | separator 48 | info "Test 2: Slow command with short timeout (should timeout after ~2s)" 49 | separator 50 | 51 | CMD="$CLI_BIN exe --tools $TOOLS_FILE slow_command_short_timeout" 52 | info "Executing: $CMD" 53 | 54 | START_TIME=$(date +%s) 55 | OUTPUT=$(eval "$CMD" 2>&1) 56 | RESULT=$? 57 | END_TIME=$(date +%s) 58 | ELAPSED=$((END_TIME - START_TIME)) 59 | 60 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (slow_command_short_timeout):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 61 | 62 | # Command should fail due to timeout 63 | [ $RESULT -ne 0 ] || fail "Slow command should have timed out but succeeded" "$OUTPUT" 64 | 65 | # Should complete in roughly 2-3 seconds (not the full 10 seconds) 66 | if [ $ELAPSED -ge 8 ]; then 67 | fail "Command took ${ELAPSED}s but should have timed out after ~2s" "$OUTPUT" 68 | fi 69 | 70 | success "Slow command correctly timed out after ${ELAPSED}s (expected ~2s)" 71 | 72 | ##################################################################################### 73 | separator 74 | info "Test 3: Command with long timeout (should complete successfully)" 75 | separator 76 | 77 | CMD="$CLI_BIN exe --tools $TOOLS_FILE command_with_long_timeout" 78 | info "Executing: $CMD" 79 | 80 | START_TIME=$(date +%s) 81 | OUTPUT=$(eval "$CMD" 2>&1) 82 | RESULT=$? 83 | END_TIME=$(date +%s) 84 | ELAPSED=$((END_TIME - START_TIME)) 85 | 86 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (command_with_long_timeout):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 87 | 88 | [ $RESULT -eq 0 ] || fail "Command with long timeout failed with exit code: $RESULT" "$OUTPUT" 89 | echo "$OUTPUT" | grep -q "Completed successfully" || fail "Expected output not found" "$OUTPUT" 90 | 91 | success "Command with long timeout completed in ${ELAPSED}s" 92 | 93 | ##################################################################################### 94 | separator 95 | info "Test 4: Command without timeout (should use default)" 96 | separator 97 | 98 | CMD="$CLI_BIN exe --tools $TOOLS_FILE no_timeout_command" 99 | info "Executing: $CMD" 100 | 101 | START_TIME=$(date +%s) 102 | OUTPUT=$(eval "$CMD" 2>&1) 103 | RESULT=$? 104 | END_TIME=$(date +%s) 105 | ELAPSED=$((END_TIME - START_TIME)) 106 | 107 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (no_timeout_command):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 108 | 109 | [ $RESULT -eq 0 ] || fail "Command without timeout failed with exit code: $RESULT" "$OUTPUT" 110 | echo "$OUTPUT" | grep -q "Done" || fail "Expected output not found" "$OUTPUT" 111 | 112 | success "Command without timeout completed in ${ELAPSED}s" 113 | 114 | ##################################################################################### 115 | separator 116 | success "All timeout tests passed!" 117 | exit 0 118 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # MCPShell Tests 2 | 3 | This directory contains end-to-end tests for MCPShell, organized by functionality. 4 | 5 | ## Directory Structure 6 | 7 | ```text 8 | tests/ 9 | ├── agent/ # Agent functionality tests 10 | │ ├── test_agent.sh # Main agent test script 11 | │ └── tools/ # Agent tools configurations 12 | │ └── test_agent.yaml # Agent test configuration 13 | ├── exe/ # Direct tool execution tests 14 | │ ├── test_exe.sh # Basic exe command test 15 | │ ├── test_exe_empty_file.sh # Empty file creation test 16 | │ ├── test_exe_constraints.sh # Constraint validation test 17 | │ └── test_exe_config.yaml # Tool configuration for exe tests 18 | ├── runners/ # Runner-specific tests 19 | │ ├── test_runner_docker.sh # Docker runner tests 20 | │ └── test_runner_docker.yaml # Docker runner configuration 21 | ├── common/ # Shared utilities and fixtures 22 | │ ├── common.sh # Common test utilities 23 | │ ├── test_prompt.json # Test prompt fixtures 24 | │ └── test_response.json # Test response fixtures 25 | └── run_tests.sh # Main test runner script 26 | ``` 27 | 28 | ## Running Tests 29 | 30 | ### Run All Tests 31 | 32 | ```bash 33 | cd tests 34 | ./run_tests.sh 35 | ``` 36 | 37 | ### Run Specific Test Categories 38 | 39 | ```bash 40 | # Agent tests 41 | cd tests/agent 42 | ./test_agent.sh 43 | 44 | # Exe command tests 45 | cd tests/exe 46 | ./test_exe.sh 47 | ./test_exe_empty_file.sh 48 | ./test_exe_constraints.sh 49 | 50 | # Runner tests 51 | cd tests/runners 52 | ./test_runner_docker.sh 53 | ``` 54 | 55 | ## Test Categories 56 | 57 | ### Agent Tests (`agent/`) 58 | 59 | Tests the interactive agent functionality that uses LLMs to interact with tools. 60 | 61 | - **test_agent_config.sh**: Tests agent configuration management commands (`config show`) 62 | - **test_agent_info.sh**: Tests agent info command with various flags 63 | - **test_agent.sh**: Tests agent initialization, tool calling, and file creation 64 | - **tools/test_agent.yaml**: Agent test configuration (uses MCPSHELL_TOOLS_DIR) 65 | 66 | **Note**: The agent test uses the `MCPSHELL_TOOLS_DIR` environment variable to specify 67 | the tools directory, demonstrating how MCPShell can load configurations from custom 68 | directories. 69 | 70 | **LLM Availability**: The agent tests use `mcpshell agent info --check` to verify LLM 71 | connectivity before running. If no LLM is available, the tests will skip gracefully 72 | with a clear message explaining how to set up an LLM for testing. 73 | 74 | ### Exe Tests (`exe/`) 75 | 76 | Tests direct tool execution without the agent (using the `exe` command). 77 | 78 | - **test_exe.sh**: Basic tool execution and file creation 79 | - **test_exe_empty_file.sh**: Tests default content handling for empty files 80 | - **test_exe_constraints.sh**: Tests that constraints are properly enforced 81 | - **test_exe_timeout.sh**: Tests that command timeouts work correctly 82 | 83 | ### Runner Tests (`runners/`) 84 | 85 | Tests different execution environments for tools. 86 | 87 | - **test_runner_docker.sh**: Tests Docker-based tool execution 88 | - **test_runner_sandbox_exec.sh**: Tests sandbox-exec runner (macOS only) 89 | 90 | ### Common Utilities (`common/`) 91 | 92 | Shared utilities and test fixtures used across all tests. 93 | 94 | - **common.sh**: Common functions for test setup, assertions, and utilities 95 | - **test_prompt.json**: Sample prompt data for testing 96 | - **test_response.json**: Sample response data for testing 97 | 98 | ## Adding New Tests 99 | 100 | When adding new tests: 101 | 102 | 1. **Determine the category**: agent, exe, runners, or create a new category 103 | 1. **Create test files in the appropriate subdirectory** 104 | 1. **Update `run_tests.sh`** to include the new test in the TEST_FILES array 105 | 1. **Use common utilities** by sourcing `../common/common.sh` (or appropriate path) 106 | 1. **Follow naming conventions**: `test_.sh` for scripts 107 | 108 | ## Test Dependencies 109 | 110 | - All test scripts depend on the built `mcpshell` binary in `../build/mcpshell` 111 | - Agent tests require an LLM endpoint (default: local Ollama at `http://localhost:11434/v1`) 112 | - If no LLM is available, agent tests will be skipped gracefully 113 | - The tests use `mcpshell agent info --check` to verify LLM connectivity 114 | - Configure via environment variables: `MODEL`, `OPENAI_API_BASE`, `OPENAI_API_KEY` 115 | - Docker tests require Docker to be installed and running 116 | - All tests use the shared utilities in `common/common.sh` 117 | -------------------------------------------------------------------------------- /tests/runners/test_runner_sandbox_exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Source common utilities 4 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 5 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 6 | source "$TESTS_ROOT/common/common.sh" 7 | 8 | # Configuration file for this test 9 | CONFIG_FILE="$SCRIPT_DIR/test_runner_sandbox_exec.yaml" 10 | TEST_NAME="test_runner_sandbox_exec" 11 | 12 | ##################################################################################### 13 | # Start the test 14 | 15 | testcase "$TEST_NAME" 16 | 17 | info "Testing sandbox-exec runner with config: $CONFIG_FILE" 18 | 19 | separator 20 | info "1. Checking if running on macOS" 21 | separator 22 | 23 | # Check if we're on macOS 24 | OS_TYPE=$(uname -s) 25 | if [ "$OS_TYPE" != "Darwin" ]; then 26 | skip "sandbox-exec is only available on macOS (detected: $OS_TYPE), skipping test" 27 | fi 28 | 29 | success "Running on macOS ($OS_TYPE), proceeding with tests" 30 | 31 | separator 32 | info "2. Checking if sandbox-exec is installed" 33 | separator 34 | 35 | # Check if sandbox-exec is installed 36 | command_exists sandbox-exec || skip "sandbox-exec not found, skipping test" 37 | success "sandbox-exec is available, proceeding with tests" 38 | 39 | # Make sure we have the CLI binary 40 | check_cli_exists 41 | 42 | separator 43 | info "3. Simple hello world in sandbox-exec" 44 | separator 45 | 46 | CMD="$CLI_BIN --tools $CONFIG_FILE exe sandbox_hello" 47 | info "Executing: $CMD" 48 | OUTPUT=$(eval "$CMD" 2>&1) 49 | RESULT=$? 50 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (sandbox_hello):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 51 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 52 | echo "$OUTPUT" | grep -q "Hello from sandbox-exec" || fail "Test failed: Expected output not found" "$OUTPUT" 53 | 54 | success "Test passed" 55 | 56 | separator 57 | info "4. Reading files with proper permissions" 58 | separator 59 | 60 | CMD="$CLI_BIN --tools $CONFIG_FILE exe sandbox_read_file" 61 | info "Executing: $CMD" 62 | OUTPUT=$(eval "$CMD" 2>&1) 63 | RESULT=$? 64 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (sandbox_read_file):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 65 | 66 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 67 | echo "$OUTPUT" | grep -q "Can read /etc files" || fail "Test failed: Expected output not found" "$OUTPUT" 68 | 69 | success "Test passed" 70 | 71 | separator 72 | info "5. Writing to /tmp with proper permissions" 73 | separator 74 | 75 | RANDOM_FILE="sandbox_test_$(date +%s).txt" 76 | CMD="$CLI_BIN --tools $CONFIG_FILE exe sandbox_with_write filename=$RANDOM_FILE" 77 | info "Executing: $CMD" 78 | OUTPUT=$(eval "$CMD" 2>&1) 79 | RESULT=$? 80 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (sandbox_with_write):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 81 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 82 | echo "$OUTPUT" | grep -q "Test content" || fail "Test failed: Expected output not found" "$OUTPUT" 83 | 84 | success "Test passed" 85 | 86 | separator 87 | info "6. Timeout functionality with sandbox-exec" 88 | separator 89 | 90 | CMD="$CLI_BIN --tools $CONFIG_FILE exe sandbox_with_timeout" 91 | info "Executing: $CMD" 92 | 93 | START_TIME=$(date +%s) 94 | OUTPUT=$(eval "$CMD" 2>&1) 95 | RESULT=$? 96 | END_TIME=$(date +%s) 97 | ELAPSED=$((END_TIME - START_TIME)) 98 | 99 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (sandbox_with_timeout):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 100 | 101 | # Command should fail due to timeout 102 | [ $RESULT -ne 0 ] || fail "Command should have timed out but succeeded" "$OUTPUT" 103 | 104 | # Should complete in roughly 2-3 seconds (not the full 10 seconds) 105 | if [ $ELAPSED -ge 8 ]; then 106 | fail "Command took ${ELAPSED}s but should have timed out after ~2s" "$OUTPUT" 107 | fi 108 | 109 | success "Timeout test passed (completed in ${ELAPSED}s, expected ~2s)" 110 | 111 | separator 112 | info "7. Network access is properly blocked" 113 | separator 114 | 115 | # Only run this test if curl is available 116 | if command_exists curl; then 117 | CMD="$CLI_BIN --tools $CONFIG_FILE exe sandbox_network_blocked" 118 | info "Executing: $CMD" 119 | OUTPUT=$(eval "$CMD" 2>&1) 120 | RESULT=$? 121 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME (sandbox_network_blocked):\n\n$OUTPUT" >> "$E2E_LOG_FILE" 122 | [ $RESULT -eq 0 ] || fail "Test failed with exit code $RESULT" "$OUTPUT" 123 | echo "$OUTPUT" | grep -q "Network access blocked as expected" || fail "Test failed: Expected output not found" "$OUTPUT" 124 | 125 | success "Test passed" 126 | else 127 | warning "curl not available, skipping network test" 128 | fi 129 | 130 | echo 131 | success "All sandbox-exec runner tests passed!" 132 | exit 0 133 | -------------------------------------------------------------------------------- /examples/sandbox-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configuration for MCPShell with sandbox runner examples 3 | # 4 | # This configuration demonstrates how to use different runners for command execution, 5 | # including the macOS sandbox-exec runner and Linux firejail for enhanced security. 6 | 7 | mcp: 8 | tools: 9 | # Example 1: Simple hello world using default exec runner 10 | - name: hello_world 11 | description: Say hello to someone 12 | params: 13 | name: 14 | description: Name of the person to greet 15 | type: string 16 | required: true 17 | run: 18 | timeout: "30s" 19 | command: echo "Hello, {{.name}}!" 20 | 21 | # Example 2: Command using sandboxed runners 22 | - name: secure_echo 23 | description: Echo text in a sandboxed environment 24 | params: 25 | text: 26 | description: Text to echo 27 | type: string 28 | required: true 29 | run: 30 | timeout: "30s" 31 | command: 'echo "Sandboxed echo: {{.text}}"' 32 | runners: 33 | - name: sandbox-exec 34 | requirements: 35 | os: darwin 36 | executables: [sandbox-exec] 37 | - name: firejail 38 | requirements: 39 | os: linux 40 | executables: [firejail] 41 | 42 | # Example 3: Command execution in sandbox with restricted networking 43 | - name: network_check 44 | description: Try to access the network in a sandboxed environment 45 | params: 46 | url: 47 | description: URL to check (will fail) 48 | type: string 49 | required: true 50 | run: 51 | timeout: "30s" 52 | command: 'curl --max-time 5 {{.url}}' 53 | runners: 54 | - name: sandbox-exec 55 | requirements: 56 | os: darwin 57 | executables: [sandbox-exec, curl] 58 | options: 59 | allow_networking: false 60 | - name: firejail 61 | requirements: 62 | os: linux 63 | executables: [firejail, curl] 64 | options: 65 | allow_networking: false 66 | constraints: 67 | - 'true' 68 | 69 | # Example 4: File system access with restrictions 70 | - name: file_browser 71 | description: List files in a directory (with restrictions) 72 | params: 73 | directory: 74 | description: Directory to list 75 | type: string 76 | required: true 77 | constraints: 78 | - 'directory.matches("^/tmp|^/var/tmp")' 79 | run: 80 | timeout: "30s" 81 | command: 'ls -la {{.directory}}' 82 | runners: 83 | - name: sandbox-exec 84 | requirements: 85 | os: darwin 86 | executables: [sandbox-exec] 87 | options: 88 | allow_user_folders: false 89 | allow_read_folders: 90 | - "/tmp" 91 | - "/var/tmp" 92 | - name: firejail 93 | requirements: 94 | os: linux 95 | executables: [firejail] 96 | options: 97 | allow_user_folders: false 98 | allow_read_folders: 99 | - "/tmp" 100 | - "/var/tmp" 101 | 102 | # Example 5: Custom sandbox profile with specific permissions 103 | - name: custom_sandbox 104 | description: Run with a custom sandbox profile 105 | params: 106 | command: 107 | description: Command to run 108 | type: string 109 | required: true 110 | run: 111 | timeout: "30s" 112 | command: '{{.command}}' 113 | runners: 114 | - name: sandbox-exec 115 | requirements: 116 | os: darwin 117 | executables: [sandbox-exec] 118 | options: 119 | allow_networking: false 120 | allow_user_folders: false 121 | custom_profile: | 122 | (version 1) 123 | (allow default) 124 | (deny network*) 125 | (allow file-read* (regex "^/tmp")) 126 | - name: firejail 127 | requirements: 128 | os: linux 129 | executables: [firejail] 130 | options: 131 | allow_networking: false 132 | allow_user_folders: false 133 | custom_profile: | 134 | # Firejail equivalent profile 135 | net none 136 | noroot 137 | whitelist /tmp 138 | read-only /tmp 139 | constraints: 140 | - 'command.matches("^ls|^pwd|^echo")' 141 | -------------------------------------------------------------------------------- /docs/security.md: -------------------------------------------------------------------------------- 1 | # Security Considerations 2 | 3 | ## ⚠️ WARNING: Potential Security Risks 4 | 5 | The MCPShell allows Large Language Models (LLMs) to execute commands on your local machine. This functionality comes with significant security implications that all users must understand before deployment. 6 | 7 | ## Primary Security Concerns 8 | 9 | ### Command Execution Risks 10 | 11 | When you allow an LLM to execute shell commands on your system: 12 | 13 | - **Data Destruction**: An LLM could issue commands that delete files or directories (`rm -rf`, etc.) 14 | - **Data Exfiltration**: Commands could be used to send your sensitive data to external servers 15 | - **System Modification**: System configurations could be altered in harmful ways 16 | - **Resource Exhaustion**: Commands could be designed to consume excessive CPU, memory, or disk space 17 | - **Privilege Escalation**: If running with elevated permissions, the damage potential increases significantly 18 | 19 | ### Parameter Injection Risks 20 | 21 | Even with "safe" commands, parameters can be dangerous: 22 | 23 | - **Path Traversal**: Parameters containing `../` sequences could access files outside expected directories 24 | - **Command Injection**: Parameters containing shell metacharacters (`;`, `&&`, `|`, etc.) could execute additional unintended commands 25 | - **Resource Overloading**: Parameters designed to trigger excessive resource usage (e.g., extremely large file sizes) 26 | 27 | ## Best Practices for Secure Usage 28 | 29 | ### 1. Prefer Read-Only Commands 30 | 31 | Whenever possible, limit LLM-executed commands to those without side effects: 32 | 33 | ✅ **Safer Commands** (examples): 34 | 35 | - `ls`, `dir` - List directory contents 36 | - `cat`, `type` - View file contents 37 | - `grep`, `find` - Search operations 38 | - `ps`, `top` - Process information 39 | 40 | ❌ **Higher Risk Commands** to avoid or restrict heavily: 41 | 42 | - `rm`, `del` - Delete files 43 | - `mv`, `move` - Move files 44 | - `chmod` - Change permissions 45 | - Any command that writes to disk or modifies system state 46 | 47 | ### 2. Implement Strict Constraints 48 | 49 | Always define and enforce constraints on commands: 50 | 51 | - **Allowlist-based approach**: Only permit specific, pre-approved commands 52 | - **Directory restrictions**: Limit file operations to specific directories 53 | - **Command pattern validation**: Ensure commands match expected patterns before execution 54 | - **Parameter validation**: Validate all parameters against strict rules 55 | 56 | ### 3. Parameter Validation 57 | 58 | Add validation for all command parameters: 59 | 60 | - **Type checking**: Ensure parameters are of expected types 61 | - **Range validation**: For numeric parameters, ensure they fall within safe ranges 62 | - **Pattern matching**: For string parameters, validate against strict patterns 63 | - **Size limitations**: Restrict the size of inputs to prevent resource exhaustion 64 | - **Character filtering**: Sanitize inputs to remove potentially dangerous characters 65 | 66 | Example constraint approach: 67 | 68 | ```yaml 69 | constraints: 70 | - "value.length < 100" 71 | - "not value.includes('..')" 72 | - "not value.includes(';'" 73 | } 74 | ``` 75 | 76 | ### 4. Use the Restricted _runners_ 77 | 78 | - Use one of the restricted [runners](config-runners.md) 79 | - Limit the directories and files the runner can access. 80 | 81 | ### 5. Run with Minimal Privileges 82 | 83 | - Run the adapter with the least privileges necessary 84 | - Create a dedicated user account with limited permissions 85 | - Use containerization when possible to isolate execution 86 | 87 | ### 6. Audit and Monitor 88 | 89 | - Log all commands executed by the LLM 90 | - Regularly review logs for suspicious activity 91 | - Implement alerting for potentially dangerous commands 92 | 93 | ## Disclaimer of Liability 94 | 95 | **THE MCPShell IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED.** 96 | 97 | By using the MCPShell, you acknowledge and accept all risks associated with allowing an LLM to execute commands on your system. The developers, contributors, and associated organizations are not responsible for any damage, data loss, security breaches, or other negative consequences resulting from the use of this software. 98 | 99 | It is your responsibility to: 100 | 101 | 1. Understand the security implications of each command you allow 102 | 1. Implement appropriate constraints and validations 103 | 1. Monitor system activity and respond to suspicious behavior 104 | 1. Maintain regular backups of important data 105 | 1. Deploy in a manner consistent with your own security requirements 106 | 107 | **If you cannot accept these risks, do not use the MCPShell for command execution.** 108 | 109 | ## Reporting Security Issues 110 | 111 | If you discover security vulnerabilities in the MCPShell, please report them responsibly by [creating an issue](https://github.com/inercia/MCPShell/issues) with appropriate security labels. 112 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean test run lint lint-golangci format validate-examples docs-update-tags help release test-e2e 2 | 3 | # Binary name 4 | BINARY_NAME=mcpshell 5 | # Build directory 6 | BUILD_DIR=build 7 | 8 | # Go related variables 9 | GOBASE=$(shell pwd) 10 | GOBIN=$(GOBASE)/$(BUILD_DIR) 11 | 12 | # Default target 13 | all: build 14 | 15 | # Build the application 16 | build: 17 | @echo ">>> Building $(BINARY_NAME)..." 18 | @mkdir -p $(GOBIN) 19 | @go build -o $(GOBIN)/$(BINARY_NAME) . 20 | @echo ">>> ... $(BINARY_NAME) built successfully" 21 | 22 | # Clean build artifacts 23 | clean: 24 | @echo ">>> Cleaning..." 25 | @rm -rf $(BUILD_DIR) 26 | 27 | # Run tests 28 | test: 29 | @echo ">>> Running tests..." 30 | @go test -v ./... 31 | @echo ">>> ... tests completed successfully" 32 | 33 | # Run the exe command tests 34 | test-e2e: 35 | @echo ">>> Running exe command tests..." 36 | @if [ ! -x "$(GOBIN)/$(BINARY_NAME)" ]; then \ 37 | echo ">>> $(GOBIN)/$(BINARY_NAME) not found. Building..."; \ 38 | $(MAKE) build; \ 39 | fi 40 | @chmod +x tests/*.sh 41 | @tests/run_tests.sh 42 | @echo ">>> ... exe command tests completed" 43 | 44 | # Run the application 45 | run: 46 | @go run main.go 47 | 48 | # Install the application 49 | install: 50 | @echo ">>> Installing $(BINARY_NAME)..." 51 | @go install . 52 | @echo ">>> ... $(BINARY_NAME) installed successfully" 53 | 54 | # Run linting (golangci-lint) 55 | lint: lint-golangci 56 | 57 | # Run golangci-lint (comprehensive linting) 58 | lint-golangci: 59 | @echo ">>> Running golangci-lint..." 60 | @if command -v golangci-lint >/dev/null 2>&1; then \ 61 | golangci-lint run; \ 62 | else \ 63 | echo "golangci-lint not found. Installing..."; \ 64 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest; \ 65 | golangci-lint run; \ 66 | fi 67 | @echo ">>> ... golangci-lint completed successfully" 68 | 69 | # Legacy linting - deprecated but kept for backward compatibility 70 | lint-legacy: 71 | @echo ">>> Running legacy linting (golint)..." 72 | @golint ./... 73 | @echo ">>> ... legacy linting completed successfully" 74 | 75 | # Format code 76 | format: 77 | @echo ">>> Formatting Go code..." 78 | @go fmt ./... 79 | @go mod tidy 80 | @echo ">>> ... code formatted successfully" 81 | 82 | format-md: 83 | @echo ">>> Formatting Markdown code..." 84 | @find . -name "*.md" -type f -exec mdformat {} \; 85 | @echo ">>> ... markdown code formatted successfully" 86 | 87 | # Validate all YAML configuration files in examples directory 88 | validate-examples: build 89 | @echo ">>> Validating example YAML configurations..." 90 | @find examples -name "*.yaml" -type f | while read file; do \ 91 | echo "--------------------------------------------------------------"; \ 92 | echo ">>> Validating $$file..."; \ 93 | $(GOBIN)/$(BINARY_NAME) validate --tools $$file || exit 1; \ 94 | done 95 | @echo ">>>" 96 | @echo ">>> ... all example configurations validated SUCCESSFULLY !!!" 97 | 98 | # Automated release process 99 | release: 100 | @echo ">>> Checking repository status..." 101 | @if [ -n "$$(git status --porcelain --untracked-files=no)" ]; then \ 102 | echo "Error: Repository has uncommitted changes. Please commit or stash them first."; \ 103 | exit 1; \ 104 | fi 105 | @echo ">>> Repository is clean." 106 | @echo ">>> Existing tags:" 107 | @git tag -l | sort -V 108 | @echo "" 109 | @read -p "Enter new version tag (e.g., v1.2.3): " TAG; \ 110 | echo ">>> Using tag: $$TAG"; \ 111 | echo ">>> Updating version tags in documentation..."; \ 112 | find . -name "*.md" -type f -exec sed -i.bak -E "s|github.com/inercia/MCPShell@v[0-9]+\.[0-9]+\.[0-9]+|github.com/inercia/MCPShell@$$TAG|g" {} \; -exec rm {}.bak \; ; \ 113 | echo ">>> Documentation version tags updated successfully"; \ 114 | echo ">>> Adding and committing documentation changes..."; \ 115 | git add -u ; \ 116 | git commit -m "Release $$TAG"; \ 117 | echo ">>> Creating git tag..."; \ 118 | git tag -a "$$TAG" -m "Version $$TAG"; \ 119 | echo ">>> Tag '$$TAG' created successfully."; \ 120 | echo ""; \ 121 | echo "To push the tag and documentation changes, run:"; \ 122 | echo " git push origin main $$TAG" 123 | 124 | # Show help 125 | help: 126 | @echo "Available targets:" 127 | @echo " build - Build the application" 128 | @echo " clean - Remove build artifacts" 129 | @echo " test - Run tests" 130 | @echo " test-e2e - Run end-to-end tests" 131 | @echo " run - Run the application" 132 | @echo " install - Install the application" 133 | @echo " lint - Run linting (alias for lint-golangci)" 134 | @echo " lint-golangci - Run golangci-lint (installs if not present)" 135 | @echo " lint-legacy - Run legacy linting with golint" 136 | @echo " format - Format Go code" 137 | @echo " validate-examples - Validate all YAML configs in examples directory" 138 | @echo " release - Automated release process (creates tag)" 139 | @echo " help - Show this help" -------------------------------------------------------------------------------- /examples/advanced-templates.yaml: -------------------------------------------------------------------------------- 1 | mcp: 2 | description: | 3 | Advanced Templating Examples demonstrating sophisticated Go 4 | template usage within command execution, including conditional logic, 5 | optional parameter handling, complex command construction, and multi-stage 6 | processing pipelines for powerful and flexible tool definitions. 7 | tools: 8 | # ----------------------------------------------- 9 | # Tool: greeter 10 | # 11 | # Template Features Demonstrated: 12 | # - Conditional logic (if/else/end) 13 | # - Optional parameter handling 14 | # - String concatenation in templates 15 | # 16 | # This tool shows how to use if/else conditionals 17 | # to format output differently based on whether 18 | # an optional parameter (title) is provided. 19 | # ----------------------------------------------- 20 | - name: "greeter" 21 | description: "Greets a person with conditional formatting" 22 | params: 23 | name: 24 | type: string 25 | description: "Name of the person to greet" 26 | required: true 27 | title: 28 | type: string 29 | description: "Optional title (Mr, Mrs, Dr, etc.)" 30 | constraints: 31 | - "name.size() >= 2 && name.size() <= 50" # Name length between 2 and 50 chars 32 | - "title == '' || ['Mr', 'Mrs', 'Ms', 'Dr', 'Prof'].exists(t, t == title)" # Only allowed titles 33 | run: 34 | timeout: "5s" 35 | command: "echo '{{ if .title }}Hello, {{ .title }} {{ .name }}!{{ else }}Hello, {{ .name }}!{{ end }}'" 36 | 37 | # ----------------------------------------------- 38 | # Tool: file_finder 39 | # 40 | # Template Features Demonstrated: 41 | # - Multiple conditionals in a single command 42 | # - Boolean parameter handling 43 | # - Conditional command flag inclusion 44 | # - String interpolation with quotes 45 | # 46 | # This tool demonstrates how to build a complex 47 | # command with multiple conditional parts based 48 | # on provided parameters. It showcases how to 49 | # conditionally include command-line flags and 50 | # properly handle quoted string parameters. 51 | # ----------------------------------------------- 52 | - name: "file_finder" 53 | description: "Find files with a given extension" 54 | params: 55 | directory: 56 | type: string 57 | description: "Directory to search in" 58 | required: true 59 | extension: 60 | type: string 61 | description: "File extension to search for (without the dot)" 62 | recursive: 63 | type: boolean 64 | description: "Whether to search recursively" 65 | constraints: 66 | - "directory.startsWith('/') || directory.startsWith('./')" # Must be absolute or relative path 67 | - "!directory.contains('../')" # Prevent directory traversal 68 | - "!directory.contains('~')" # Prevent home directory expansion 69 | - "extension == '' || extension.matches('^[a-zA-Z0-9]+$')" # Extension must be alphanumeric if provided 70 | run: 71 | timeout: "30s" 72 | command: "find {{ .directory }} {{ if .recursive }}-type f{{ else }}-maxdepth 1 -type f{{ end }} {{ if .extension }}-name \"*.{{ .extension }}\"{{ end }}" 73 | output: 74 | prefix: "Found files:" 75 | 76 | # ----------------------------------------------- 77 | # Tool: conditional_formatter 78 | # 79 | # Template Features Demonstrated: 80 | # - Multi-line command with templating 81 | # - Pipeline processing with conditionals 82 | # - Boolean flags controlling command behavior 83 | # - Command substitution based on parameters 84 | # 85 | # This tool shows how to create a processing pipeline 86 | # where each step in the pipeline is conditionally 87 | # included or replaced based on boolean parameters. 88 | # It demonstrates using templates in a multi-line 89 | # command with proper shell escaping. 90 | # ----------------------------------------------- 91 | - name: "conditional_formatter" 92 | description: "Format text based on user preferences" 93 | params: 94 | text: 95 | type: string 96 | description: "Text to format" 97 | required: true 98 | uppercase: 99 | type: boolean 100 | description: "Convert to uppercase" 101 | reverse: 102 | type: boolean 103 | description: "Reverse the text" 104 | constraints: 105 | - "text.startsWith('text:')" # Ensure text starts with the 'text:' prefix 106 | - "text.size() > 6" # Text must be longer than just the prefix 107 | - "text.size() <= 200" # Limit text size 108 | - "!text.matches('.*[;&|`].*')" # Prevent shell injection 109 | run: 110 | timeout: "10s" 111 | command: | 112 | echo '{{ .text }}' | \ 113 | {{ if .uppercase }}tr '[:lower:]' '[:upper:]'{{ else }}cat{{ end }} | \ 114 | {{ if .reverse }}rev{{ else }}cat{{ end }}" -------------------------------------------------------------------------------- /cmd/mcp.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "syscall" 8 | 9 | "github.com/inercia/MCPShell/pkg/common" 10 | "github.com/inercia/MCPShell/pkg/config" 11 | "github.com/inercia/MCPShell/pkg/server" 12 | "github.com/inercia/MCPShell/pkg/utils" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | var ( 17 | useHTTP bool 18 | httpPort int 19 | daemon bool 20 | ) 21 | 22 | // mcpCommand represents the run command which starts the MCP server 23 | var mcpCommand = &cobra.Command{ 24 | Use: "mcp", 25 | Aliases: []string{"serve", "server", "run"}, 26 | Short: "Run the MCP server for a MCP configuration file", 27 | Long: ` 28 | Run an MCP server that provides tools to LLM applications. 29 | This command starts a server that communicates using the Model Context Protocol (MCP) 30 | and expooses the tools defined in a MCP configuration file. 31 | 32 | The server loads tool definitions from a MCP configuration file and makes them 33 | available to AI applications via the MCP protocol. 34 | 35 | When using --http mode, you can also use --daemon to run the server in the background 36 | and ignore SIGHUP signals. 37 | `, 38 | PreRunE: func(cmd *cobra.Command, args []string) error { 39 | // Initialize logger 40 | logger, err := initLogger() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | defer common.RecoverPanic() 46 | 47 | logger.Info("Starting MCPShell") 48 | 49 | // Check if config files are provided 50 | if len(toolsFiles) == 0 { 51 | logger.Error("Tools configuration file(s) are required") 52 | return fmt.Errorf("tools configuration file(s) are required. Use --tools flag to specify the path(s)") 53 | } 54 | 55 | // Ensure tools directory exists 56 | if err := utils.EnsureToolsDir(); err != nil { 57 | logger.Error("Failed to ensure tools directory: %v", err) 58 | return fmt.Errorf("failed to ensure tools directory: %w", err) 59 | } 60 | 61 | // Daemon mode is only supported with HTTP mode 62 | if daemon && !useHTTP { 63 | logger.Error("Daemon mode is only supported with HTTP mode") 64 | return fmt.Errorf("daemon mode is only supported with HTTP mode (use --http flag)") 65 | } 66 | 67 | return nil 68 | }, 69 | RunE: func(cmd *cobra.Command, args []string) error { 70 | logger := common.GetLogger() 71 | defer common.RecoverPanic() 72 | 73 | // Daemonize if requested 74 | if daemon { 75 | if err := daemonize(); err != nil { 76 | logger.Error("Failed to daemonize: %v", err) 77 | return fmt.Errorf("failed to daemonize: %w", err) 78 | } 79 | logger.Info("Daemonized successfully") 80 | } 81 | 82 | // Load the configuration file(s) (local or remote) 83 | localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger) 84 | if err != nil { 85 | logger.Error("Failed to load configuration: %v", err) 86 | return fmt.Errorf("failed to load configuration: %w", err) 87 | } 88 | 89 | // Ensure temporary files are cleaned up 90 | defer cleanup() 91 | 92 | // Create and start the server 93 | srv := server.New(server.Config{ 94 | ConfigFile: localConfigPath, 95 | Logger: logger, 96 | Version: version, 97 | Descriptions: description, 98 | DescriptionFiles: descriptionFile, 99 | DescriptionOverride: descriptionOverride, 100 | }) 101 | 102 | if useHTTP { 103 | // Set up SIGHUP handling for daemon mode 104 | if daemon { 105 | setupSIGHUPHandler(logger) 106 | } 107 | return srv.StartHTTP(httpPort) 108 | } 109 | return srv.Start() 110 | }, 111 | } 112 | 113 | // setupSIGHUPHandler sets up signal handling to ignore SIGHUP in daemon mode 114 | func setupSIGHUPHandler(logger *common.Logger) { 115 | sigChan := make(chan os.Signal, 1) 116 | signal.Notify(sigChan, syscall.SIGHUP) 117 | 118 | go func() { 119 | for { 120 | sig := <-sigChan 121 | if sig == syscall.SIGHUP { 122 | logger.Info("Received SIGHUP, ignoring in daemon mode") 123 | } 124 | } 125 | }() 126 | } 127 | 128 | // init adds flags to the run command 129 | func init() { 130 | rootCmd.AddCommand(mcpCommand) 131 | 132 | // Add MCP-specific flags 133 | mcpCommand.Flags().StringSliceVarP(&description, "description", "d", []string{}, "MCP server description (optional, can be specified multiple times)") 134 | mcpCommand.Flags().StringSliceVarP(&descriptionFile, "description-file", "", []string{}, "Read the MCP server description from files (optional, can be specified multiple times)") 135 | mcpCommand.Flags().BoolVarP(&descriptionOverride, "description-override", "", false, "Override the description found in the config file") 136 | 137 | // Add HTTP server flags 138 | mcpCommand.Flags().BoolVar(&useHTTP, "http", false, "Enable HTTP server mode (serve MCP over HTTP/SSE instead of stdio)") 139 | mcpCommand.Flags().IntVar(&httpPort, "port", 8080, "Port for HTTP server (default: 8080, only used with --http)") 140 | mcpCommand.Flags().BoolVar(&daemon, "daemon", false, "Run in daemon mode (background process, ignores SIGHUP, only works with --http)") 141 | 142 | // Mark required flags 143 | _ = mcpCommand.MarkFlagRequired("tools") 144 | } 145 | -------------------------------------------------------------------------------- /tests/examples/test_github-cli-ro.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for github-cli-ro.yaml example 3 | # Tests the gh_raw_file tool with public GitHub repositories 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | ##################################################################################### 11 | # Test configuration 12 | TOOLS_FILE="$SCRIPT_DIR/../../examples/github-cli-ro.yaml" 13 | TEST_NAME="test_github_cli_ro" 14 | 15 | ##################################################################################### 16 | # Start the test 17 | 18 | testcase "$TEST_NAME" 19 | 20 | info_blue "Configuration file: $TOOLS_FILE" 21 | separator 22 | 23 | # Make sure we have the CLI binary 24 | check_cli_exists 25 | 26 | # Check if curl is available (required for gh_raw_file) 27 | if ! command_exists curl; then 28 | skip "curl not found, skipping test" 29 | fi 30 | 31 | ##################################################################################### 32 | # Test 1: Fetch README from torvalds/linux repository 33 | 34 | info "Test 1: Fetching README from torvalds/linux (master branch)" 35 | CMD="$CLI_BIN exe --tools $TOOLS_FILE gh_raw_file repo=torvalds/linux filepath=README ref=master" 36 | info "Executing: $CMD" 37 | 38 | OUTPUT=$(eval "$CMD" 2>&1) 39 | RESULT=$? 40 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 1:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 41 | 42 | if [ $RESULT -ne 0 ]; then 43 | failure "Test 1 failed: Command execution failed with exit code: $RESULT" 44 | echo "$OUTPUT" 45 | exit 1 46 | fi 47 | 48 | # Check if output contains expected content 49 | if echo "$OUTPUT" | grep -q "Linux kernel"; then 50 | success "Test 1 passed: Successfully fetched README from torvalds/linux" 51 | else 52 | failure "Test 1 failed: Output doesn't contain expected content" 53 | echo "$OUTPUT" 54 | exit 1 55 | fi 56 | 57 | separator 58 | 59 | ##################################################################################### 60 | # Test 2: Fetch LICENSE from golang/go repository 61 | 62 | info "Test 2: Fetching LICENSE from golang/go (master branch)" 63 | CMD="$CLI_BIN exe --tools $TOOLS_FILE gh_raw_file repo=golang/go filepath=LICENSE ref=master" 64 | info "Executing: $CMD" 65 | 66 | OUTPUT=$(eval "$CMD" 2>&1) 67 | RESULT=$? 68 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 2:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 69 | 70 | if [ $RESULT -ne 0 ]; then 71 | failure "Test 2 failed: Command execution failed with exit code: $RESULT" 72 | echo "$OUTPUT" 73 | exit 1 74 | fi 75 | 76 | # Check if output contains expected content 77 | if echo "$OUTPUT" | grep -q "Copyright.*Go Authors"; then 78 | success "Test 2 passed: Successfully fetched LICENSE from golang/go" 79 | else 80 | failure "Test 2 failed: Output doesn't contain expected content" 81 | echo "$OUTPUT" 82 | exit 1 83 | fi 84 | 85 | separator 86 | 87 | ##################################################################################### 88 | # Test 3: Test constraint validation (path traversal prevention) 89 | 90 | info "Test 3: Testing path traversal prevention" 91 | CMD="$CLI_BIN exe --tools $TOOLS_FILE gh_raw_file repo=golang/go filepath=../../../etc/passwd ref=master" 92 | info "Executing: $CMD" 93 | 94 | OUTPUT=$(eval "$CMD" 2>&1) 95 | RESULT=$? 96 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 3:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 97 | 98 | # This should fail due to constraint violation 99 | if [ $RESULT -eq 0 ]; then 100 | failure "Test 3 failed: Command should have been blocked by constraints" 101 | echo "$OUTPUT" 102 | exit 1 103 | fi 104 | 105 | # Check if output mentions constraint violation 106 | if echo "$OUTPUT" | grep -q "constraint"; then 107 | success "Test 3 passed: Path traversal correctly blocked by constraints" 108 | else 109 | failure "Test 3 failed: Expected constraint violation message" 110 | echo "$OUTPUT" 111 | exit 1 112 | fi 113 | 114 | separator 115 | 116 | ##################################################################################### 117 | # Test 4: Test with default ref parameter (should use 'main') 118 | 119 | info "Test 4: Testing default ref parameter" 120 | CMD="$CLI_BIN exe --tools $TOOLS_FILE gh_raw_file repo=golang/go filepath=CONTRIBUTING.md" 121 | info "Executing: $CMD (should default to 'main' branch)" 122 | 123 | OUTPUT=$(eval "$CMD" 2>&1) 124 | RESULT=$? 125 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 4:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 126 | 127 | # This might fail if golang/go doesn't have 'main' branch, but that's okay 128 | # We're just testing that the default parameter works 129 | if [ $RESULT -eq 0 ]; then 130 | success "Test 4 passed: Default ref parameter works" 131 | elif echo "$OUTPUT" | grep -q "404"; then 132 | info "Test 4: Got 404 (expected if repo uses 'master' instead of 'main')" 133 | success "Test 4 passed: Default ref parameter was applied (got 404 for 'main' branch)" 134 | else 135 | failure "Test 4 failed: Unexpected error" 136 | echo "$OUTPUT" 137 | exit 1 138 | fi 139 | 140 | separator 141 | 142 | success "All tests passed for github-cli-ro.yaml!" 143 | exit 0 144 | 145 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Package root contains the command-line interface implementation for the MCPShell. 2 | // 3 | // It defines the root command and all subcommands using Cobra and manages CLI flags, 4 | // execution flow, and global application state. 5 | package root 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | 11 | "github.com/inercia/MCPShell/pkg/common" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // ApplicationName is the name of the application used in various places 16 | const ApplicationName = "mcpshell" 17 | 18 | // Common command-line flags 19 | var ( 20 | // Common flags 21 | toolsFiles []string 22 | logFile string 23 | logLevel string 24 | verbose bool 25 | 26 | // MCP server flags 27 | description []string 28 | descriptionFile []string 29 | descriptionOverride bool 30 | 31 | // Agent-specific flags 32 | agentModel string 33 | agentSystemPrompt string 34 | agentUserPrompt string 35 | agentOpenAIApiKey string 36 | agentOpenAIApiURL string 37 | agentOnce bool 38 | 39 | // Application version information (set via SetVersion from main) 40 | version = "dev" 41 | commit = "none" 42 | buildDate = "unknown" 43 | ) 44 | 45 | // rootCmd represents the base command when called without any subcommands 46 | var rootCmd = &cobra.Command{ 47 | Use: ApplicationName, 48 | Short: "MCPShell", 49 | Long: ` 50 | MCPShell is a MCP bridge for LLMs and shell commands. 51 | 52 | This CLI application enables AI systems to securely execute commands through 53 | the Model Context Protocol (MCP). 54 | 55 | Specify your tools configuration using the --tools flag (supports multiple files): 56 | mcpshell --tools /path/to/tools.yaml (single file, absolute path) 57 | mcpshell --tools mytools (single file in global tools directory, adds .yaml) 58 | mcpshell --tools mytools.yaml (single file in tools directory) 59 | mcpshell --tools /some/dir (load all tools in the directory) 60 | mcpshell --tools file1.yaml --tools file2.yaml (multiple files) 61 | mcpshell --tools file1.yaml,file2.yaml (multiple files, comma-separated) 62 | 63 | The tools directory defaults to ~/.mcpshell/tools but can be overridden 64 | with the MCPSHELL_TOOLS_DIR environment variable. 65 | 66 | When multiple configuration files are provided, they are merged with: 67 | 68 | - Prompts concatenated from all files 69 | - Tools combined from all files 70 | - MCP description and run config taken from the first file 71 | `, 72 | Run: func(cmd *cobra.Command, args []string) { 73 | // If no subcommand is specified, show the help 74 | _ = cmd.Help() 75 | }, 76 | } 77 | 78 | // SetVersion sets the version information from build-time variables 79 | func SetVersion(v, c, d string) { 80 | if v != "" { 81 | version = v 82 | } 83 | if c != "" { 84 | commit = c 85 | } 86 | if d != "" { 87 | buildDate = d 88 | } 89 | } 90 | 91 | // GetVersion returns the full version string with build information 92 | func GetVersion() string { 93 | return fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, buildDate) 94 | } 95 | 96 | // Execute adds all child commands to the root command and sets flags appropriately. 97 | // This is called by main.main(). It only needs to happen once to the rootCmd. 98 | func Execute() { 99 | defer common.RecoverPanic() 100 | 101 | if err := rootCmd.Execute(); err != nil { 102 | common.GetLogger().Error("Command execution failed: %v", err) 103 | fmt.Println(err) 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | // init registers all subcommands and sets up global flags 109 | func init() { 110 | // Add common persistent flags 111 | rootCmd.PersistentFlags().StringSliceVar(&toolsFiles, "tools", []string{}, "Path(s) to the tools configuration file(s).\nSupports multiple files via --tools=file1 --tools=file2 or --tools=file1,file2.\nEach path supports relative paths and auto .yaml extension.\nDefault look path from MCPSHELL_TOOLS_DIR") 112 | rootCmd.PersistentFlags().StringVarP(&logFile, "logfile", "l", "", "Path to the log file (optional)") 113 | rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "", "info", "Log level: none, error, info, debug") 114 | rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging (sets log level to debug)") 115 | 116 | // Add version flag to all commands with custom handling 117 | versionFlag := false 118 | rootCmd.PersistentFlags().BoolVar(&versionFlag, "version", false, "Print version information") 119 | 120 | // Add pre-run to handle version flag 121 | rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { 122 | if versionFlag, _ := cmd.Flags().GetBool("version"); versionFlag { 123 | fmt.Println(GetVersion()) 124 | os.Exit(0) 125 | } 126 | } 127 | } 128 | 129 | // initLogger initializes the logger with the specified configuration 130 | func initLogger() (*common.Logger, error) { 131 | // If verbose flag is set, use debug level; otherwise use the configured log level 132 | var level common.LogLevel 133 | if verbose { 134 | level = common.LogLevelDebug 135 | } else { 136 | level = common.LogLevelFromString(logLevel) 137 | } 138 | 139 | logger, err := common.NewLogger("[mcpshell] ", logFile, level, true) 140 | if err != nil { 141 | return nil, fmt.Errorf("failed to set up logger: %w", err) 142 | } 143 | 144 | common.SetLogger(logger) 145 | return logger, nil 146 | } 147 | -------------------------------------------------------------------------------- /pkg/common/logging.go: -------------------------------------------------------------------------------- 1 | // Package common provides shared utilities and types used across the MCPShell. 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | "io" 7 | "log" 8 | "os" 9 | ) 10 | 11 | // Global application logger 12 | var globalLogger *Logger 13 | 14 | // LogLevel represents logging verbosity levels 15 | type LogLevel int 16 | 17 | const ( 18 | // LogLevelNone disables logging 19 | LogLevelNone LogLevel = iota 20 | // LogLevelError logs only errors 21 | LogLevelError 22 | // LogLevelInfo logs information and errors 23 | LogLevelInfo 24 | // LogLevelDebug logs detailed debug information 25 | LogLevelDebug 26 | ) 27 | 28 | // LogLevelFromString converts a string representation to a LogLevel 29 | func LogLevelFromString(level string) LogLevel { 30 | switch level { 31 | case "debug": 32 | return LogLevelDebug 33 | case "info": 34 | return LogLevelInfo 35 | case "error": 36 | return LogLevelError 37 | case "none": 38 | return LogLevelNone 39 | default: 40 | // Default to info level 41 | return LogLevelInfo 42 | } 43 | } 44 | 45 | // Logger provides a structured logging interface for the application 46 | type Logger struct { 47 | // The underlying Go logger 48 | *log.Logger 49 | // The logging level 50 | level LogLevel 51 | // The log file path (if used) 52 | filePath string 53 | // The log file handle (if used) 54 | file *os.File 55 | } 56 | 57 | // NewLogger creates a new Logger instance 58 | // 59 | // Parameters: 60 | // - prefix: The prefix for all log messages 61 | // - filePath: Path to the log file (empty string disables file logging) 62 | // - level: The logging verbosity level 63 | // - truncate: If true, truncate the log file; if false, append to it 64 | // 65 | // Returns: 66 | // - A new Logger instance 67 | // - An error if the log file cannot be opened 68 | func NewLogger(prefix string, filePath string, level LogLevel, truncate bool) (*Logger, error) { 69 | var writer io.Writer 70 | var file *os.File 71 | var err error 72 | 73 | // Set up the log writer - always use stderr unless LogLevelNone 74 | if level == LogLevelNone { 75 | writer = io.Discard 76 | } else { 77 | writer = os.Stderr 78 | } 79 | 80 | // If a file path is provided, also log to the file 81 | if filePath != "" && level != LogLevelNone { 82 | // Determine file open flags 83 | flags := os.O_RDWR | os.O_CREATE 84 | if truncate { 85 | flags |= os.O_TRUNC 86 | } else { 87 | flags |= os.O_APPEND 88 | } 89 | 90 | // Open the log file 91 | file, err = os.OpenFile(filePath, flags, 0666) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to open log file: %w", err) 94 | } 95 | // Write to both stderr and file 96 | writer = io.MultiWriter(os.Stderr, file) 97 | } 98 | 99 | // Create the logger 100 | logger := &Logger{ 101 | Logger: log.New(writer, prefix, log.Ldate|log.Ltime|log.Lshortfile), 102 | level: level, 103 | filePath: filePath, 104 | file: file, 105 | } 106 | 107 | // Log the initialization 108 | if filePath != "" && level >= LogLevelInfo { 109 | logger.Debug("----------------------------") 110 | logger.Debug("Logging initialized to file: %s", filePath) 111 | } 112 | 113 | return logger, nil 114 | } 115 | 116 | // Close closes the log file if it's open 117 | func (l *Logger) Close() error { 118 | if l.file != nil { 119 | return l.file.Close() 120 | } 121 | return nil 122 | } 123 | 124 | // Debug logs a message at debug level 125 | func (l *Logger) Debug(format string, v ...interface{}) { 126 | if l.level >= LogLevelDebug { 127 | l.Printf("[DEBUG] "+format, v...) 128 | } 129 | } 130 | 131 | // Info logs a message at info level 132 | func (l *Logger) Info(format string, v ...interface{}) { 133 | if l.level >= LogLevelInfo { 134 | l.Printf("[INFO] "+format, v...) 135 | } 136 | } 137 | 138 | // Warn logs a warning message 139 | func (l *Logger) Warn(format string, v ...interface{}) { 140 | if l.level >= LogLevelInfo { 141 | l.Printf("[WARN] "+format, v...) 142 | } 143 | } 144 | 145 | // Error logs a message at error level 146 | func (l *Logger) Error(format string, v ...interface{}) { 147 | if l.level >= LogLevelError { 148 | l.Printf("[ERROR] "+format, v...) 149 | } 150 | } 151 | 152 | // FilePath returns the current log file path 153 | func (l *Logger) FilePath() string { 154 | return l.filePath 155 | } 156 | 157 | // Level returns the current log level 158 | func (l *Logger) Level() LogLevel { 159 | return l.level 160 | } 161 | 162 | // SetLevel changes the current log level 163 | func (l *Logger) SetLevel(level LogLevel) { 164 | l.level = level 165 | } 166 | 167 | ////////////////////////////////////////////////////////////////////// 168 | 169 | // GetLogger returns the global application logger. 170 | // If the logger hasn't been initialized yet, it returns a default stderr logger. 171 | func GetLogger() *Logger { 172 | if globalLogger == nil { 173 | // Create a default stderr logger at info level 174 | logger, err := NewLogger("[mcpshell] ", "", LogLevelInfo, false) 175 | if err != nil { 176 | // If we can't even create a basic logger, just return a minimal one 177 | fmt.Fprintf(os.Stderr, "Error creating default logger: %v\n", err) 178 | minimalLogger, _ := NewLogger("[mcpshell] ", "", LogLevelError, false) 179 | return minimalLogger 180 | } 181 | return logger 182 | } 183 | return globalLogger 184 | } 185 | 186 | // SetLogger sets the global application logger 187 | func SetLogger(logger *Logger) { 188 | globalLogger = logger 189 | } 190 | -------------------------------------------------------------------------------- /pkg/utils/tools_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | ) 8 | 9 | func TestResolveToolsFile(t *testing.T) { 10 | // Create a temporary tools directory for testing 11 | tmpDir := t.TempDir() 12 | toolsDir := filepath.Join(tmpDir, "tools") 13 | if err := os.MkdirAll(toolsDir, 0o755); err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | // Set the tools directory environment variable 18 | t.Setenv(MCPShellToolsDirEnv, toolsDir) 19 | 20 | // Create test files in tools directory 21 | toolsTestFile := filepath.Join(toolsDir, "test.yaml") 22 | if err := os.WriteFile(toolsTestFile, []byte("test content from tools dir"), 0o644); err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | // Create a current directory test file 27 | currentDir := t.TempDir() 28 | originalWd, err := os.Getwd() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | defer func() { _ = os.Chdir(originalWd) }() 33 | if err := os.Chdir(currentDir); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | currentTestFile := filepath.Join(currentDir, "current.yaml") 38 | if err := os.WriteFile(currentTestFile, []byte("test content from current dir"), 0o644); err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | tests := []struct { 43 | name string 44 | input string 45 | expected string 46 | wantErr bool 47 | }{ 48 | { 49 | name: "file in current directory takes precedence", 50 | input: "current.yaml", 51 | expected: currentTestFile, 52 | wantErr: false, 53 | }, 54 | { 55 | name: "file in tools directory when not in current", 56 | input: "test.yaml", 57 | expected: toolsTestFile, 58 | wantErr: false, 59 | }, 60 | { 61 | name: "relative path without extension found in tools dir", 62 | input: "test", 63 | expected: toolsTestFile, 64 | wantErr: false, 65 | }, 66 | { 67 | name: "nonexistent file", 68 | input: "nonexistent", 69 | wantErr: true, 70 | }, 71 | { 72 | name: "absolute path", 73 | input: toolsTestFile, 74 | expected: toolsTestFile, 75 | wantErr: false, 76 | }, 77 | } 78 | 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | result, err := ResolveToolsFile(tt.input) 82 | if tt.wantErr { 83 | if err == nil { 84 | t.Errorf("expected error but got none") 85 | } 86 | return 87 | } 88 | if err != nil { 89 | t.Errorf("unexpected error: %v", err) 90 | return 91 | } 92 | // Resolve symlinks for comparison (important on macOS where /var -> /private/var) 93 | expectedResolved, err := filepath.EvalSymlinks(tt.expected) 94 | if err != nil { 95 | expectedResolved = tt.expected 96 | } 97 | resultResolved, err := filepath.EvalSymlinks(result) 98 | if err != nil { 99 | resultResolved = result 100 | } 101 | if resultResolved != expectedResolved { 102 | t.Errorf("expected %s, got %s", expectedResolved, resultResolved) 103 | } 104 | }) 105 | } 106 | } 107 | 108 | func TestEnsureToolsDir(t *testing.T) { 109 | // Create a temporary directory for testing 110 | tmpDir := t.TempDir() 111 | toolsDir := filepath.Join(tmpDir, "tools") 112 | 113 | // Set the tools directory environment variable 114 | t.Setenv(MCPShellToolsDirEnv, toolsDir) 115 | 116 | // Ensure the directory doesn't exist initially 117 | if _, err := os.Stat(toolsDir); !os.IsNotExist(err) { 118 | t.Fatal("Tools directory should not exist initially") 119 | } 120 | 121 | // Call EnsureToolsDir 122 | err := EnsureToolsDir() 123 | if err != nil { 124 | t.Fatalf("EnsureToolsDir failed: %v", err) 125 | } 126 | 127 | // Check that the directory was created 128 | if _, err := os.Stat(toolsDir); os.IsNotExist(err) { 129 | t.Fatal("Tools directory was not created") 130 | } 131 | 132 | // Ensure calling it again doesn't cause an error 133 | err = EnsureToolsDir() 134 | if err != nil { 135 | t.Fatalf("EnsureToolsDir failed on second call: %v", err) 136 | } 137 | } 138 | 139 | func TestGetMCPShellToolsDir(t *testing.T) { 140 | tests := []struct { 141 | name string 142 | envVar string 143 | wantErr bool 144 | }{ 145 | { 146 | name: "default directory", 147 | envVar: "", 148 | wantErr: false, 149 | }, 150 | { 151 | name: "custom directory from env", 152 | envVar: "/custom/tools/dir", 153 | wantErr: false, 154 | }, 155 | } 156 | 157 | for _, tt := range tests { 158 | t.Run(tt.name, func(t *testing.T) { 159 | if tt.envVar != "" { 160 | t.Setenv(MCPShellToolsDirEnv, tt.envVar) 161 | } else { 162 | if err := os.Unsetenv(MCPShellToolsDirEnv); err != nil { 163 | t.Fatalf("failed to unset env var: %v", err) 164 | } 165 | } 166 | 167 | result, err := GetMCPShellToolsDir() 168 | if tt.wantErr { 169 | if err == nil { 170 | t.Errorf("expected error but got none") 171 | } 172 | return 173 | } 174 | if err != nil { 175 | t.Errorf("unexpected error: %v", err) 176 | return 177 | } 178 | 179 | if tt.envVar != "" { 180 | if result != tt.envVar { 181 | t.Errorf("expected %s, got %s", tt.envVar, result) 182 | } 183 | } else { 184 | // Should contain the default tools directory 185 | if !filepath.IsAbs(result) { 186 | t.Errorf("expected absolute path, got %s", result) 187 | } 188 | if filepath.Base(result) != MCPShellToolsDir { 189 | t.Errorf("expected path to end with %s, got %s", MCPShellToolsDir, result) 190 | } 191 | } 192 | }) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /docs/usage-cursor.md: -------------------------------------------------------------------------------- 1 | # Using the MCPShell in Cursor 2 | 3 | ## What is MCP? 4 | 5 | The Model Context Protocol (MCP) is an open protocol that standardizes how applications provide context and tools to LLMs. It acts as a plugin system for Cursor, allowing you to extend the AI's capabilities by connecting it to various data sources and tools through standardized interfaces. 6 | 7 | The MCPShell lets you expose command-line tools to Cursor's AI, enabling it to interact with your system, run commands, and process their output. 8 | 9 | ## Setting Up MCPShell 10 | 11 | ### Step 1: Define your tools in YAML 12 | 13 | Create a `mcp-cli.yaml` file with your tool definitions: 14 | 15 | ```yaml 16 | mcp: 17 | run: 18 | shell: bash 19 | tools: 20 | - name: "weather" 21 | description: "Get the weather for a location" 22 | params: 23 | location: 24 | type: string 25 | description: "The location to get weather for" 26 | required: true 27 | constraints: 28 | - "location.size() <= 50" # Prevent overly long inputs 29 | run: 30 | command: "curl -s 'https://wttr.in/{{ .location }}?format=3'" 31 | ``` 32 | 33 | ### Step 2: Configure Cursor to use the adapter 34 | 35 | Cursor supports two configuration locations: 36 | 37 | 1. **Project-specific**: Create a `.cursor/mcp.json` file in your project directory 38 | 1. **Global**: Create a `~/.cursor/mcp.json` file in your home directory 39 | 40 | The MCPShell uses the "stdio" transport type, which runs locally on your machine. 41 | 42 | #### If you have the `go` command available 43 | 44 | ```json 45 | { 46 | "mcpServers": { 47 | "mcpshell": { 48 | "command": "go", 49 | "args": [ 50 | "run", "github.com/inercia/MCPShell@v0.1.8", 51 | "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" 52 | ] 53 | } 54 | } 55 | } 56 | ``` 57 | 58 | #### If you've downloaded the `mcpshell` binary 59 | 60 | ```json 61 | { 62 | "mcpServers": { 63 | "mcpshell": { 64 | "command": "/absolute/path/to/mcpshell", 65 | "args": [ 66 | "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" 67 | ] 68 | } 69 | } 70 | } 71 | ``` 72 | 73 | ### Step 3: Refresh the Cursor client 74 | 75 | After creating or modifying your MCP configuration, reflesh the Cursor client for the changes to take effect. 76 | 77 | ## Using Multiple MCPShell Instances 78 | 79 | You can configure multiple instances of the MCPShell, each with different tool sets and configurations: 80 | 81 | ```json 82 | { 83 | "mcpServers": { 84 | "mcp-cli-examples": { 85 | "command": "/some/path/mcpshell/build/mcpshell", 86 | "args": [ 87 | "mcp", 88 | "--tools", "/some/path/mcpshell/examples/config.yaml", 89 | "--logfile", "/some/path/mcpshell/debug.log" 90 | ], 91 | "env": { 92 | } 93 | }, 94 | "mcp-cli-kubernetes-ro": { 95 | "command": "/some/path/mcpshell/build/mcpshell", 96 | "args": [ 97 | "mcp", 98 | "--tools", "/some/path/mcpshell/examples/kubectl-ro.yaml", 99 | "--logfile", "/some/path/mcpshell/debug.kubernetes-ro.log" 100 | ], 101 | "env": { 102 | "KUBECONFIG": "/some/path/ethos/kubeconfig/kubeconfig.yaml" 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | 109 | With this configuration, Cursor will have access to tools from both instances: 110 | 111 | ![Multiple MCP tools in Cursor](cursor-config-1.png) 112 | 113 | ## Providing Authentication 114 | 115 | You can provide authentication credentials and other sensitive information using environment variables: 116 | 117 | ```json 118 | { 119 | "mcpServers": { 120 | "mcpshell": { 121 | "command": "/absolute/path/to/mcpshell", 122 | "args": [ 123 | "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" 124 | ], 125 | "env": { 126 | "API_KEY": "your-api-key-here", 127 | "SECRET_TOKEN": "your-secret-token" 128 | } 129 | } 130 | } 131 | } 132 | ``` 133 | 134 | ## How Cursor Uses MCP Tools 135 | 136 | When you chat with Cursor, the AI will: 137 | 138 | 1. **Automatically detect** when a tool might be useful based on your request 139 | 1. **Ask for approval** before running any tool (by default) 140 | 1. **Display the results** in the chat conversation 141 | 142 | You can prompt Cursor to use specific tools by mentioning them by name or description in your request. 143 | 144 | ## Tool Approval 145 | 146 | By default, when Cursor wants to use an MCP tool, it will display a message asking for your approval. You can: 147 | 148 | - View the arguments the tool will be called with 149 | - Approve the tool just once 150 | - Approve the tool for the current session 151 | - Configure auto-run to allow tools to run without approval 152 | 153 | ## Known Limitations 154 | 155 | - **Tool Quantity**: Cursor currently supports up to 40 tools at a time 156 | - **Remote Development**: MCP servers may not work properly when accessing Cursor over SSH or other remote development environments 157 | - **Resources**: Currently, only tools are supported in Cursor; resources are not yet supported 158 | 159 | ## Troubleshooting 160 | 161 | Read the [troubleshooting guide](troubleshooting.md). 162 | -------------------------------------------------------------------------------- /tests/examples/README.md: -------------------------------------------------------------------------------- 1 | # Example Configuration Tests 2 | 3 | This directory contains integration tests for the example configuration files in `/examples`. 4 | 5 | ## Overview 6 | 7 | These tests verify that the example configurations work correctly by executing specific tools with known inputs and validating the outputs. The tests use the `mcpshell exe` command to directly invoke tools without requiring a full MCP server setup. 8 | 9 | ## Test Files 10 | 11 | ### `test_github-cli-ro.sh` 12 | Tests the GitHub CLI read-only tools from `examples/github-cli-ro.yaml`. 13 | 14 | **Tests:** 15 | - Fetching raw files from public GitHub repositories (torvalds/linux, golang/go) 16 | - Path traversal prevention (security constraint validation) 17 | - Default parameter handling (ref defaults to 'main') 18 | - Command injection prevention 19 | 20 | **Requirements:** 21 | - `curl` command available 22 | - Internet connectivity to github.com 23 | 24 | ### `test_config.sh` 25 | Tests basic utility tools from `examples/config.yaml`. 26 | 27 | **Tests:** 28 | - `hello_world` - Simple greeting tool 29 | - `calculator` - Mathematical expression evaluation 30 | - `number_validator` - Numeric operations (square, double, half) 31 | - Constraint validation (name length limits) 32 | - `secure_shell` - Whitelisted command execution 33 | 34 | **Requirements:** 35 | - `bc` command available (for calculator) 36 | - Basic shell commands (echo, pwd) 37 | 38 | ### `test_disk-diagnostics-ro.sh` 39 | Tests disk diagnostic tools from `examples/disk-diagnostics-ro.yaml`. 40 | 41 | **Tests:** 42 | - `storage_overview` - Filesystem usage and mount information 43 | 44 | **Requirements:** 45 | - `df` command available 46 | - Basic disk utilities 47 | 48 | ## Running Tests 49 | 50 | ### Run all tests (including examples) 51 | ```bash 52 | make test-e2e 53 | ``` 54 | 55 | ### Run only example tests 56 | ```bash 57 | ./tests/examples/test_github-cli-ro.sh 58 | ./tests/examples/test_config.sh 59 | ./tests/examples/test_disk-diagnostics-ro.sh 60 | ``` 61 | 62 | ### Run a specific test 63 | ```bash 64 | ./tests/examples/test_config.sh 65 | ``` 66 | 67 | ## Test Structure 68 | 69 | Each test follows this pattern: 70 | 71 | 1. **Setup**: Source common utilities, define configuration 72 | 2. **Validation**: Check prerequisites (CLI binary, required commands) 73 | 3. **Test Cases**: Execute tools with specific inputs 74 | 4. **Verification**: Validate outputs match expected results 75 | 5. **Cleanup**: Remove temporary files if needed 76 | 6. **Exit**: Return 0 for success, 1 for failure 77 | 78 | ## Adding New Tests 79 | 80 | To add a test for a new example configuration: 81 | 82 | 1. Create `test_.sh` in this directory 83 | 2. Follow the existing test pattern (see `test_config.sh` as a template) 84 | 3. Add the test to `TEST_FILES` array in `tests/run_tests.sh` 85 | 4. Make the script executable: `chmod +x tests/examples/test_.sh` 86 | 5. Test locally before committing 87 | 88 | ### Example Test Template 89 | 90 | ```bash 91 | #!/bin/bash 92 | # Test script for .yaml 93 | 94 | # Source common utilities 95 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 96 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 97 | source "$TESTS_ROOT/common/common.sh" 98 | 99 | # Test configuration 100 | TOOLS_FILE="$SCRIPT_DIR/../../examples/.yaml" 101 | TEST_NAME="test_" 102 | 103 | # Start the test 104 | testcase "$TEST_NAME" 105 | info_blue "Configuration file: $TOOLS_FILE" 106 | separator 107 | 108 | # Make sure we have the CLI binary 109 | check_cli_exists 110 | 111 | # Test 1: Your first test 112 | info "Test 1: Description" 113 | CMD="$CLI_BIN exe --tools $TOOLS_FILE " 114 | OUTPUT=$(eval "$CMD" 2>&1) 115 | RESULT=$? 116 | 117 | if [ $RESULT -ne 0 ]; then 118 | failure "Test 1 failed" 119 | echo "$OUTPUT" 120 | exit 1 121 | fi 122 | 123 | success "Test 1 passed" 124 | separator 125 | 126 | success "All tests passed!" 127 | exit 0 128 | ``` 129 | 130 | ## CI/CD Considerations 131 | 132 | These tests are designed to run in GitHub Actions CI environment: 133 | 134 | - **Avoid tests requiring credentials** (AWS, private repos, etc.) 135 | - **Avoid tests requiring external services** (databases, APIs with auth) 136 | - **Use public, stable resources** (well-known GitHub repos, basic system commands) 137 | - **Handle network failures gracefully** (skip tests if resources unavailable) 138 | 139 | ## Examples NOT Tested 140 | 141 | Some examples are intentionally not tested because they require resources unavailable in CI: 142 | 143 | - `aws-*.yaml` - Requires AWS credentials and resources 144 | - `kubectl-ro.yaml` - Requires Kubernetes cluster 145 | - `container-diagnostics-ro.yaml` - Requires Docker containers 146 | - `network-diagnostics-ro.yaml` - May have network restrictions in CI 147 | 148 | These can be tested manually in appropriate environments. 149 | 150 | ## Utilities 151 | 152 | Tests use utilities from `tests/common/common.sh`: 153 | 154 | - `testcase()` - Print test header 155 | - `info()` - Print informational message 156 | - `success()` - Print success message (green checkmark) 157 | - `failure()` - Print failure message (red X) 158 | - `skip()` - Skip test with message 159 | - `fail()` - Fail test and exit 160 | - `check_cli_exists()` - Verify CLI binary exists 161 | - `command_exists()` - Check if command is available 162 | 163 | ## Debugging 164 | 165 | Test output is logged to `tests/e2e_output.log` for debugging failed tests. 166 | 167 | To run tests with verbose output: 168 | ```bash 169 | ./tests/examples/test_config.sh 2>&1 | tee test_output.log 170 | ``` 171 | 172 | -------------------------------------------------------------------------------- /tests/examples/test_config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Test script for config.yaml example 3 | # Tests basic utility tools like hello_world, calculator, and number_validator 4 | 5 | # Source common utilities 6 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 7 | TESTS_ROOT="$(dirname "$SCRIPT_DIR")" 8 | source "$TESTS_ROOT/common/common.sh" 9 | 10 | ##################################################################################### 11 | # Test configuration 12 | TOOLS_FILE="$SCRIPT_DIR/../../examples/config.yaml" 13 | TEST_NAME="test_config" 14 | 15 | ##################################################################################### 16 | # Start the test 17 | 18 | testcase "$TEST_NAME" 19 | 20 | info_blue "Configuration file: $TOOLS_FILE" 21 | separator 22 | 23 | # Make sure we have the CLI binary 24 | check_cli_exists 25 | 26 | ##################################################################################### 27 | # Test 1: hello_world tool 28 | 29 | info "Test 1: Testing hello_world tool" 30 | CMD="$CLI_BIN exe --tools $TOOLS_FILE hello_world name=World" 31 | info "Executing: $CMD" 32 | 33 | OUTPUT=$(eval "$CMD" 2>&1) 34 | RESULT=$? 35 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 1:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 36 | 37 | if [ $RESULT -ne 0 ]; then 38 | failure "Test 1 failed: Command execution failed with exit code: $RESULT" 39 | echo "$OUTPUT" 40 | exit 1 41 | fi 42 | 43 | # Check if output contains expected greeting 44 | if echo "$OUTPUT" | grep -q "Hello, World!"; then 45 | success "Test 1 passed: hello_world tool works correctly" 46 | else 47 | failure "Test 1 failed: Output doesn't contain expected greeting" 48 | echo "$OUTPUT" 49 | exit 1 50 | fi 51 | 52 | separator 53 | 54 | ##################################################################################### 55 | # Test 2: calculator tool 56 | 57 | info "Test 2: Testing calculator tool" 58 | CMD="$CLI_BIN exe --tools $TOOLS_FILE calculator expression='2+2'" 59 | info "Executing: $CMD" 60 | 61 | OUTPUT=$(eval "$CMD" 2>&1) 62 | RESULT=$? 63 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 2:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 64 | 65 | if [ $RESULT -ne 0 ]; then 66 | failure "Test 2 failed: Command execution failed with exit code: $RESULT" 67 | echo "$OUTPUT" 68 | exit 1 69 | fi 70 | 71 | # Check if output contains the result 72 | if echo "$OUTPUT" | grep -q "4"; then 73 | success "Test 2 passed: calculator tool works correctly" 74 | else 75 | failure "Test 2 failed: Output doesn't contain expected result" 76 | echo "$OUTPUT" 77 | exit 1 78 | fi 79 | 80 | separator 81 | 82 | ##################################################################################### 83 | # Test 3: number_validator tool with square operation 84 | 85 | info "Test 3: Testing number_validator tool (square operation)" 86 | CMD="$CLI_BIN exe --tools $TOOLS_FILE number_validator value=5 operation=square" 87 | info "Executing: $CMD" 88 | 89 | OUTPUT=$(eval "$CMD" 2>&1) 90 | RESULT=$? 91 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 3:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 92 | 93 | if [ $RESULT -ne 0 ]; then 94 | failure "Test 3 failed: Command execution failed with exit code: $RESULT" 95 | echo "$OUTPUT" 96 | exit 1 97 | fi 98 | 99 | # Check if output contains the result (5*5=25) 100 | if echo "$OUTPUT" | grep -q "25"; then 101 | success "Test 3 passed: number_validator tool works correctly" 102 | else 103 | failure "Test 3 failed: Output doesn't contain expected result" 104 | echo "$OUTPUT" 105 | exit 1 106 | fi 107 | 108 | separator 109 | 110 | ##################################################################################### 111 | # Test 4: Test constraint validation (name length limit) 112 | 113 | info "Test 4: Testing constraint validation (name too long)" 114 | LONG_NAME=$(printf 'A%.0s' {1..150}) # Create a 150-character string 115 | CMD="$CLI_BIN exe --tools $TOOLS_FILE hello_world name='$LONG_NAME'" 116 | info "Executing: $CMD" 117 | 118 | OUTPUT=$(eval "$CMD" 2>&1) 119 | RESULT=$? 120 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 4:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 121 | 122 | # This should fail due to constraint violation 123 | if [ $RESULT -eq 0 ]; then 124 | failure "Test 4 failed: Command should have been blocked by constraints" 125 | echo "$OUTPUT" 126 | exit 1 127 | fi 128 | 129 | # Check if output mentions constraint violation 130 | if echo "$OUTPUT" | grep -q "constraint"; then 131 | success "Test 4 passed: Name length constraint correctly enforced" 132 | else 133 | failure "Test 4 failed: Expected constraint violation message" 134 | echo "$OUTPUT" 135 | exit 1 136 | fi 137 | 138 | separator 139 | 140 | ##################################################################################### 141 | # Test 5: Test secure_shell with whitelisted command 142 | 143 | info "Test 5: Testing secure_shell with whitelisted command (pwd)" 144 | CMD="$CLI_BIN exe --tools $TOOLS_FILE secure_shell command=pwd" 145 | info "Executing: $CMD" 146 | 147 | OUTPUT=$(eval "$CMD" 2>&1) 148 | RESULT=$? 149 | [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME - Test 5:\n\n$OUTPUT" >> "$E2E_LOG_FILE" 150 | 151 | if [ $RESULT -ne 0 ]; then 152 | failure "Test 5 failed: Command execution failed with exit code: $RESULT" 153 | echo "$OUTPUT" 154 | exit 1 155 | fi 156 | 157 | # Check if output contains a path 158 | if echo "$OUTPUT" | grep -q "/"; then 159 | success "Test 5 passed: secure_shell tool works correctly" 160 | else 161 | failure "Test 5 failed: Output doesn't contain expected path" 162 | echo "$OUTPUT" 163 | exit 1 164 | fi 165 | 166 | separator 167 | 168 | success "All tests passed for config.yaml!" 169 | exit 0 170 | 171 | -------------------------------------------------------------------------------- /docs/usage-vscode.md: -------------------------------------------------------------------------------- 1 | # Using the MCPShell in Visual Studio Code 2 | 3 | This guide explains how to set up and use the MCPShell with Visual Studio Code. 4 | 5 | ## Prerequisites 6 | 7 | - Visual Studio Code with GitHub Copilot 8 | - MCPShell installed (built from source or downloaded binary) 9 | 10 | ## Setup Instructions 11 | 12 | To use MCPShell with Visual Studio Code, follow these steps: 13 | 14 | 1. **Create your YAML configuration file** for the tools you want to expose (e.g., `mcp-cli.yaml`). 15 | 16 | ```yaml 17 | mcp: 18 | run: 19 | shell: bash 20 | tools: 21 | - name: "weather" 22 | description: "Get the weather for a location" 23 | params: 24 | location: 25 | type: string 26 | description: "The location to get weather for" 27 | required: true 28 | constraints: 29 | - "location.size() <= 50" # Prevent overly long inputs 30 | run: 31 | command: "curl -s 'https://wttr.in/{{ .location }}?format=3'" 32 | ``` 33 | 34 | 1. **Configure VS Code to use the MCPShell** by creating a `.vscode/mcp.json` file in your workspace: 35 | 36 | ```json 37 | { 38 | "servers": { 39 | "mcpshell": { 40 | "type": "stdio", 41 | "command": "/absolute/path/to/mcpshell", 42 | "args": [ 43 | "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" 44 | ] 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | If you have Go installed, you can use it directly: 51 | 52 | ```json 53 | { 54 | "servers": { 55 | "mcpshell": { 56 | "type": "stdio", 57 | "command": "go", 58 | "args": [ 59 | "run", "github.com/inercia/MCPShell", 60 | "mcp", "--tools", "${workspaceFolder}/mcp-cli.yaml" 61 | ] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | Note: You can use predefined VS Code variables like `${workspaceFolder}` in your configuration. 68 | 69 | 1. **Restart VS Code** or run the **MCP: List Servers** command from the Command Palette to start the server. 70 | 71 | ## Using Multiple MCPShell Instances 72 | 73 | You can configure multiple instances of the MCPShell, 74 | each with different tool configurations: 75 | 76 | ```json 77 | { 78 | "servers": { 79 | "mcp-cli-example": { 80 | "type": "stdio", 81 | "command": "/absolute/path/to/mcpshell", 82 | "args": [ 83 | "mcp", 84 | "--tools", "${workspaceFolder}/examples/config.yaml", 85 | "--logfile", "${workspaceFolder}/debug.log" 86 | ] 87 | }, 88 | "mcp-cli-kubernetes-ro": { 89 | "type": "stdio", 90 | "command": "/absolute/path/to/mcpshell", 91 | "args": [ 92 | "mcp", 93 | "--tools", "${workspaceFolder}/examples/kubectl-ro.yaml", 94 | "--logfile", "${workspaceFolder}/debug.kubernetes-ro.log" 95 | ], 96 | "env": { 97 | "KUBECONFIG": "${workspaceFolder}/kubeconfig.yaml" 98 | } 99 | } 100 | } 101 | } 102 | ``` 103 | 104 | ## Setting up for Sensitive Information 105 | 106 | If your tools require API keys or other sensitive information, you can use input variables: 107 | 108 | ```json 109 | { 110 | "inputs": [ 111 | { 112 | "type": "promptString", 113 | "id": "api-key", 114 | "description": "API Key", 115 | "password": true 116 | } 117 | ], 118 | "servers": { 119 | "mcpshell": { 120 | "type": "stdio", 121 | "command": "/absolute/path/to/mcpshell", 122 | "args": [ 123 | "mcp", "--tools", "${workspaceFolder}/mcp-cli.yaml" 124 | ], 125 | "env": { 126 | "API_KEY": "${input:api-key}" 127 | } 128 | } 129 | } 130 | } 131 | ``` 132 | 133 | VS Code will prompt for these values when the server starts for the first time and securely store them for subsequent use. 134 | 135 | ## Using the Tools in Agent Mode 136 | 137 | After configuring the MCPShell: 138 | 139 | 1. Open the **Chat** view (⌃⌘I on macOS, Ctrl+Alt+I on Windows/Linux) 140 | 1. Select **Agent** mode from the dropdown 141 | 1. Click the **Tools** button to view and select available tools 142 | 1. Enter your query in the chat input box 143 | 144 | When a tool is invoked, you'll need to confirm the action before it runs. You can choose to automatically confirm the specific tool for the current session, workspace, or all future invocations. 145 | 146 | ## Managing MCP Servers 147 | 148 | To manage your MCP servers: 149 | 150 | 1. Run the **MCP: List Servers** command from the Command Palette 151 | 1. Select a server to start, stop, restart, view configuration, or view server logs 152 | 153 | ## Troubleshooting 154 | 155 | If you're experiencing issues with the MCPShell in VS Code: 156 | 157 | 1. **Check for error indicators** in the Chat view. Select the error notification and then **Show Output** to view server logs. 158 | 1. **Verify paths**: Ensure all file paths in your configuration are correct. 159 | 1. **Environment variables**: Make sure any required environment variables are properly set. 160 | 1. **Permissions**: Verify that the MCPShell binary has execution permissions. 161 | 1. **Connection type**: Ensure the server connection type (`type: "stdio"`) is correctly specified. 162 | 163 | ## Security Considerations 164 | 165 | When using MCPShell with VS Code, be aware of the following security considerations: 166 | 167 | - The tools you configure have the same system access permissions as VS Code. 168 | - Be careful with tools that execute shell commands or access sensitive files. 169 | - Use constraints to limit what your tools can do, especially when executing commands. 170 | - Consider running VS Code with restricted permissions when using powerful tools. 171 | -------------------------------------------------------------------------------- /pkg/command/runner_exec_test.go: -------------------------------------------------------------------------------- 1 | package command 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/inercia/MCPShell/pkg/common" 11 | ) 12 | 13 | func TestNewRunnerExecOptions(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | options RunnerOptions 17 | want RunnerExecOptions 18 | wantErr bool 19 | }{ 20 | { 21 | name: "valid options with shell", 22 | options: RunnerOptions{ 23 | "shell": "/bin/bash", 24 | }, 25 | want: RunnerExecOptions{ 26 | Shell: "/bin/bash", 27 | }, 28 | wantErr: false, 29 | }, 30 | { 31 | name: "empty options", 32 | options: RunnerOptions{}, 33 | want: RunnerExecOptions{}, 34 | wantErr: false, 35 | }, 36 | { 37 | name: "options with additional fields", 38 | options: RunnerOptions{ 39 | "shell": "/bin/zsh", 40 | "extra": "value", 41 | }, 42 | want: RunnerExecOptions{ 43 | Shell: "/bin/zsh", 44 | }, 45 | wantErr: false, 46 | }, 47 | { 48 | name: "options with numeric shell as string", 49 | options: RunnerOptions{ 50 | "shell": "123", 51 | }, 52 | want: RunnerExecOptions{ 53 | Shell: "123", 54 | }, 55 | wantErr: false, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | got, err := NewRunnerExecOptions(tt.options) 62 | if (err != nil) != tt.wantErr { 63 | t.Errorf("NewRunnerExecOptions() error = %v, wantErr %v", err, tt.wantErr) 64 | return 65 | } 66 | if !reflect.DeepEqual(got, tt.want) { 67 | t.Errorf("NewRunnerExecOptions() = %v, want %v", got, tt.want) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func TestRunnerExec_Run(t *testing.T) { 74 | tests := []struct { 75 | name string 76 | shell string 77 | command string 78 | env []string 79 | params map[string]interface{} 80 | want string 81 | wantErr bool 82 | }{ 83 | { 84 | name: "simple echo command", 85 | shell: "", 86 | command: "echo hello world", 87 | env: nil, 88 | params: nil, 89 | want: "hello world", 90 | wantErr: false, 91 | }, 92 | { 93 | name: "command with environment variable", 94 | shell: "", 95 | command: "echo $TEST_VAR", 96 | env: []string{"TEST_VAR=test_value"}, 97 | params: nil, 98 | want: "test_value", 99 | wantErr: false, 100 | }, 101 | } 102 | 103 | if runtime.GOOS == "windows" { 104 | tests[1].command = "echo %TEST_VAR%" 105 | } 106 | 107 | for _, tt := range tests { 108 | t.Run(tt.name, func(t *testing.T) { 109 | logger, _ := common.NewLogger("test-runner-exec: ", "", common.LogLevelInfo, false) 110 | r, err := NewRunnerExec(RunnerOptions{}, logger) 111 | if err != nil { 112 | t.Fatalf("Failed to create RunnerExec: %v", err) 113 | } 114 | 115 | got, err := r.Run(context.Background(), tt.shell, tt.command, tt.env, tt.params, true) 116 | if (err != nil) != tt.wantErr { 117 | t.Errorf("RunnerExec.Run() error = %v, wantErr %v", err, tt.wantErr) 118 | return 119 | } 120 | 121 | // Trim any trailing newlines for comparison 122 | got = strings.TrimSpace(got) 123 | 124 | if got != tt.want { 125 | t.Errorf("RunnerExec.Run() = %q, want %q", got, tt.want) 126 | } 127 | }) 128 | } 129 | } 130 | 131 | func TestRunnerExec_RunWithEnvExpansion(t *testing.T) { 132 | // This test demonstrates using the -c flag to execute a command with environment variable expansion 133 | logger, _ := common.NewLogger("test-runner-exec-env: ", "", common.LogLevelInfo, false) 134 | 135 | r, err := NewRunnerExec(RunnerOptions{}, logger) 136 | if err != nil { 137 | t.Fatalf("Failed to create RunnerExec: %v", err) 138 | } 139 | 140 | command := "echo $TEST_VAR" 141 | if runtime.GOOS == "windows" { 142 | command = "echo %TEST_VAR%" 143 | } 144 | 145 | // Use the shell's -c flag directly to execute a command that expands an environment variable 146 | output, err := r.Run( 147 | context.Background(), 148 | "", 149 | command, 150 | []string{"TEST_VAR=test_value_expanded"}, 151 | nil, 152 | false, // No tmpfile needed for this test 153 | ) 154 | 155 | if err != nil { 156 | t.Fatalf("RunnerExec.Run() error = %v", err) 157 | } 158 | 159 | output = strings.TrimSpace(output) 160 | expected := "test_value_expanded" 161 | 162 | if output != expected { 163 | t.Errorf("Environment variable expansion failed: got %q, want %q", output, expected) 164 | } 165 | } 166 | 167 | func TestRunnerExec_Optimization_SingleExecutable(t *testing.T) { 168 | logger, _ := common.NewLogger("test-runner-exec-opt: ", "", common.LogLevelInfo, false) 169 | r, err := NewRunnerExec(RunnerOptions{}, logger) 170 | if err != nil { 171 | t.Fatalf("Failed to create RunnerExec: %v", err) 172 | } 173 | 174 | // This command should be a single executable and run directly 175 | command := "whoami" 176 | output, err := r.Run(context.Background(), "", command, nil, nil, false) 177 | if err != nil { 178 | t.Errorf("Expected '%s' to run without error, got: %v", command, err) 179 | } 180 | if len(strings.TrimSpace(output)) == 0 { 181 | t.Errorf("Expected output from '%s', got empty string", command) 182 | } 183 | 184 | // This command has arguments and should be run via a shell, not directly. 185 | // isSingleExecutableCommand should return false. 186 | // The command itself should succeed when run through the shell. 187 | commandWithArgs := "echo hello" 188 | output, err = r.Run(context.Background(), "", commandWithArgs, nil, nil, false) 189 | if err != nil { 190 | t.Errorf("Expected '%s' to run without error, got: %v", commandWithArgs, err) 191 | } 192 | if strings.TrimSpace(output) != "hello" { 193 | t.Errorf("Expected output from '%s' to be 'hello', got %q", commandWithArgs, output) 194 | } 195 | } 196 | --------------------------------------------------------------------------------