Unix Manual Server
by tizee
import os
import re
import subprocess
import logging
from mcp.server.fastmcp import FastMCP
# Configure logging
# Get the directory where this script is located
script_dir = os.path.dirname(os.path.abspath(__file__))
log_file = os.path.join(script_dir, "unix-manual-server.log")
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file),
logging.StreamHandler()
]
)
logger = logging.getLogger("unix-manual-server")
logger.info(f"Logging to: {log_file}")
# Create an MCP server instance
mcp = FastMCP("unix-manual-server")
def get_command_path(command):
"""Get the full absolute path to a command by filtering shell output."""
logger.debug(f"Searching for command path: {command}")
try:
# Use the user's shell (defaulting to /bin/zsh) with login to load the full environment.
user_shell = os.environ.get('SHELL', '/bin/zsh')
logger.debug(f"Using shell: {user_shell}")
result = subprocess.run(
[user_shell, "-l", "-c", f"command -v {command} 2>/dev/null"],
capture_output=True,
text=True
)
# Process the output line by line and return the first line that is a valid absolute path.
for line in result.stdout.splitlines():
if re.match(r'^/', line):
path = line.strip()
logger.debug(f"Found command path: {path}")
return path
logger.warning(f"Command not found: {command}")
return None
except subprocess.SubprocessError as e:
logger.error(f"Error finding command path for {command}: {str(e)}")
return None
def safe_execute(cmd_args, timeout=10):
"""Safely execute a command directly (not through shell) and return its output."""
logger.debug(f"Executing command: {cmd_args} with timeout={timeout}")
try:
# Execute command directly without shell
result = subprocess.run(
cmd_args,
capture_output=True,
text=True,
timeout=timeout,
shell=False # Explicitly set shell=False for security
)
logger.debug(f"Command exit code: {result.returncode}")
# Add debug output to see first 100 chars of stdout
if result.stdout:
logger.debug(f"Command stdout first 100 chars: {result.stdout[:100].replace('\n', '\\n')}")
return result
except subprocess.TimeoutExpired:
logger.warning(f"Command timed out after {timeout} seconds: {cmd_args}")
return None
except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
logger.error(f"Error executing command {cmd_args}: {str(e)}")
return None
def search_help_documentation(main_command, command_path):
"""Search for help documentation using --help, -h, or help options."""
logger.info(f"Searching for help documentation for command: {main_command} at {command_path}")
# Try --help
logger.debug(f"Trying --help for {main_command}")
help_result = safe_execute([command_path, "--help"], timeout=5)
if help_result and help_result.returncode < 2 and help_result.stdout.strip():
# Verify this is actual help text, not just command execution output
output = help_result.stdout.strip()
# Most help text contains words like "usage", "options", or "help"
# Adding additional terms "USAGE" and checking for version string patterns
if (re.search(r'usage|options|help|Usage|Options|Help|USAGE|OPTIONS|HELP|USAGE:|VERSION|Version', output, re.IGNORECASE) or
re.search(r'\d+\.\d+\.\d+', output)): # Version number pattern
logger.info(f"Found help documentation using --help for {main_command}")
return f"Help output for '{main_command}':\n\n{output}"
else:
# Add debug output to see what we're getting
logger.debug(f"--help output did not match help text pattern:\n{output[:200]}...")
# Try -h
logger.debug(f"Trying -h for {main_command}")
help_result = safe_execute([command_path, "-h"], timeout=5)
if help_result and help_result.returncode < 2 and help_result.stdout.strip():
output = help_result.stdout.strip()
if (re.search(r'usage|options|help|Usage|Options|Help|USAGE|OPTIONS|HELP|USAGE:|VERSION|Version', output, re.IGNORECASE) or
re.search(r'\d+\.\d+\.\d+', output)): # Version number pattern
logger.info(f"Found help documentation using -h for {main_command}")
return f"Help output for '{main_command}':\n\n{output}"
else:
logger.debug(f"-h output did not match help text pattern:\n{output[:200]}...")
# Try help
logger.debug(f"Trying help subcommand for {main_command}")
help_result = safe_execute([command_path, "help"], timeout=5)
if help_result and help_result.returncode < 2 and help_result.stdout.strip():
output = help_result.stdout.strip()
if re.search(r'usage|options|help|Usage|Options|Help|USAGE|OPTIONS|HELP', output, re.IGNORECASE):
logger.info(f"Found help documentation using help subcommand for {main_command}")
return f"Help output for '{main_command}':\n\n{output}"
else:
logger.debug(f"help subcommand output did not match help text pattern:\n{output[:200]}...")
# If we get here, no valid help documentation was found
logger.warning(f"No help documentation found for {main_command}")
return ""
@mcp.tool()
def get_command_documentation(command: str, prefer_economic: bool = True, man_section: int = None) -> str:
"""
Get documentation for a command in Unix-like system.
Args:
command: The command to get documentation for (no arguments)
prefer_economic: Whether to prefer the economic approach (--help/-h/help) [default: True]
man_section: Specific manual section to look in (1-9) [optional]
Returns:
The command documentation as a string
"""
logger.info(f"Getting documentation for command: '{command}', prefer_economic={prefer_economic}, man_section={man_section}")
# Parse the command input to separate main command from subcommands/arguments
parts = command.strip().split()
main_command = parts[0] # Extract the base command
logger.debug(f"Main command: {main_command}")
# Check if there's a subcommand (at least 2 parts and not an option)
has_subcommand = len(parts) > 1 and not parts[1].startswith('-')
subcommand = parts[1] if has_subcommand else None
cmd_with_subcommand = f"{main_command} {subcommand}" if has_subcommand else None
if has_subcommand:
logger.debug(f"Detected subcommand: '{subcommand}', will try '{cmd_with_subcommand}' first")
# Validate command name (basic check to prevent injection)
if not re.match(r'^[a-zA-Z0-9_\.-]+$', main_command):
logger.warning(f"Invalid command name: '{main_command}'")
return f"Invalid command name: '{main_command}'"
# Get full path to command
command_path = get_command_path(main_command)
if not command_path:
logger.warning(f"Command not found: '{main_command}'")
return f"Command not found: '{main_command}'"
# Try economic approach for subcommand first if available
if has_subcommand and prefer_economic:
logger.debug(f"Trying economic approach for subcommand: {cmd_with_subcommand}")
# Try --help for subcommand
help_cmd = safe_execute([command_path, subcommand, "--help"], timeout=5)
if help_cmd and help_cmd.stdout and help_cmd.returncode < 2:
logger.info(f"Found help docs using --help for subcommand {cmd_with_subcommand}")
return f"Help output for '{cmd_with_subcommand}':\n\n{help_cmd.stdout.strip()}"
# Try -h for subcommand
help_cmd = safe_execute([command_path, subcommand, "-h"], timeout=5)
if help_cmd and help_cmd.stdout and help_cmd.returncode < 2:
logger.info(f"Found help docs using -h for subcommand {cmd_with_subcommand}")
return f"Help output for '{cmd_with_subcommand}':\n\n{help_cmd.stdout.strip()}"
# Try help subcommand for subcommand
help_cmd = safe_execute([command_path, subcommand, "help"], timeout=5)
if help_cmd and help_cmd.stdout and help_cmd.returncode < 2:
logger.info(f"Found help docs using help subcommand for {cmd_with_subcommand}")
return f"Help output for '{cmd_with_subcommand}':\n\n{help_cmd.stdout.strip()}"
logger.debug(f"No help documentation found for subcommand {cmd_with_subcommand}, falling back to main command")
# Try economic approach for the main command
if prefer_economic:
logger.debug(f"Trying economic approach for main command: {main_command}")
help_result = search_help_documentation(main_command, command_path)
if help_result:
return help_result
# Direct check if the previous function failed but command exists
# Try direct approach for well-known patterns
if command_path:
# Try --help directly
help_cmd = safe_execute([command_path, "--help"], timeout=5)
if help_cmd and help_cmd.stdout and help_cmd.returncode < 2:
logger.info(f"Found help docs by direct --help check for {main_command}")
return f"Help output for '{main_command}':\n\n{help_cmd.stdout.strip()}"
# Use man as fallback or if economic approach not preferred
logger.debug(f"Trying man page for {main_command}")
# Execute man directly without going through shell
man_args = ["man"]
if man_section is not None and 1 <= man_section <= 9:
man_args.append(str(man_section))
logger.debug(f"Using man section {man_section}")
man_args.append(main_command)
try:
# Use col to strip formatting from man output
logger.debug(f"Executing man command: {man_args}")
man_result = subprocess.run(
man_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=10
)
# Pipe the man output through col to remove formatting
if man_result.returncode == 0:
logger.debug("Man command succeeded, processing with col")
col_result = subprocess.run(
["col", "-b"],
input=man_result.stdout,
capture_output=True,
text=True
)
man_text = col_result.stdout
logger.info(f"Successfully retrieved man page for {main_command}")
return f"Manual page for '{main_command}':\n\n{man_text}"
else:
logger.warning(f"Man command failed with exit code {man_result.returncode}, stderr: {man_result.stderr}")
except Exception as e:
logger.error(f"Error executing man command for {main_command}: {str(e)}")
# If we tried man first and it failed, try economic approach as fallback
if not prefer_economic:
logger.debug(f"Man failed, trying economic approach as fallback for {main_command}")
help_result = search_help_documentation(main_command, command_path)
if help_result:
return help_result
# If everything failed
logger.warning(f"All documentation methods failed for '{command}'")
return f"No documentation available for '{command}'"
@mcp.tool()
def list_common_commands() -> str:
"""
List common Unix commands available on the system.
Returns:
A list of common Unix commands
"""
logger.info("Listing common commands")
# Define common directories in PATH that contain commands
common_dirs = ['/bin', '/usr/bin', '/usr/local/bin']
logger.debug(f"Searching in directories: {common_dirs}")
commands = []
for directory in common_dirs:
if os.path.exists(directory) and os.path.isdir(directory):
logger.debug(f"Scanning directory: {directory}")
# List only executable files
try:
for file in os.listdir(directory):
file_path = os.path.join(directory, file)
if os.path.isfile(file_path) and os.access(file_path, os.X_OK):
commands.append(file)
except Exception as e:
logger.error(f"Error listing directory {directory}: {str(e)}")
# Remove duplicates and sort
commands = sorted(set(commands))
logger.info(f"Found {len(commands)} unique commands")
# Return a formatted string with command categories
result = "Common Unix commands available on this system:\n\n"
# File operations
file_cmds = [cmd for cmd in commands if cmd in ['ls', 'cp', 'mv', 'rm', 'mkdir', 'touch', 'chmod', 'chown', 'find', 'grep']]
if file_cmds:
logger.debug(f"File operation commands found: {len(file_cmds)}")
result += "File Operations:\n" + ", ".join(file_cmds) + "\n\n"
# Text processing
text_cmds = [cmd for cmd in commands if cmd in ['cat', 'less', 'more', 'head', 'tail', 'grep', 'sed', 'awk', 'sort', 'uniq', 'wc']]
if text_cmds:
logger.debug(f"Text processing commands found: {len(text_cmds)}")
result += "Text Processing:\n" + ", ".join(text_cmds) + "\n\n"
# System information
sys_cmds = [cmd for cmd in commands if cmd in ['ps', 'top', 'htop', 'df', 'du', 'free', 'uname', 'uptime', 'who', 'whoami']]
if sys_cmds:
logger.debug(f"System info commands found: {len(sys_cmds)}")
result += "System Information:\n" + ", ".join(sys_cmds) + "\n\n"
# Network tools
net_cmds = [cmd for cmd in commands if cmd in ['ping', 'netstat', 'ifconfig', 'ip', 'ssh', 'scp', 'curl', 'wget']]
if net_cmds:
logger.debug(f"Networking commands found: {len(net_cmds)}")
result += "Networking:\n" + ", ".join(net_cmds) + "\n\n"
# Show total count
result += f"Total commands found: {len(commands)}\n"
result += "Use get_command_documentation() to learn more about any command."
return result
@mcp.tool()
def check_command_exists(command: str) -> str:
"""
Check if a command exists on the system.
Args:
command: The command to check
Returns:
Information about whether the command exists
"""
logger.info(f"Checking if command exists: '{command}'")
command_name = command.strip().split()[0]
logger.debug(f"Extracted command name: {command_name}")
if not re.match(r'^[a-zA-Z0-9_\.-]+$', command_name):
logger.warning(f"Invalid command name: '{command_name}'")
return f"Invalid command name: '{command_name}'"
command_path = get_command_path(command_name)
if command_path:
logger.info(f"Command '{command_name}' exists at {command_path}")
# Try --version
logger.debug(f"Trying --version for {command_name}")
version_result = safe_execute([command_path, "--version"], timeout=5)
if version_result and version_result.returncode < 2 and version_result.stdout.strip():
logger.debug(f"Got version info using --version for {command_name}")
return f"Command '{command_name}' exists at {command_path}.\nVersion information: {version_result.stdout.strip()}"
# Try -V (some commands use this for version)
logger.debug(f"Trying -V for {command_name}")
version_result = safe_execute([command_path, "-V"], timeout=5)
if version_result and version_result.returncode < 2 and version_result.stdout.strip():
logger.debug(f"Got version info using -V for {command_name}")
return f"Command '{command_name}' exists at {command_path}.\nVersion information: {version_result.stdout.strip()}"
# Try version
logger.debug(f"Trying version subcommand for {command_name}")
version_result = safe_execute([command_path, "version"], timeout=5)
if version_result and version_result.returncode < 2 and version_result.stdout.strip():
logger.debug(f"Got version info using version subcommand for {command_name}")
return f"Command '{command_name}' exists at {command_path}.\nVersion information: {version_result.stdout.strip()}"
return f"Command '{command_name}' exists on this system at {command_path}."
else:
logger.warning(f"Command '{command_name}' does not exist or is not in the PATH")
return f"Command '{command_name}' does not exist or is not in the PATH."
def main():
logger.info("Starting unix-manual-server")
try:
mcp.run()
except Exception as e:
logger.critical(f"Fatal error in MCP server: {str(e)}", exc_info=True)
if __name__ == "__main__":
main()