Pete

The Stack

All posts / Homelab (11)

Ithaca, NY

Set a friend up with his own server on my homelab tonight. Full root, do whatever he wants, zero risk to anything of mine.

Went with an Incus system container instead of Docker or a full VM. It behaves like a real machine (its own systemd, sudo, persistent disk) but rides the host kernel, so the overhead is basically nothing. Capped it at 6GB RAM, 3 cores, 40GB disk on a size-limited btrfs pool so it can never starve or fill the box that runs my other 43 containers.

Inside: a full LAMP stack, SSH, and Claude Code. He brings his own model keys, so none of my gateways are in the blast radius. Remote access is Tailscale SSH, identity-based, no keys to mail around.

The real work was the isolation. He is root in his box, but the box is walled off two ways. A host firewall that drops everything from his bridge toward my LAN, my host, every other container, and the gateway, while still allowing the open internet. And a tailnet ACL so even over Tailscale he can reach his own node and nothing else of mine. Tested every direction, in and out. The firewall missed one path on the first pass: traffic to the host's own IP rides the INPUT chain, not FORWARD. Caught it, closed it, re-verified.

Then I made it boring. Automatic security updates inside the box, a five-minute timer that re-applies the firewall in case Docker ever rewrites its chains, and a weekly export to the NAS. The whole build is one folder of scripts in the repo, so I can rebuild it in minutes.

Best kind of favor: hand someone real keys to a real machine and still sleep fine.

Turned on IPv6 at home. Empire delegates a /56, which is plenty for a slice per VLAN. Easy change on paper.

In practice it surfaced three separate bugs in my UniFi control tooling, each one where the dry_run preview came back green and the live gateway rejected the actual write. Wrong delegation enum value, then a missing WAN binding, then a fresh network that needed the full prefix scaffold the old one only had by accident. Caught all three without ever leaving the firewall half-applied, fixed them properly, shipped three patches that auto-deployed to nix1.

Held the IoT and guest networks on IPv4 on purpose. Their isolation rules only exist for v4, and a camera with a public address and no v6 firewall turns a segmented VLAN into an open one. Dual-stack where it matters, NAT where it belongs.

The reminder I keep relearning: a preview that doesn't model the server's validation is a preview that lies.

github.com/pete-builds/mcp-unifi ↗

Shipped mcp-unifi v0.13.0 and pointed it straight at a real annoyance: a phone in the bedroom clinging to a far access point instead of roaming to the near one.

Five new tools for tuning access point radios: read the radio table, set transmit power, set minimum RSSI, set channel and width, rename the device. All strict read-modify-write against the live radio table, every response shows before and after, and dry_run previews the change before it commits. I started to build a band-steering tool too, then probed the live gateway and found there's no API surface for it on this firmware. Dropped it rather than ship a tool that silently does nothing.

Then I wired a watcher on nix1 that checks every 20 minutes for a client parked on that access point below -75 dBm and pings me on Discord only when it actually happens. The tuning is alert-driven now instead of me guessing. First fix was easy: renamed the bedroom unit, dropped its 5GHz transmit power from auto to medium, verified live.

657 tests, 90% coverage. The auto-deploy pipeline picked it up and rolled it onto nix1 the next morning while I was asleep. That's the whole point of building the pipeline first.

github.com/pete-builds/mcp-unifi ↗

Six of my self-hosted services now auto-deploy with a tokenless provenance gate standing in front of them.

Watchtower handles same-tag digest updates fine, but it can't bump a pinned semver tag. That's how mcp-unifi quietly drifted two minor versions behind without me noticing. So I built a three-stage pipeline: a GitHub release bumps the public compose example, a gitignored override on the host pins the deployed tag, and a cron job on nix1 pulls, health-checks, and rolls back on failure with a Discord ping.

The part I'm happiest with is the gate. Before any new image deploys, the host runs cosign keyless verification against the SLSA build attestation, checking the certificate identity matches the exact release workflow in the exact repo it claims to come from. No GitHub token sits on the box at all. I proved it fails closed four ways: good digest passes, fake digest fails, an unsigned image fails, and a real attestation signed by the wrong repo fails on the certificate-identity check.

The deployed override files are now symlinks into a private GitOps repo, so every successful bump commits and pushes its own state. The history of what's running writes itself.

Silent on no-op, pings on events. The daily 'everything is fine' runs make zero noise. I only hear from it when something actually deploys or breaks.

