Shell MCP Server
by blazickjp
- src
- shell_mcp_server
"""
Shell MCP Server
==============
This module implements an MCP server that provides secure shell command execution
within specified working directories using specified shells.
Key Features:
- Safe command execution in specified directories
- Supports multiple shell types (bash, sh, cmd, powershell)
- Command timeout handling
- Cross-platform support
- Robust error handling
"""
import asyncio
import os
import sys
import argparse
from typing import Dict, Any, List
import mcp
import mcp.types as types
from mcp.server import Server, InitializationOptions, NotificationOptions
from .config import Settings
# Parse command line arguments for directories and shells
def parse_args() -> tuple[List[str], Dict[str, str]]:
"""Parse command line arguments for directories and shells."""
parser = argparse.ArgumentParser(description="Shell MCP Server")
parser.add_argument('directories', nargs='+', help='Allowed directories for command execution')
parser.add_argument('--shell', action='append', nargs=2, metavar=('name', 'path'),
help='Shell specification in format: name path')
args = parser.parse_args()
# Convert shell arguments to dictionary
shells = {}
if args.shell:
shells = {name: path for name, path in args.shell}
# Default to system shell if none specified
if not shells:
if sys.platform == 'win32':
shells = {'cmd': 'cmd.exe', 'powershell': 'powershell.exe'}
else:
shells = {'bash': '/bin/bash', 'sh': '/bin/sh'}
return args.directories, shells
# Initialize server settings and create server instance - skip arg parsing for tests
if 'pytest' not in sys.modules:
directories, shells = parse_args()
settings = Settings(directories=directories, shells=shells)
else:
settings = Settings(directories=['/tmp'], shells={'bash': '/bin/bash'})
server = Server(settings.APP_NAME)
async def run_shell_command(shell: str, command: str, cwd: str) -> Dict[str, Any]:
"""
Execute a shell command safely and return its output.
Args:
shell (str): Name of the shell to use
command (str): The command to execute
cwd (str): Working directory for command execution
Returns:
Dict[str, Any]: Command execution results including stdout, stderr, and exit code
"""
if not settings.is_path_allowed(cwd):
raise ValueError(f"Directory '{cwd}' is not in the allowed directories list")
if shell not in settings.ALLOWED_SHELLS:
raise ValueError(f"Shell '{shell}' is not allowed. Available shells: {list(settings.ALLOWED_SHELLS.keys())}")
shell_path = settings.ALLOWED_SHELLS[shell]
try:
if sys.platform == 'win32':
shell_cmd = [shell_path, '/c', command] if shell == 'cmd' else [shell_path, '-Command', command]
else:
shell_cmd = [shell_path, '-c', command]
process = await asyncio.create_subprocess_exec(
*shell_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=settings.COMMAND_TIMEOUT
)
return {
"stdout": stdout.decode() if stdout else "",
"stderr": stderr.decode() if stderr else "",
"exit_code": process.returncode,
"command": command,
"shell": shell,
"cwd": cwd
}
except asyncio.TimeoutError:
try:
process.kill()
await process.wait()
except ProcessLookupError:
pass
raise TimeoutError(f"Command execution timed out after {settings.COMMAND_TIMEOUT} seconds")
except TimeoutError:
raise
except Exception as e:
return {
"stdout": "",
"stderr": str(e),
"exit_code": -1,
"command": command,
"shell": shell,
"cwd": cwd
}
@server.list_tools()
async def list_tools() -> List[types.Tool]:
"""List available shell tools."""
return [
types.Tool(
name="execute_command",
description="Execute a shell command in a specified directory using a specified shell",
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute",
},
"shell": {
"type": "string",
"description": f"Shell to use for execution. Available: {list(settings.ALLOWED_SHELLS.keys())}",
},
"cwd": {
"type": "string",
"description": "Working directory for command execution",
},
},
"required": ["command", "shell", "cwd"],
},
)
]
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""
Handle tool calls for shell command execution.
Args:
name (str): The name of the tool to call (must be 'execute_command')
arguments (Dict[str, Any]): Tool arguments including 'command', 'shell', and 'cwd'
Returns:
List[types.TextContent]: The command execution results or error message
"""
if name != "execute_command":
return [types.TextContent(type="text", text=f"Error: Unknown tool {name}")]
command = arguments["command"]
shell = arguments["shell"]
cwd = arguments["cwd"]
try:
result = await run_shell_command(shell, command, cwd)
return [types.TextContent(type="text", text=str(result))]
except Exception as e:
return [types.TextContent(type="text", text=f"Error: {str(e)}")]
async def main():
"""Main entry point for the shell MCP server."""
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
InitializationOptions(
server_name=settings.APP_NAME,
server_version=settings.APP_VERSION,
capabilities=server.get_capabilities(
notification_options=NotificationOptions(),
experimental_capabilities={},
),
),
)