Bootstrap tree for the media box Linux conversion

Autoinstall lays down a thin base (sshd, key, DHCP, Docker CE, /srv) and hands
off to this repo on first boot. Everything interesting stays in git so it is
reviewable and re-runnable, rather than frozen onto a USB nobody can diff.

Stages, all idempotent:
  00-preflight  asserts hardware/BIOS state, changes nothing. Catches a BIOS
                update having silently re-enabled Secure Boot, which would stop
                the NVIDIA DKMS module loading on a box with no keyboard.
  10-secrets    ADD-ONLY seeder for /srv/secrets/stacks.env. Never overwrites an
                existing key. Verified against a pre-populated file: existing
                values, unrelated keys, the operator tier and existing manifest
                lines all survive byte-for-byte; a second run is a no-op.
  20-cifs       the 8 shares Plex actually uses (Share is excluded, it is not a
                library root). \040 escaping, nofail + x-systemd.automount +
                _netdev. Managed-block rewrite verified not to duplicate or to
                drop the root fstab entry.
  30-nvidia     nvidia-driver-580 explicitly: 580 is the LAST branch supporting
                Pascal, and the -open modules need Turing+. Pins against newer
                branches. Not in late-commands because DKMS needs the installed
                kernel, not the installer's.
  40-shell-mcp  builds the native MCP locally for amd64; refuses to finish
                unless /sse returns 401 without a token.
  50-plex       run by hand: PLEX_CLAIM expires in 4 minutes. Refuses to start
                against missing mounts and disables autoEmptyTrash, which with
                read-write NAS credentials is the most dangerous default here.

shell-mcp was built on arm64 originally. It builds clean on amd64 (whole dep
tree resolves to prebuilt manylinux x86_64 wheels, no compiler needed), but
dependencies are now pinned - the original installed mcp/starlette/uvicorn
unpinned and starlette has since gone 1.x. Port moved to 8103 so NPM host 42
can simply be repointed, and the tool description now says media box rather
than arrsstack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-07-27 18:12:44 -04:00
co-authored by Claude Opus 5
parent 965f5f5ba0
commit 7eadcdf9e8
14 changed files with 1142 additions and 4 deletions
+188
View File
@@ -0,0 +1,188 @@
#!/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.
Commands run in the host namespaces via nsenter — full root on the media box.
"""
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 30, max 300",
"default": 30,
},
},
"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", 30)), 300)
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)}"
)
try:
result = subprocess.run(
host_cmd,
shell=True,
executable="/bin/bash",
capture_output=True,
text=True,
timeout=timeout,
)
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")