The 2 gig symmetric fiber is live. Empire Access into a UniFi Cloud Gateway Fiber, and the speed tests are landing around 2.1 down and 1.95 up with 10 to 15ms latency and 100% WAN uptime over the last day. Wired the whole house onto it.

Then segmented the network properly. Four VLAN tiers: management, trusted, IoT, and guest, with a 9-rule LAN_IN matrix. The IoT junk can talk out but can't touch any homelab admin surface. Guests are walled off from everything. No more flat network.

And mcp-unifi has been driving all of it against real hardware, out of stub mode for good. Went v0.5.1 to v0.10.1 since the last post. New tool surface for network segmentation, threat management, honeypots, and Teleport. Added an 18-tool read-only UniFi Access module for door and reader state. Bearer-token auth is now on by default, and destructive deletes preview the exact change before they run.

Built the controller against a mock for weeks, then watched it run the real gateway the day the fiber landed. That's the payoff.

github.com/pete-builds/mcp-unifi ↗

Three days deep on mcp-unifi. Started Wednesday with the new UCG-Fiber going live and the server flipping out of stub mode against real hardware for the first time. Shipped two release candidates, then v0.5.0, then v0.5.1. Network module split into 10 files, Protect module added (12 tools), audit log plus replay CLI, composite rollback on partial failure, Helm chart, .dxt one-click for Claude Desktop, cosign-signed images with SBOM and build provenance.

Spent today fixing the docs site, which had been silently producing one HTML page instead of nineteen since Astro 5. Missing content collection config, plus a Starlight bug where the draft filter dropped every entry because the schema default wasn't being applied. Found it by writing a debug page and printing what getCollection returned. Guides and reference now live at pete-builds.github.io/mcp-unifi.

Then the honest moment. Compared against the dominant UniFi MCP server out there. 343 stars, 19 contributors, four times the tool count, dedicated domain, plugin marketplace install. Not going to out-feature that in six weeks. So I leaned in on what's actually different: dry-run plus audit log plus composite rollback plus supply-chain hardening plus single-container with Helm plus API-key-only auth. Depth, not breadth.

This was always a portfolio piece more than a product. The point isn't users. It's proving I can architect a safety substrate for LLM-driven infra ops and ship it end-to-end with provenance.

pete-builds.github.io/mcp-unifi/ ↗

Shipped mcp-unifi v0.3.0 today. Forty-one tools for managing self-hosted UniFi gateways from any MCP client. Adds 26 new tools across four tiers: CRUD gaps (firewall update, port profile create/update/delete, port forward CRUD), high-frequency client and port ops (block client, set port state, restart and locate device, static DHCP leases), observability (site health, WAN status, events, alarms, speed tests, top talkers), and four composite tools that collapse multi-step UI workflows into single calls with rollback on partial failure: create_iot_network, create_guest_network, provision_homelab_service, audit_open_ports.

Hardened container: UID 1000, no shell, read-only rootfs, digest-pinned base, hash-pinned wheels. Multi-arch with build provenance and SBOM pushed to GHCR. CI gates on Trivy, ruff, mypy strict, and 224 tests at 90% coverage.

Published to the official MCP Registry as io.github.pete-builds/unifi. Auto-publish workflow wired so future tags self-publish. Also pitched to the new curated GitHub MCP Registry at github.com/mcp via the partnership process. That one reviews manually and runs on a longer cadence.

The other UniFi MCP servers in the wild use older auth flows, no tests, deprecated transport. This is the only one with a hardened container and a registry listing.

Stub mode by default until UCG-Fiber arrives. Same surface, mock data. Build the controller before the hardware shows up.

github.com/pete-builds/mcp-unifi ↗

Built mcp-phish today: a FastMCP server wrapping both the Phish.net v5 API and Phish.in v2 into a clean typed tool surface. Twelve tools across three domains: shows and setlists (get_show, search_shows, recent_shows), songs (get_song, search_songs, song_history, jam_chart, get_reviews), and audio (get_audio, get_track, search_audio_tracks). Running on nix1:3705, Tailscale/LAN only.

The interesting bit was the cache layer. There's an aiosqlite SQLite database sitting between the tools and the upstream APIs, but it's intentionally minimal: endpoint + params_hash pointing to a raw JSON blob with a 24h TTL. Not a normalized store. Not the beginnings of a real database. The whole job is rate-limit safety so a burst of questions from a Claude session doesn't hammer phish.net's API. The Phase 2 Postgres vault is a completely separate project with its own schema that we'll build after the MCP is verified.

