"""External Terminal MCP Run: uv run sme_external_terminal_mcp_server Tools: - list_shells: Returns a list of available shell executables. - execute_command: Runs a command in the specified shell. Notes: Update BANNED_COMMANDS to ensure that potentially dangerous commands are blocked. """ from __future__ import annotations import logging import sys import subprocess import re from fastmcp import FastMCP from dotenv import load_dotenv from .shell_utils import ( BANNED_COMMANDS, SHELLS ) from .config import ( # CONTROL_CHAR, # TODO HTTP_PORT, REQUIRE_TOKEN, TOKEN_FIELD, USE_STREAMABLE_HTTP, ) from .auth_utils import ( check_env_var, get_env_var_or_request, ) load_dotenv() logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s' ) mcp = FastMCP("SME External Terminal MCP Server") HELP_TEXT = ( "SME External Terminal MCP Server\n\n" "Usage:\n" " sme_external_terminal_mcp_server [--help] [--version]\n\n" "Environment:\n" " USE_STREAMABLE_HTTP=true Run in HTTP mode (default stdio)\n" " HTTP_PORT=4100 Port for HTTP mode\n" " REQUIRE_TOKEN=true Enforce token authentication\n" " TOKEN_FIELD=SOME_TOKEN Env var name holding token value\n\n" "Tools:\n" " list_shells List detected safe shell executables\n" " execute_command Run a command via a selected shell\n" ) def _resolve_token(passed: str | None, ctx=None) -> tuple[str | None, str]: """Resolve a token and its source. Order of resolution: 1. Explicit argument 2. Environment / headers / ctx via get_env_var_or_request Returns: (token or None, source string) """ if passed: return passed, "arg" try: resolved = get_env_var_or_request(TOKEN_FIELD, ctx) except Exception: return None, "none" if isinstance(resolved, dict): # {"value": str, "source": str} return resolved.get("value"), resolved.get("source", "unknown") if isinstance(resolved, str): return resolved, "fallback" return None, "none" def _auth(passed_token: str | None, ctx=None): """Authenticate based on REQUIRE_TOKEN and provided context. Returns None if auth passes (or not required). Returns error dict on failure. This keeps tool bodies small. """ token_field = TOKEN_FIELD if not REQUIRE_TOKEN and not passed_token: return None # Auth not required; no token supplied. token, _source = _resolve_token(passed_token, ctx) # TODO: Ensure TOKEN_FIELD is processed for http-header auth.... # if _source == 'http': # # break by control_char # token_field = token_field.split(CONTROL_CHAR)[0].strip() if not token: if REQUIRE_TOKEN: return {"error": "Authentication required: token missing."} # Optional mode with no token: allow. return None if not check_env_var(token_field, token): return {"error": "Invalid token."} return None def _run(cmd: list[str]) -> tuple[int, str, str]: """Run a subprocess and return (rc, stdout, stderr).""" try: proc = subprocess.run( cmd, capture_output=True, text=True, check=False ) return proc.returncode, proc.stdout.strip(), proc.stderr.strip() except Exception as exc: # pragma: no cover (system dependent) return 1, "", str(exc) @mcp.tool( description="Return a list of shells to execute in.", annotations={ "token": "Optional auth token." }) def list_shells(token: str | None = None, ctx=None): """Return a list of available shell executables. Optionally gated by token if REQUIRE_TOKEN=true or a token is supplied. """ auth_err = _auth(token, ctx) if auth_err: return auth_err return {"value": SHELLS} @mcp.tool( description=( "Execute a command in an external shell, and return the output." ), annotations={ "shell_path": "Path to the shell executable (e.g., /bin/bash).", "command": ( "Shell command to execute (e.g., ffmpeg -i input.mp4 " "output.mp3)."), "token": "Optional auth token (required if REQUIRE_TOKEN=true).", } ) def execute_command( shell_path: str, command: str, token: str | None = None, ctx=None ): """Execute a command in an external shell, and return the output. Args: shell_path (str): Path to the shell executable (e.g., /bin/bash). command (str): Shell command to execute (e.g., ffmpeg -i input.mp4 output.mp3). Returns: dict: The result of the command execution, including either "value" or "error". """ # Auth (short-circuit on failure) auth_err = _auth(token, ctx) if auth_err: return auth_err # Validate that the shell is available if shell_path not in SHELLS: return {"error": f"Shell is not available: {shell_path}"} # Check if passed command matches any banned pattern (case-insensitive) for pattern in BANNED_COMMANDS: if re.search(pattern, command, re.IGNORECASE): return {"error": f"Command is not allowed: {command}"} # Run command cmd = [shell_path, "-c", command] rc, out, err = _run(cmd) if rc != 0: return {"error": err or "Command execution failed"} return {"value": out} def main() -> None: """Entry point console script. Supports minimal flag parsing for --help / --version without pulling in argparse (keeps startup tiny for MCP embedding). """ argv = sys.argv[1:] if any(a in ("-h", "--help") for a in argv): print(HELP_TEXT) return if any(a in ("-V", "--version") for a in argv): try: from . import __version__ # local import to avoid cycles print(__version__) except Exception: print("unknown") return mode = "HTTP" if USE_STREAMABLE_HTTP else "stdio" logging.info("Starting SME External Terminal MCP Server (%s)", mode) if USE_STREAMABLE_HTTP: mcp.run(transport="streamable-http", port=HTTP_PORT) else: mcp.run() if __name__ == "__main__": # pragma: no cover main()