#!/usr/bin/env python3 """shellmcp: an MCP server with one tool, `run`, that runs a bash command. Stdlib only, stdio. --mode readonly (default) runs every command in a bubblewrap sandbox: the filesystem is read-only except a private /tmp, there is no network, no other process is visible, and the --hide paths (always ~/.ssh and ~/.gnupg) look empty. --mode all runs commands with the user's full permissions; let the MCP client ask before each call. """ import argparse import getpass import json import os import shutil import signal import socket import subprocess import sys ENV = {"PAGER": "cat", "GIT_PAGER": "cat", "NO_COLOR": "1", "TERM": "dumb"} def cut(text, limit): """text, or its head and tail around a note when it is longer than limit.""" if len(text) <= limit: return text half = limit // 2 return f"{text[:half]}\n[... {len(text) - 2 * half} characters cut ...]\n{text[-half:]}" def run(command, cwd, timeout=60, limit=8000, prefix=()): """The exit code and combined stdout and stderr of `bash -c command`, as one text.""" proc = subprocess.Popen([*prefix, "bash", "-c", command], cwd=cwd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env={**os.environ, **ENV}, start_new_session=True) try: out, _ = proc.communicate(timeout=timeout) head = f"exit code {proc.returncode}" except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) try: out, _ = proc.communicate(timeout=5) except subprocess.TimeoutExpired: # a process left the group and holds the pipe out = b"" head = f"timed out after {timeout} s, killed" return f"{head}\n{cut(out.decode(errors='replace'), limit)}" def sandbox(cwd, hide=()): """The bwrap argv prefix of --mode readonly.""" argv = ["bwrap", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--tmpfs", "/run", "--unshare-net", "--unshare-ipc", "--unshare-pid", "--die-with-parent", "--new-session"] for path in hide: if os.path.isdir(path): argv += ["--tmpfs", path] elif os.path.exists(path): argv += ["--ro-bind", "/dev/null", path] return argv + ["--chdir", cwd] PREFERRED = { # named in the tool description when installed, in this order "rg": "search file contents (instead of grep)", "fd": "find files (instead of find)", "eza": "list files (instead of ls)", "sd": "find and replace (instead of sed)", "dust": "disk usage (instead of du)", "procs": "processes (instead of ps)", "jq": "query JSON", "ast-grep": "search code by syntax", "tokei": "count lines of code"} def describe(mode, cwd, timeout, limit): """The tool description: where commands run, the limits, and the modern tools installed.""" text = (f"Run a bash command on {socket.gethostname()} as {getpass.getuser()} in {cwd}. " f"Returns its exit code and combined stdout and stderr (at most {limit} characters; " f"the middle of longer output is cut). Commands are killed after {timeout} s. " "There is no terminal: interactive programs, pagers and prompts fail.") if mode == "readonly": text += (" Read-only sandbox: nothing can be written except /tmp, which is emptied " "after every command; no network; other processes are not visible. To change " "something, give the user the command to run instead.") else: text += (" Commands run with the user's full permissions: they can change files, " "processes and the network.") tools = [f"{name}: {what}" for name, what in PREFERRED.items() if shutil.which(name)] if tools: text += " Installed, prefer these: " + "; ".join(tools) + "." return text