The cache key piece took some thought. Each tool call hashes its parameter dict with SHA-256 after JSON-canonicalizing it first: sort_keys=True, consistent separators. So get_song(slug='fluffhead') and get_song(slug='fluffhead') always resolve to the same row regardless of how the dict was constructed at call time. Kills a whole class of cache miss bugs before they happen.

First real-world smoke: pulled the Sphere setlist while tonight's show was still running. Frankenstein dropped at the top of Set 2. Saw it in the MCP client about a minute after the band played it.

github.com/pete-builds/mcp-phish ↗

Built a dashboard to track Anthropic's open job listings. It pulls from the Greenhouse API on a schedule, stores daily snapshots in SQLite, and diffs each run against the previous day to surface new roles, closed ones, and anything that shifted. Two surfaces: a Rich terminal dashboard for quick CLI checks and a FastAPI web view when I want to see trends over time.

The motivation was practical. Applied to six Anthropic roles in March and wanted a clean way to watch the board without refreshing the careers page every morning. The delta detection ended up being the useful part. Not just 'are there new jobs' but which departments are expanding, which roles stay open for months, and what the hiring pace looks like across research vs. engineering vs. operations.

Running in Docker on nix1. Open-sourced at the link.

github.com/pete-builds/anthropic-tracker ↗

The 53 Report is live. Full tech stack: SQLite, MCP server, Claude Code agentic workflow for the editorial pipeline, Astro 5, Docker on a Hetzner VPS. Here is how it all connects.

The data layer is the SQLite database from post 045. Every draft pick since 1980, weekly rosters since 2002, per-game snap counts since 2012. About 1.3 million rows. A pick counts as a hit if the player produced 500 or more snaps in any single regular season, the line where they spent at least one year as a real rotational contributor (we started at 100 snaps and tightened the bar after publishing the first three articles).

On top of that sits an MCP server running in Docker on nix1 over Tailscale. Eight tools: team draft hit rate, round hit rate, round trends heatmap, roster composition, pick outcome for a single selection, player career arc, player search, and a database health check. The server runs SSE at port 3711 and gets queried by Claude Code during every editorial run.

The editorial pipeline is where it gets interesting. Four stages: Scout, Beat, Editor, Coach. All running inside Claude Code as custom skill agents.

Scout is read-only. It hits the MCP and returns a structured evidence pack with three to five ranked angles. No prose, no opinions, just numbers and angle proposals, ranked by anomaly vs. league, anomaly within team, regime shift signals, single-pick stories, and counter-narratives.

Beat takes the evidence pack plus the approved angle and writes the article. Every number has to be traceable to Scout's pack or a clean derivation from it. No new numbers, no player names Scout didn't surface. Targets 1,800-2,600 words depending on shape, with narrative and data woven together in every section.

Editor is the stat-fidelity gate. It reads Beat's draft against Scout's pack and returns PASS, REVISE, or BLOCK. A hallucinated stat is an automatic BLOCK. No league rank gets through without the raw value, population denominator, and era window in the same sentence.

Coach orchestrates the whole run. It reads the publishing calendar, picks the next queued team, spawns Scout, presents angles, hands the approved one to Beat, runs Editor, and calls the deploy script only after explicit approval. Never ships without that sign-off.

The product is GM Performance Grading: how well NFL general managers draft and retain talent. Three article shapes: scorecard (tenured GM, four graded columns, final letter grade), narrative (paradox or anomaly, no grade), and methodology (league-wide framing, no team focus). Three published pieces so far, twenty-nine teams queued.

The site is Astro 5, static build, deployed via rsync to Zion (Hetzner VPS, Plesk-managed). DNS through Cloudflare, proxied, Full Strict SSL. Build is clean in under ten seconds.

Long-term target is a paper for SSAC 2027 (abstract due around October 2026) and a staff or contributor role at an NFL team analytics group or a shop like SumerSports or The 33rd Team. The dataset edge is the window: Dubow's AP piece used a 2021-2024 window with binary roster data. SIS used first-round picks only with a four-year endpoint. This stack goes multi-year, snap-weighted, and position-weighted across every round.

Next up: interactive analysis with logins and custom date range filters. After that, a longer story-driven piece on the BNM blog, less technical, more about how this came together.

the53report.com ↗

Built a Spotify MCP server this weekend. Most of the ones out there run as a local subprocess on your laptop. This one runs once in a Docker container and any MCP client on the LAN or Tailscale hits it over SSE. Nine tools, one OAuth, no per-machine setup.

github.com/pete-builds/spotify-mcp-sse ↗