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
+100 -1
View File
@@ -1,3 +1,102 @@
# mediabox-bootstrap
First-boot bootstrap for the media box (10.0.1.20) — CIFS mounts, NVIDIA 580, native shell-mcp, Plex. Paired with projects/mediabox-linux in claude-workspace.
Everything the media box (`10.0.1.20`, hostname `mediabox`) needs after the
Ubuntu 24.04 autoinstall has laid down the base system.
The autoinstall USB is deliberately thin: sshd, a key, DHCP, Docker CE, `/srv`,
and a first-boot unit that clones **this** repo and runs `bootstrap.sh`. That
split exists so the interesting parts stay in git, reviewable and re-runnable,
instead of frozen onto a USB stick that nobody can diff.
Full context, decisions and the phase-by-phase runbook live in the workspace repo
at `projects/mediabox-linux/`.
---
## Layout
```
bootstrap.sh orchestrator — runs the stages, fails soft
scripts/
00-preflight.sh asserts hardware/BIOS state. Changes nothing.
10-secrets.sh idempotent ADD-ONLY seeder for /srv/secrets/stacks.env
20-cifs.sh the 8 NAS mounts (--verify mode included)
30-nvidia.sh NVIDIA 580 + container toolkit
40-shell-mcp.sh builds and starts the native shell-mcp
50-plex.sh Plex. Run BY HAND — needs a live claim token.
shell-mcp/ amd64 build of the MCP server (server.py, Dockerfile,
docker-compose.yml, pinned requirements.txt)
plex/docker-compose.yml Plex service definition
secrets/stacks.env.example placeholder master env
```
## Running it
```bash
sudo /srv/mediabox-bootstrap/bootstrap.sh # all stages
sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh # one stage
sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify
```
Every stage is idempotent. Re-running is the normal way to use this, not an
emergency measure.
---
## Design rules
**Nothing may leave the box unreachable.** `sshd` is up before any of this runs
and no stage may compromise that. `bootstrap.sh` catches stage failures and
continues; the systemd unit declares `SuccessExitStatus=0 1` so a bad stage can
never wedge boot. There is no keyboard attached to this machine.
**Secrets never ride on removable media.** The CIFS credentials file is created
*empty* by the autoinstall and filled in post-boot over the MCP. `PLEX_CLAIM`
tokens expire in four minutes and are passed as a one-shot environment variable,
never written to disk or committed.
**One secrets file per host.** This box has its own `/srv/secrets/stacks.env`,
using the same `[OPERATOR]` / `[VALUES]` / `[MANIFEST]` tiering as arrsstack, but
it is not shared or mounted from anywhere. Canonical copies live in Vaultwarden.
**The seeder only ever adds.** `10-secrets.sh` will never overwrite an existing
key, change its value, or move it. If `KEY=` is present it is skipped entirely.
Verified against a pre-populated file: existing values, unrelated keys, the
operator tier and existing manifest lines all survive byte-for-byte, and a second
run is a no-op.
---
## Notes that will save you an evening
**shell-mcp was originally built on arm64.** It builds clean on amd64 — the whole
Python dependency tree resolves to prebuilt manylinux x86_64 wheels, so no
compiler is needed. Two real changes were required:
- **Dependencies are now pinned.** The original installed `mcp starlette uvicorn`
unpinned. `starlette` has since gone 1.x. Unpinned installs are tolerable in a
hand-run build and dangerous inside a first-boot script.
- **Port and description.** It listens on 8103 (not 8085) so NPM proxy host 42 can
simply be repointed, and the tool description says *media box*, not *arrsstack* —
otherwise every session starts with the wrong idea of which host it is touching.
**The GTX 1070 is Pascal, and NVIDIA branch 580 is the last one that supports it.**
There will be no 590 for this card. `30-nvidia.sh` installs 580 explicitly and pins
against newer branches. Never use `ubuntu-drivers autoinstall` here — it will
cheerfully install a branch that drops the card. And it must be the proprietary
module, not `-open`: the open kernel modules need Turing or newer.
**Secure Boot must stay off.** It is off today. A BIOS update resets ASUS defaults
and turns it back on, at which point the DKMS module needs interactive MOK
enrollment at a console this box does not have. `00-preflight.sh` checks for this.
**`nofail` on every CIFS mount is not optional.** Without it, an unreachable NAS
drops a headless box to an emergency shell waiting for a root password on a
keyboard that is not plugged in.
**`autoEmptyTrash` is the most dangerous default here.** The NAS account is
read-write. If a mount is missing when Plex scans, Plex sees an empty library and
will act on that. `50-plex.sh` disables it before any library is added.
**Plex's PUID/PGID must equal the CIFS `uid=`/`gid=`.** Both are `3000` (`media`).
If they ever drift, every file is permission-denied.
Regular → Executable
+3 -3
View File
@@ -62,15 +62,15 @@ else
warn "NOT marking first-boot done — fix and re-run $0"
fi
cat &lt;&lt;'NEXT'
cat <<'NEXT'
--------------------------------------------------------------------------
REMAINING STEPS — deliberately NOT automated
--------------------------------------------------------------------------
1. Write the NAS password into the credentials file (over the MCP, never
onto the USB):
printf 'username=&lt;nas-user&gt;\npassword=&lt;nas-pass&gt;\ndomain=WORKGROUP\n' \
&gt; /etc/cifs/korval.cred
printf 'username=<nas-user>\npassword=<nas-pass>\ndomain=WORKGROUP\n' \
> /etc/cifs/korval.cred
chmod 600 /etc/cifs/korval.cred
systemctl daemon-reload
/srv/mediabox-bootstrap/scripts/20-cifs.sh --verify
+58
View File
@@ -0,0 +1,58 @@
services:
plex:
image: lscr.io/linuxserver/plex:latest
container_name: plex
restart: unless-stopped
# NOT OPTIONAL. Plex's local discovery (GDM), DLNA and the Plex-for-client
# "found a local server" path all rely on broadcast traffic that a bridge
# network silently eats. This is why Plex does not sit behind NPM the way
# every other service here does — NPM proxy host 17 already points
# plex.thewichersfamily.com at 10.0.1.20:32400 and needs no change.
network_mode: host
environment:
- PUID=3000 # MUST match uid= on the CIFS mounts
- PGID=3000 # MUST match gid= on the CIFS mounts
- TZ=${TZ:-America/New_York}
- VERSION=docker
# PLEX_CLAIM is intentionally absent from this file. Claim tokens expire
# four minutes after they are issued, so one can never be committed to a
# repo, written to a USB, or baked into an image. It is injected at first
# start by 50-plex.sh and never persisted.
- PLEX_CLAIM=${PLEX_CLAIM:-}
- ADVERTISE_IP=${PLEX_ADVERTISE_URL:-http://10.0.1.20:32400}
devices:
# Quick Sync. The i7-8700K's UHD 630 is the transcode target: no session
# cap, and better HDR tone mapping than the Pascal-era NVENC on the 1070.
- /dev/dri:/dev/dri
group_add:
# The render node is root:render, and PGID 3000 is not in that group.
# Resolved from the live host by 50-plex.sh — do not hardcode, the gid
# differs between distro releases.
- "${RENDER_GID:-993}"
volumes:
# Config lives on the SSD, deliberately. The Plex SQLite database is the
# most latency-sensitive thing on this box; it does not belong on the
# 3TB spinner.
- /srv/plex/config:/config
# NAS media, read-write per the account scope. Mounted read-only INTO the
# container for every library that Plex has no business writing to.
- /mnt/nas/audiobooks:/media/audiobooks:ro
- /mnt/nas/education-videos:/media/education-videos:ro
- /mnt/nas/health:/media/health:ro
- /mnt/nas/home-movies:/media/home-movies:ro
- /mnt/nas/media:/media/media:ro
- /mnt/nas/music-organized:/media/music-organized:ro
- /mnt/nas/pictures:/media/pictures:ro
- /mnt/nas/radio-shows:/media/radio-shows:ro
tmpfs:
# Transcodes are throwaway. Keeping them in RAM saves a great deal of
# SSD write wear. 4G of the box's 16G, capped so a pathological transcode
# cannot pressure the rest of the system.
- /transcode:rw,size=4g,mode=1777
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# =============================================================================
# 00-preflight — assert the box is what we think it is before changing it.
#
# This stage NEVER modifies anything. It only reports. Its job is to catch
# "the BIOS update reset your settings" before you spend an hour wondering
# why Plex will not transcode.
# =============================================================================
set -uo pipefail
RC=0
note() { printf ' %-34s %s\n' "$1" "$2"; }
bad() { printf ' \033[1;31m%-34s %s\033[0m\n' "$1" "$2"; RC=1; }
warn() { printf ' \033[1;33m%-34s %s\033[0m\n' "$1" "$2"; }
echo "--- identity ---"
note "hostname" "$(hostname)"
note "kernel" "$(uname -r)"
note "ip" "$(hostname -I | tr -s ' ')"
echo "--- firmware ---"
if [ -d /sys/firmware/efi ]; then note "boot mode" "UEFI"; else bad "boot mode" "LEGACY/BIOS — expected UEFI"; fi
# Secure Boot must stay OFF. A BIOS update commonly restores defaults, and the
# ASUS default for this board turns Secure Boot back on. With it on, the DKMS
# NVIDIA module will not load and there is no keyboard attached to enroll a MOK.
if command -v mokutil >/dev/null 2>&1; then
SB="$(mokutil --sb-state 2>/dev/null || echo unknown)"
case "$SB" in
*disabled*) note "secure boot" "disabled (correct)" ;;
*enabled*) bad "secure boot" "ENABLED — NVIDIA DKMS will not load. Disable it in BIOS." ;;
*) warn "secure boot" "$SB" ;;
esac
else
warn "secure boot" "mokutil not installed; check BIOS manually"
fi
echo "--- disks ---"
# Disk 0 must be the system disk. Disk 1 must still be NTFS and untouched.
SYS_SERIAL="$(lsblk -dno SERIAL "$(findmnt -no SOURCE / | sed -E 's/p?[0-9]+$//')" 2>/dev/null | tr -d ' ')"
if [ "$SYS_SERIAL" = "1808AE802176" ]; then
note "root disk serial" "1808AE802176 (correct)"
else
bad "root disk serial" "${SYS_SERIAL:-unknown} — expected 1808AE802176"
fi
DATA_DEV="$(lsblk -dno NAME,SERIAL | awk '$2=="WD-WCC7K3JAEDR3"{print $1}')"
if [ -n "$DATA_DEV" ]; then
FSTYPES="$(lsblk -no FSTYPE "/dev/$DATA_DEV" | tr -s '\n' ' ')"
note "data disk (D:)" "/dev/$DATA_DEV present, fstypes: ${FSTYPES:-none}"
if echo "$FSTYPES" | grep -q ntfs; then
note "data disk state" "still NTFS — correct, leave it until after soak"
else
warn "data disk state" "no NTFS found — has it already been converted?"
fi
if findmnt -rno TARGET -S "/dev/${DATA_DEV}1" >/dev/null 2>&1; then
warn "data disk mounted" "D: is mounted; it should not be during the soak"
fi
else
bad "data disk (D:)" "WD-WCC7K3JAEDR3 NOT FOUND"
fi
echo "--- gpu ---"
if lspci -nn | grep -qi 'VGA.*Intel'; then
note "iGPU (UHD 630)" "present"
else
bad "iGPU (UHD 630)" "NOT enumerated — set BIOS: Advanced > System Agent (SA) Configuration > Graphics Configuration > iGPU Multi-Monitor = Enabled"
fi
if lspci -nn | grep -qi 'NVIDIA'; then
note "GTX 1070" "$(lspci -nn | grep -i nvidia | head -1 | cut -c1-70)"
else
warn "GTX 1070" "not seen on PCI bus"
fi
if [ -e /dev/dri/renderD128 ]; then
note "/dev/dri/renderD128" "present"
note "render group gid" "$(stat -c '%g (%G)' /dev/dri/renderD128)"
else
warn "/dev/dri/renderD128" "missing — Quick Sync unavailable until iGPU is enabled in BIOS"
fi
echo "--- runtime ---"
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
note "docker" "$(docker --version | cut -d, -f1)"
else
bad "docker" "not running"
fi
note "media uid/gid" "$(id -u media 2>/dev/null || echo '?'):$(getent group media | cut -d: -f3 2>/dev/null || echo '?')"
note "swap" "$(free -h | awk '/Swap:/{print $2}')"
echo
[ $RC -eq 0 ] && echo " preflight clean" || echo " preflight found problems above"
# Preflight never blocks the rest of the bootstrap — it reports.
exit 0
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# =============================================================================
# 10-secrets — idempotent, ADD-ONLY seeder for /srv/secrets/stacks.env
#
# Mirrors the arrsstack convention: one master file, three tiers.
# [OPERATOR] secrets for operating the host; never emitted to a container
# [VALUES] flat, deduplicated KEY=value
# [MANIFEST] "#@stack <name> = VAR VAR" lines consumed by genenv.sh
#
# GUARANTEES:
# * An existing key is NEVER touched. Not its value, not its position,
# not its comment. If KEY= exists, this script skips it entirely.
# * A missing key is appended with either a generated value (for tokens we
# can safely generate) or the literal FILL_ME (for anything a human must
# supply). genenv.sh already fails closed on FILL_ME.
# * Values are never printed. Only key names and add/skip decisions.
# * Running this twice in a row produces zero changes the second time.
#
# This is a SEPARATE secrets file from arrsstack's. Per the project decision:
# no shared or mounted secrets between hosts. Canonical copy is Vaultwarden.
# =============================================================================
set -euo pipefail
MASTER="/srv/secrets/stacks.env"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
umask 077
install -d -m 0700 /srv/secrets
added=0; skipped=0
# --- create the skeleton only if the file does not exist at all -------------
if [ ! -f "$MASTER" ]; then
echo " creating $MASTER from template"
install -m 0600 "$REPO_DIR/secrets/stacks.env.example" "$MASTER"
fi
# Ensure the tier markers exist, so insertion has something to anchor to.
grep -q '^# ----- \[VALUES\]' "$MASTER" || printf '\n# ----- [VALUES] -----\n' >> "$MASTER"
grep -q '^# ----- \[MANIFEST\]' "$MASTER" || printf '\n# ----- [MANIFEST] -----\n' >> "$MASTER"
# ---------------------------------------------------------------------------
# add_value <KEY> <generator>
# generator: "hex32" -> openssl rand -hex 32
# "fill" -> literal FILL_ME (human must supply)
# anything else -> used as the literal default value
# Inserts immediately BEFORE the [MANIFEST] marker so it lands inside [VALUES].
# ---------------------------------------------------------------------------
add_value() {
local key="$1" gen="$2" val
if grep -qE "^${key}=" "$MASTER"; then
printf ' skip %-32s (already present)\n' "$key"
skipped=$((skipped+1))
return 0
fi
case "$gen" in
hex32) val="$(openssl rand -hex 32)" ;;
fill) val="FILL_ME" ;;
*) val="$gen" ;;
esac
# Insert before the [MANIFEST] header, preserving everything else byte-for-byte.
local tmp; tmp="$(mktemp)"
awk -v line="${key}=${val}" '
/^# ----- \[MANIFEST\]/ && !done { print line; print ""; done=1 }
{ print }
' "$MASTER" > "$tmp"
install -m 0600 "$tmp" "$MASTER"; rm -f "$tmp"
printf ' ADD %-32s (%s)\n' "$key" "$([ "$gen" = fill ] && echo 'needs a human' || echo generated)"
added=$((added+1))
}
# ---------------------------------------------------------------------------
# add_manifest <stack> <vars...>
# Appends a "#@stack" line only if one does not already exist for that stack.
# ---------------------------------------------------------------------------
add_manifest() {
local stack="$1"; shift
if grep -qE "^#@stack[[:space:]]+${stack}[[:space:]]*=" "$MASTER"; then
printf ' skip %-32s (manifest present)\n' "#@stack ${stack}"
skipped=$((skipped+1))
return 0
fi
printf '#@stack %s = %s\n' "$stack" "$*" >> "$MASTER"
printf ' ADD %-32s\n' "#@stack ${stack}"
added=$((added+1))
}
echo " seeding $MASTER (add-only)"
# --- [VALUES] ---------------------------------------------------------------
add_value TZ "America/New_York"
# Reuses the SAME token value that arrsstack's mediabox-mcp already serves, so
# the Claude connector entry does not change when NPM host 42 is repointed.
# Left as FILL_ME: copy it across by hand rather than minting a new one.
add_value MEDIABOX_MCP_BEARER_TOKEN fill
add_value PLEX_ADVERTISE_URL "http://10.0.1.20:32400"
# --- [MANIFEST] -------------------------------------------------------------
add_manifest shell-mcp MEDIABOX_MCP_BEARER_TOKEN TZ
add_manifest plex TZ PLEX_ADVERTISE_URL
chmod 600 "$MASTER"
chown root:root "$MASTER"
echo " result: ${added} added, ${skipped} left untouched"
# Report unfilled keys by NAME only — never values.
if grep -qE '^[A-Za-z_][A-Za-z0-9_]*=FILL_ME$' "$MASTER"; then
echo
echo " keys still needing a value (fill by hand, then re-run):"
grep -E '^[A-Za-z_][A-Za-z0-9_]*=FILL_ME$' "$MASTER" | cut -d= -f1 | sed 's/^/ - /'
fi
exit 0
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# =============================================================================
# 20-cifs — NAS mounts for the 8 shares Plex actually uses.
#
# Shares confirmed live from the Windows box 2026-07-27 (exact names/casing):
# Audio Books · Education Videos · Health · Home Movies · media
# Music Organized · Pictures · Radio Shows (+ "Share" — NOT mounted)
#
# "Share" is mounted on Windows today but is not a Plex library root, so it is
# deliberately left out. Every entry below maps to a real library section.
#
# 11 Plex library roots resolve onto these 8 mounts:
# Audio Books -> /mnt/nas/audiobooks
# Music Organized -> /mnt/nas/music-organized
# Radio Shows -> /mnt/nas/radio-shows
# Pictures -> /mnt/nas/pictures/Plex Pictures
# Health -> /mnt/nas/health
# Home Movies -> /mnt/nas/home-movies
# Education Videos -> /mnt/nas/education-videos
# media -> /mnt/nas/media/{movies,tvshows,music,audiobooks}
#
# WHY EACH OPTION IS THERE — none of these are decoration:
# nofail an unreachable NAS must NOT drop a headless
# box into an emergency shell. There is no
# keyboard attached to type the root password.
# _netdev tells systemd this needs the network up.
# x-systemd.automount mount on first access rather than at boot, so
# a slow NAS never stretches boot time.
# x-systemd.mount-timeout=30 bounded failure instead of an indefinite hang.
# x-systemd.idle-timeout=600 unmount when idle; keeps stale handles rare.
# vers=3.1.1 dialect confirmed from the Windows box.
# uid/gid=3000 MUST match Plex's PUID/PGID or every file is
# permission-denied.
# \040 fstab field separator is whitespace; share
# names with spaces MUST escape them.
# =============================================================================
set -uo pipefail
CRED="/etc/cifs/korval.cred"
NAS="10.0.1.254"
MEDIA_UID=3000
MEDIA_GID=3000
MARK_BEGIN="# >>> mediabox NAS mounts (managed by 20-cifs.sh) >>>"
MARK_END="# <<< mediabox NAS mounts <<<"
OPTS="credentials=${CRED},vers=3.1.1,uid=${MEDIA_UID},gid=${MEDIA_GID},file_mode=0664,dir_mode=0775,iocharset=utf8,nofail,_netdev,x-systemd.automount,x-systemd.mount-timeout=30,x-systemd.idle-timeout=600"
# share-name-on-nas | local mount point
SHARES=(
"Audio Books|/mnt/nas/audiobooks"
"Education Videos|/mnt/nas/education-videos"
"Health|/mnt/nas/health"
"Home Movies|/mnt/nas/home-movies"
"media|/mnt/nas/media"
"Music Organized|/mnt/nas/music-organized"
"Pictures|/mnt/nas/pictures"
"Radio Shows|/mnt/nas/radio-shows"
)
# --- --verify mode: check mounts, change nothing ----------------------------
if [ "${1:-}" = "--verify" ]; then
echo " verifying NAS mounts"
rc=0
if [ ! -s "$CRED" ]; then
echo " [FAIL] $CRED is empty — write the NAS credentials first"
exit 1
fi
for entry in "${SHARES[@]}"; do
mp="${entry#*|}"
if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then
printf ' [ok] %-28s %s\n' "$(basename "$mp")" "$(df -h --output=size "$mp" 2>/dev/null | tail -1 | tr -d ' ')"
else
printf ' [FAIL] %-28s not mounted\n' "$(basename "$mp")"
rc=1
fi
done
exit $rc
fi
echo " writing fstab entries for ${#SHARES[@]} shares"
install -d -m 0700 /etc/cifs
[ -f "$CRED" ] || install -m 0600 /dev/null "$CRED"
chmod 600 "$CRED"
# The credentials file is created EMPTY by autoinstall on purpose. The real
# password is written post-boot over the MCP so it never rides on the USB.
if [ ! -s "$CRED" ]; then
cat <<'CREDNOTE'
NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage.
The mounts will fail (harmlessly, thanks to nofail) until you write:
printf 'username=<user>\npassword=<pass>\ndomain=WORKGROUP\n' \
> /etc/cifs/korval.cred
chmod 600 /etc/cifs/korval.cred
No quotes. No spaces around '='. Trailing newline required.
CREDNOTE
fi
for entry in "${SHARES[@]}"; do
mp="${entry#*|}"
install -d -m 0755 "$mp"
chown ${MEDIA_UID}:${MEDIA_GID} "$mp"
done
# Rebuild only our managed block; never touch the rest of fstab.
tmp="$(mktemp)"
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
$0 == b { skip=1 }
!skip { print }
$0 == e { skip=0 }
' /etc/fstab > "$tmp"
{
printf '%s\n' "$MARK_BEGIN"
for entry in "${SHARES[@]}"; do
share="${entry%%|*}"
mp="${entry#*|}"
# escape spaces as \040 in BOTH fields (mount points here have none, but
# the escaping is applied uniformly so a future renamed mount is safe)
esc_share="${share// /\\040}"
esc_mp="${mp// /\\040}"
printf '//%s/%s %s cifs %s 0 0\n' "$NAS" "$esc_share" "$esc_mp" "$OPTS"
done
printf '%s\n' "$MARK_END"
} >> "$tmp"
# Sanity: never install an fstab that lost the root entry.
if ! awk '$2=="/" && $1 !~ /^#/' "$tmp" | grep -q .; then
echo " [FAIL] refusing to write fstab — root entry missing from generated file"
rm -f "$tmp"; exit 1
fi
cp -a /etc/fstab "/etc/fstab.bak.$(date +%Y%m%d%H%M%S)"
install -m 0644 "$tmp" /etc/fstab
rm -f "$tmp"
systemctl daemon-reload
echo " fstab updated (backup written alongside). Managed block:"
sed -n "/${MARK_BEGIN//\//\\/}/,/${MARK_END//\//\\/}/p" /etc/fstab | sed 's/^/ /'
if [ -s "$CRED" ]; then
echo " credentials present — attempting mounts"
for entry in "${SHARES[@]}"; do
mp="${entry#*|}"
if timeout 40 mount "$mp" 2>/dev/null || mountpoint -q "$mp"; then
printf ' [ok] %s\n' "$mp"
else
printf ' [warn] %s did not mount (check credentials / share name)\n' "$mp"
fi
done
else
echo " skipping mount attempts until credentials are written"
fi
exit 0
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# =============================================================================
# 30-nvidia — NVIDIA driver + container toolkit for the GTX 1070
#
# WHY THIS IS NOT IN late-commands:
# The driver is a DKMS module. It must build against the kernel that is
# actually running on the installed system, with that kernel's headers, on a
# real boot. Building it inside the installer environment produces a module
# for the installer's kernel, which is not the kernel that boots.
#
# DRIVER BRANCH — this is the part that will bite later:
# The GTX 1070 is Pascal. NVIDIA's 580 branch is the LAST branch that supports
# Maxwell, Pascal and Volta; it is now a frozen legacy branch receiving
# security fixes only. There will be no 590 for this card. Two consequences:
# 1. We install nvidia-driver-580 explicitly. Never `ubuntu-drivers autoinstall`,
# which will happily pick a newer branch that does not support the card.
# 2. We install the PROPRIETARY module, not `-open`. The open kernel modules
# require Turing or newer. On Pascal they will not load at all.
#
# Secure Boot must be OFF (verified 2026-07-27). With it on, the DKMS module is
# unsigned as far as the firmware is concerned and requires interactive MOK
# enrollment at a physical console — on a box with no keyboard.
#
# Plex is targeted at Quick Sync, not NVENC, so this stage failing does NOT
# block Plex. It fails soft.
# =============================================================================
set -uo pipefail
DRIVER_BRANCH=580
if ! lspci -nn | grep -qi nvidia; then
echo " no NVIDIA device on the PCI bus — skipping"
exit 0
fi
if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then
echo " driver already working:"
nvidia-smi --query-gpu=name,driver_version --format=csv,noheader | sed 's/^/ /'
else
echo " installing nvidia-driver-${DRIVER_BRANCH} (proprietary; Pascal cannot use -open)"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Explicit branch, explicit proprietary flavour. No ubuntu-drivers autoinstall.
if ! apt-get install -y \
"nvidia-driver-${DRIVER_BRANCH}" \
"nvidia-utils-${DRIVER_BRANCH}" \
"linux-headers-$(uname -r)" \
dkms; then
echo " [FAIL] driver install failed — Plex/Quick Sync is unaffected, fix later"
exit 1
fi
# Persistence mode avoids a multi-second GPU init on every container start.
systemctl enable --now nvidia-persistenced 2>/dev/null || true
echo " driver installed — a REBOOT is required before nvidia-smi will work"
fi
# --- NVIDIA Container Toolkit ------------------------------------------------
if [ -f /etc/apt/sources.list.d/nvidia-container-toolkit.list ] \
&& command -v nvidia-ctk >/dev/null 2>&1; then
echo " container toolkit already present"
else
echo " installing NVIDIA container toolkit"
install -m 0755 -d /usr/share/keyrings
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt-get update -qq
apt-get install -y nvidia-container-toolkit || {
echo " [FAIL] container toolkit install failed"; exit 1; }
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker
fi
# --- guard rail --------------------------------------------------------------
# If a newer driver branch is ever pulled in, it will silently drop this card.
cat > /etc/apt/preferences.d/nvidia-pascal.pref <<EOF
# The GTX 1070 is Pascal. Branch ${DRIVER_BRANCH} is the last one that supports it.
# Anything newer will install cleanly and then fail to drive the card.
Package: nvidia-driver-6* nvidia-driver-59*
Pin: release *
Pin-Priority: -1
EOF
echo " pinned against post-${DRIVER_BRANCH} branches (Pascal EOL guard)"
exit 0
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =============================================================================
# 40-shell-mcp — build and start the NATIVE shell-mcp on this host.
#
# This replaces the mediabox-mcp SSH proxy that currently runs on arrsstack.
# Once this is up and NPM proxy host 42 is repointed to 10.0.1.20:8103, the
# proxy container and its SSH key get deleted. The public URL, the LE cert and
# the Claude connector entry do not change.
#
# BUILDS LOCALLY. It does not pull an image. The arrsstack image is arm64 and
# would not run here; and there is no registry in this homelab to pull from.
# amd64 buildability was verified before this was written — the full Python
# dependency tree resolves to prebuilt manylinux x86_64 wheels, so no compiler
# is required and the build takes about a minute.
# =============================================================================
set -uo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC="$REPO_DIR/shell-mcp"
DEST="/srv/shell-mcp"
ENVFILE="/srv/secrets/stacks/shell-mcp.env"
MASTER="/srv/secrets/stacks.env"
command -v docker >/dev/null 2>&1 || { echo " [FAIL] docker not installed"; exit 1; }
# --- resolve the bearer token ------------------------------------------------
# Reuses the SAME token arrsstack's mediabox-mcp serves today, so the Claude
# connector entry survives the cutover untouched.
TOKEN="$(grep -E '^MEDIABOX_MCP_BEARER_TOKEN=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2-)"
if [ -z "${TOKEN:-}" ] || [ "$TOKEN" = "FILL_ME" ]; then
cat <<'MSG'
[SKIP] MEDIABOX_MCP_BEARER_TOKEN is not set in /srv/secrets/stacks.env.
Copy the existing value from arrsstack so the connector keeps working:
# on arrsstack
grep '^MEDIABOX_MCP_BEARER_TOKEN=' /srv/secrets/stacks.env
then put it in this host's /srv/secrets/stacks.env and re-run:
sudo /srv/mediabox-bootstrap/scripts/40-shell-mcp.sh
MSG
exit 0
fi
# --- stage source ------------------------------------------------------------
install -d -m 0755 "$DEST"
install -m 0644 "$SRC/server.py" "$DEST/server.py"
install -m 0644 "$SRC/requirements.txt" "$DEST/requirements.txt"
install -m 0644 "$SRC/Dockerfile" "$DEST/Dockerfile"
install -m 0644 "$SRC/docker-compose.yml" "$DEST/docker-compose.yml"
# --- per-stack env slice (same convention as arrsstack) ----------------------
install -d -m 0700 /srv/secrets/stacks
umask 077
{
printf 'MEDIABOX_MCP_BEARER_TOKEN=%s\n' "$TOKEN"
printf 'TZ=%s\n' "$(grep -E '^TZ=' "$MASTER" | head -1 | cut -d= -f2- || echo America/New_York)"
} > "$ENVFILE"
chmod 600 "$ENVFILE"
# --- build & start -----------------------------------------------------------
echo " building shell-mcp for amd64 (local build, no pull)"
cd "$DEST" || exit 1
set -a
# shellcheck disable=SC1090
. "$ENVFILE"
set +a
if ! docker compose up -d --build; then
echo " [FAIL] build/start failed"
exit 1
fi
# --- verify ------------------------------------------------------------------
echo " waiting for health endpoint"
for i in $(seq 1 30); do
if curl -fsS -m 3 "http://127.0.0.1:8103/health" >/dev/null 2>&1; then
echo " [ok] /health responding: $(curl -fsS -m 3 http://127.0.0.1:8103/health)"
# An unauthenticated /sse MUST be rejected. If this ever returns 200 the
# box is publicly shell-able through NPM.
code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://127.0.0.1:8103/sse" || true)"
if [ "$code" = "401" ]; then
echo " [ok] /sse rejects unauthenticated requests (401)"
else
echo " [FAIL] /sse returned $code without a token — DO NOT repoint NPM until fixed"
exit 1
fi
exit 0
fi
sleep 2
done
echo " [FAIL] health endpoint never came up"
docker compose logs --tail 40 shell-mcp 2>&1 | sed 's/^/ /'
exit 1
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# =============================================================================
# 50-plex — deploy Plex. HUMAN-IN-THE-LOOP, run by hand, not from bootstrap.
#
# Usage:
# # 1. open https://plex.tv/claim and copy the token
# # 2. within four minutes:
# sudo PLEX_CLAIM=claim-xxxxxxxxxxxx /srv/mediabox-bootstrap/scripts/50-plex.sh
#
# A claim token expires four minutes after it is issued. That is the entire
# reason this stage is not automated: there is no way to bake one into a USB,
# a repo, or a first-boot script and have it still be valid.
#
# Claiming is only required on the FIRST start. Re-running later without
# PLEX_CLAIM is fine and will not re-claim.
# =============================================================================
set -uo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEST="/srv/plex"
MASTER="/srv/secrets/stacks.env"
# --- gate 1: mounts must be live BEFORE Plex ever scans ----------------------
# If Plex scans a library whose mount is missing, it sees zero files. With a
# read-write NAS account, "empty trash after scan" would then delete the
# library's records — and Plex has the permission to act on that. Never let
# Plex start against absent mounts.
echo " checking NAS mounts before starting Plex"
missing=0
for mp in /mnt/nas/audiobooks /mnt/nas/education-videos /mnt/nas/health \
/mnt/nas/home-movies /mnt/nas/media /mnt/nas/music-organized \
/mnt/nas/pictures /mnt/nas/radio-shows; do
if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then
printf ' [ok] %s\n' "$mp"
else
printf ' [FAIL] %s not mounted\n' "$mp"
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
echo
echo " REFUSING to start Plex with missing mounts."
echo " Fix with: sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify"
exit 1
fi
# --- gate 2: Quick Sync -----------------------------------------------------
if [ ! -e /dev/dri/renderD128 ]; then
echo " [WARN] /dev/dri/renderD128 missing — iGPU not enabled in BIOS."
echo " Plex will still run, but every transcode will be software."
RENDER_GID=993
else
RENDER_GID="$(stat -c '%g' /dev/dri/renderD128)"
echo " render node gid: $RENDER_GID"
fi
export RENDER_GID
install -d -m 0755 "$DEST"
install -d -m 0755 "$DEST/config"
chown -R 3000:3000 "$DEST"
install -m 0644 "$REPO_DIR/plex/docker-compose.yml" "$DEST/docker-compose.yml"
# --- env --------------------------------------------------------------------
set -a
TZ="$(grep -E '^TZ=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2- || echo America/New_York)"
PLEX_ADVERTISE_URL="$(grep -E '^PLEX_ADVERTISE_URL=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2- || echo http://10.0.1.20:32400)"
PLEX_CLAIM="${PLEX_CLAIM:-}"
set +a
if [ -n "$PLEX_CLAIM" ]; then
echo " claim token supplied (expires 4 min from issue — moving now)"
else
echo " no PLEX_CLAIM given; assuming this server is already claimed"
fi
cd "$DEST" || exit 1
docker compose up -d || { echo " [FAIL] compose up failed"; exit 1; }
# The claim token must not linger anywhere on disk.
unset PLEX_CLAIM
echo " waiting for Plex to answer on 32400"
for i in $(seq 1 60); do
if curl -fsS -m 3 "http://127.0.0.1:32400/identity" >/dev/null 2>&1; then
echo " [ok] Plex is up"
break
fi
sleep 3
done
# --- gate 3: disable auto-empty-trash, permanently ---------------------------
# This is the single most dangerous default on this box. If a CIFS mount is
# missing at scan time Plex sees an empty library; with autoEmptyTrash on it
# begins trimming, and the NAS account is read-write. Turn it off in the
# config rather than trusting a UI checkbox to stay ticked.
PREFS="$DEST/config/Library/Application Support/Plex Media Server/Preferences.xml"
if [ -f "$PREFS" ]; then
if grep -q 'autoEmptyTrash="0"' "$PREFS"; then
echo " [ok] autoEmptyTrash already disabled"
else
echo " disabling autoEmptyTrash (requires a Plex restart)"
docker compose stop plex >/dev/null 2>&1
cp -a "$PREFS" "${PREFS}.bak.$(date +%Y%m%d%H%M%S)"
if grep -q 'autoEmptyTrash=' "$PREFS"; then
sed -i 's/autoEmptyTrash="[^"]*"/autoEmptyTrash="0"/' "$PREFS"
else
sed -i 's/<Preferences /<Preferences autoEmptyTrash="0" /' "$PREFS"
fi
chown 3000:3000 "$PREFS"
docker compose start plex >/dev/null 2>&1
echo " [ok] autoEmptyTrash=0 written"
fi
else
cat <<'MSG'
[WARN] Preferences.xml not written yet (first start is still initialising).
Re-run this script once Plex has fully started to disable
autoEmptyTrash, OR untick it by hand:
Settings > Library > "Empty trash automatically after every scan"
Do this BEFORE adding any library. With read-write NAS credentials a
missing mount plus this setting deletes media records.
MSG
fi
cat <<'NEXT'
Next:
* Open http://10.0.1.20:32400/web and confirm the server is claimed.
* Add libraries pointing at /media/... (the container paths), not /mnt/nas.
* Verify a transcode is using Quick Sync:
docker exec plex ls -l /dev/dri
# start a transcode, then:
intel_gpu_top # Video/VideoEnhance rows should be busy
NEXT
exit 0
+43
View File
@@ -0,0 +1,43 @@
# =============================================================================
# stacks.env — mediabox master operator env
# host path: /srv/secrets/stacks.env (root:root, 0600)
#
# THIS IS THE MEDIA BOX'S OWN FILE. It is deliberately NOT shared with, or
# mounted from, arrsstack. One host, one secrets file. The canonical copy of
# every value lives in Vaultwarden; this is the runtime copy.
#
# Three tiers, same convention as arrsstack:
# [OPERATOR] secrets Claude loads to operate the host; never emitted into
# a container env slice
# [VALUES] flat, DEDUPED KEY=value — define each variable exactly once
# [MANIFEST] #@stack lines mapping a stack -> the vars it needs
#
# Load with: set -a; . /srv/secrets/stacks.env; set +a
#
# NOTE: NAS credentials are NOT in this file. fstab needs them at mount time,
# which happens long before Docker exists, so they live in /etc/cifs/*.cred
# (0600) instead. That file is created EMPTY by the autoinstall and filled in
# post-boot over the MCP, so the password never touches removable media.
#
# This example file carries placeholders only. 10-secrets.sh seeds the real
# file from it and thereafter only ever ADDS missing keys.
# =============================================================================
# ----- [OPERATOR] — loaded for Claude's use; NEVER written into a stack slice -----
# (none yet on this host)
# ----- [VALUES] — flat, deduplicated. Define each variable exactly once. -----
TZ=America/New_York
# shell-mcp (native, replaces the mediabox-mcp SSH proxy on arrsstack).
# Use the SAME value that arrsstack's stacks.env already holds — the Claude
# connector entry and the NPM cert do not change during the cutover, so the
# token should not either. Copy it across; do not mint a new one.
MEDIABOX_MCP_BEARER_TOKEN=FILL_ME
# plex
PLEX_ADVERTISE_URL=http://10.0.1.20:32400
# ----- [MANIFEST] — stack -> vars it needs (comments; genenv.sh parses these) -----
#@stack shell-mcp = MEDIABOX_MCP_BEARER_TOKEN TZ
#@stack plex = TZ PLEX_ADVERTISE_URL
+26
View File
@@ -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"]
+22
View File
@@ -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
+14
View File
@@ -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
+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")