shellmcp: MCP stdio server and CLI

This commit is contained in:
2026-11-05 10:17:21 +01:00
parent 60219ef8f8
commit e6ec0e6882
2 changed files with 103 additions and 0 deletions
Regular → Executable
+70
View File
@@ -81,3 +81,73 @@ def describe(mode, cwd, timeout, limit):
if tools:
text += " Installed, prefer these: " + "; ".join(tools) + "."
return text
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") # newest first
HIDDEN = ("~/.ssh", "~/.gnupg")
def error(mid, code, message):
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
def handle(message, tool, call):
"""The JSON-RPC response to one message; None for a notification."""
mid, method, params = message.get("id"), message.get("method"), message.get("params") or {}
if mid is None:
return None
if method == "initialize":
asked = params.get("protocolVersion")
result = {"protocolVersion": asked if asked in PROTOCOLS else PROTOCOLS[0],
"capabilities": {"tools": {}},
"serverInfo": {"name": "shellmcp", "version": "0.1.0"}}
elif method == "ping":
result = {}
elif method == "tools/list":
result = {"tools": [tool]}
elif method == "tools/call":
command = (params.get("arguments") or {}).get("command")
if params.get("name") != "run" or not isinstance(command, str):
return error(mid, -32602, "call the tool `run` with a string `command`")
result = {"content": [{"type": "text", "text": call(command)}], "isError": False}
else:
return error(mid, -32601, f"unknown method {method}")
return {"jsonrpc": "2.0", "id": mid, "result": result}
def main(argv=None):
p = argparse.ArgumentParser(description="MCP server with one tool, run: a bash command.")
p.add_argument("--mode", choices=("readonly", "all"), default="readonly",
help="readonly: bwrap sandbox (default); all: the user's full permissions")
p.add_argument("--cwd", default=os.getcwd(), help="directory commands run in")
p.add_argument("--timeout", type=int, default=60, help="seconds before a command is killed")
p.add_argument("--max-output", type=int, default=8000, help="characters of output kept")
p.add_argument("--hide", action="append", default=[], metavar="PATH",
help="readonly: also make PATH look empty (repeatable; ~/.ssh and ~/.gnupg "
"always are)")
a = p.parse_args(argv)
cwd = os.path.abspath(a.cwd)
if a.mode == "readonly" and not shutil.which("bwrap"):
sys.exit("shellmcp: --mode readonly needs bwrap (bubblewrap)")
hide = [os.path.expanduser(h) for h in (*HIDDEN, *a.hide)]
prefix = sandbox(cwd, hide) if a.mode == "readonly" else ()
tool = {"name": "run", "description": describe(a.mode, cwd, a.timeout, a.max_output),
"inputSchema": {"type": "object", "required": ["command"], "properties": {
"command": {"type": "string", "description": "a bash command line"}}}}
# ponytail: one request at a time; a long command delays the next call (no cancellation)
for line in sys.stdin:
if not line.strip():
continue
try:
message = json.loads(line)
except ValueError:
message = None
if not isinstance(message, dict):
reply = error(None, -32700, "parse error")
else:
reply = handle(message, tool, lambda c: run(c, cwd, a.timeout, a.max_output, prefix))
if reply is not None:
print(json.dumps(reply), flush=True)
if __name__ == "__main__":
main()