Files
mediabox-bootstrap/shell-mcp/server.py
T
thethreemagi d6760a709d shell-mcp: don't block the event loop; honor longer timeouts
subprocess.run inside the async handler serialized every request behind
the slowest command (found 2026-08-01 when a du wedged the server mid-
migration). Run it in the default thread pool via run_in_executor so
concurrent calls actually run concurrently. Timeout default 30->60,
cap 300->600, schema text matched.
2026-08-02 03:45:46 +01:00

200 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
shell-mcp (mediabox) — run shell commands on the media box host via MCP/SSE.
Adapted from thethreemagi/shell-mcp, which was written for and built on the
arrsstack Pi 5 (arm64). Differences that matter:
* Runs on 10.0.1.20 (amd64), NOT on arrsstack. The tool description below is
rewritten accordingly — if it still claimed to be the Pi, every session
would start with the wrong mental model of which host it is touching.
* Listens on 8103 by default, not 8085, so NPM proxy host 42 can simply be
repointed from mediabox-mcp:8103 to 10.0.1.20:8103. Same URL, same cert,
same connector entry.
* PORT is read from the environment instead of being hardcoded.
* Dependencies are pinned (see requirements.txt). The original installed
`mcp starlette uvicorn` unpinned; starlette has since gone 1.x. An
unpinned install inside a first-boot script is a time bomb.
* 2026-08-02: commands run in a worker thread (run_in_executor), not
inline in the async handler. The original blocked uvicorn's event loop
for the duration of every command, serializing ALL requests behind the
slowest one — discovered mid-migration when a long du wedged the server.
Commands run in the host namespaces via nsenter — full root on the media box.
"""
import asyncio
import os
import shlex
import subprocess
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.types import Tool, TextContent
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route
import uvicorn
BEARER_TOKEN = os.environ["BEARER_TOKEN"]
PORT = int(os.environ.get("PORT", "8103"))
# ── MCP server ──────────────────────────────────────────────────────────────
mcp = Server("shell-mcp")
@mcp.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="run_command",
description=(
"Run a shell command on the MEDIA BOX host (10.0.1.20, hostname "
"'mediabox') with full root access. This is the Plex / Docker / "
"GPU host — an amd64 machine with an Intel i7-8700K, UHD 630 "
"Quick Sync, and an NVIDIA GTX 1070. It is NOT arrsstack; that "
"is a separate Raspberry Pi connector. Commands run in host "
"namespaces via nsenter, so filesystem, network, processes and "
"systemd services are all the real host. Docker CLI available. "
"The NAS is mounted under /mnt/nas/. Use for container "
"management, service control, log inspection, file read/write, "
"and general system administration."
),
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "Shell command to execute on the media box host (bash -c)",
},
"working_directory": {
"type": "string",
"description": "Directory on the host to run the command in (optional)",
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds, default 60, max 600",
"default": 60,
},
},
"required": ["command"],
},
)
]
@mcp.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name != "run_command":
return [TextContent(type="text", text=f"Unknown tool: {name}")]
command = arguments["command"]
working_dir = arguments.get("working_directory")
timeout = min(int(arguments.get("timeout", 60)), 600)
inner = f"cd {shlex.quote(working_dir)} && {command}" if working_dir else command
host_cmd = (
"nsenter --target 1 --mount --uts --ipc --net --pid -- "
f"bash -c {shlex.quote(inner)}"
)
# Run the blocking subprocess in the default thread pool. Doing this inline
# would block uvicorn's event loop and serialize every request behind the
# slowest command — a long du froze the whole server for minutes once.
def _run() -> subprocess.CompletedProcess:
return subprocess.run(
host_cmd,
shell=True,
executable="/bin/bash",
capture_output=True,
text=True,
timeout=timeout,
)
try:
result = await asyncio.get_running_loop().run_in_executor(None, _run)
parts = []
if result.stdout:
parts.append(result.stdout.rstrip())
if result.stderr:
parts.append(f"[stderr]\n{result.stderr.rstrip()}")
if result.returncode != 0:
parts.append(f"[exit code: {result.returncode}]")
return [TextContent(type="text", text="\n".join(parts) or "(no output)")]
except subprocess.TimeoutExpired:
return [TextContent(type="text", text=f"[timed out after {timeout}s]")]
except Exception as e:
return [TextContent(type="text", text=f"[error: {e}]")]
# ── SSE transport ───────────────────────────────────────────────────────────
sse = SseServerTransport("/messages/")
async def health_endpoint(request: Request):
return JSONResponse({"status": "ok", "host": "mediabox"})
starlette_app = Starlette(
routes=[
Route("/health", endpoint=health_endpoint),
Mount("/messages/", app=sse.handle_post_message),
],
)
async def app(scope, receive, send):
if scope.get("type") != "http":
await starlette_app(scope, receive, send)
return
path = scope.get("path", "")
method = scope.get("method", "").upper()
if path == "/sse":
# Only GET establishes an SSE stream; reject everything else
if method != "GET":
await send({"type": "http.response.start", "status": 405,
"headers": [(b"content-type", b"text/plain"),
(b"allow", b"GET")]})
await send({"type": "http.response.body", "body": b"Method Not Allowed",
"more_body": False})
return
# Auth gate. Claude.ai's connector UI has no bearer-token field, so the
# token may arrive as ?token=; the Authorization header is also accepted.
query_string = scope.get("query_string", b"").decode()
token = None
for part in query_string.split("&"):
if part.startswith("token="):
token = part[6:]
break
auth_header = ""
for name, value in scope.get("headers", []):
if name.lower() == b"authorization":
auth_header = value.decode()
break
if token != BEARER_TOKEN and auth_header != f"Bearer {BEARER_TOKEN}":
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"Unauthorized",
"more_body": False})
return
# Handle SSE directly — bypasses Starlette Route, no NoneType crash on close
async with sse.connect_sse(scope, receive, send) as (read_stream, write_stream):
await mcp.run(read_stream, write_stream, mcp.create_initialization_options())
return
await starlette_app(scope, receive, send)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")