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:
@@ -0,0 +1,26 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# docker.io provides the Docker CLI; util-linux provides nsenter for host
|
||||
# namespace access. Both exist for amd64 — verified 2026-07-27.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends docker.io util-linux && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Pinned install. Every dependency in this tree resolves to a prebuilt
|
||||
# manylinux x86_64 wheel (pydantic-core, rpds-py, cffi, cryptography), so no
|
||||
# compiler is needed and the build is fast on amd64.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY server.py .
|
||||
|
||||
EXPOSE 8103
|
||||
|
||||
# Fails fast and visibly if the container is up but the app is wedged.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request,os,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8103')+'/health',timeout=3).status==200 else 1)"
|
||||
|
||||
CMD ["python", "server.py"]
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
shell-mcp:
|
||||
build:
|
||||
context: .
|
||||
image: shell-mcp:mediabox
|
||||
container_name: shell-mcp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Published on the LAN so NPM on arrsstack can reach 10.0.1.20:8103.
|
||||
# No arrstack_arr_net here — that network lives on the Pi and does not
|
||||
# exist on this host. The bearer token is the only gate on this port.
|
||||
- "8103:8103"
|
||||
environment:
|
||||
- BEARER_TOKEN=${MEDIABOX_MCP_BEARER_TOKEN}
|
||||
- PORT=8103
|
||||
- TZ=${TZ:-America/New_York}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# pid: host + privileged + nsenter --target 1 is what gives this container
|
||||
# full root on the media box host, exactly as on arrsstack.
|
||||
pid: host
|
||||
privileged: true
|
||||
@@ -0,0 +1,14 @@
|
||||
# Pinned deliberately.
|
||||
#
|
||||
# The arrsstack build installed `mcp starlette uvicorn` unpinned. That was
|
||||
# survivable for a hand-run build; it is not survivable inside a first-boot
|
||||
# script, where an upstream release between two boots silently changes what
|
||||
# gets installed. starlette has already gone 1.x since the original build.
|
||||
#
|
||||
# These exact versions were resolved and smoke-tested on amd64 / cp312
|
||||
# (import, /health 200, /sse endpoint event) on 2026-07-27.
|
||||
#
|
||||
# To bump: change here, rebuild, hit /health and /sse, then commit.
|
||||
mcp==1.28.1
|
||||
starlette==1.3.1
|
||||
uvicorn==0.51.0
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user