Files
shellmcp/shellmcp.py
T

59 lines
2.3 KiB
Python

#!/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]