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.
200 lines
7.8 KiB
Python
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")
|