A self-hostable prediction game that reads any band's setlist data over MCP.
All you need is Docker with Compose v2. No accounts, no API keys, no .env file.
git clone https://github.com/pete-builds/open-setlist-stash.git
cd open-setlist-stash
docker compose -f docker-compose.demo.yml up -d --build --waitThe command returns once the game is ready (under a minute on a fast connection; the first run builds the image). Then open http://localhost:8706 and:
- Type any handle and press Start playing. That is the whole sign-in: an anonymous handle kept in a cookie.
- Press Make your picks. The next show is a few days away, so picks are open.
- Type part of a song title (try
comet), pick up to five songs, tap one as your encore call, and submit. - Open Leaderboard, Shows and Stats: four demo players already have two past shows scored.
Port 8706 taken? Prefix the command with DEMO_PORT=9000. To stop the demo and delete its
database:
docker compose -f docker-compose.demo.yml down -vWhat the demo is not. It is not a production configuration and not a template for one. The
band ("The Placeholder Comets") is made up, and so is every song, venue and player; they come from
a small bundled data server (demo/data_server.py) whose show dates are
laid out around the day you start it. The database password is a throwaway value, and the
session key is the app's shipped development default, which is public; the app refuses to boot
with that key on an https BASE_URL or with COOKIE_SECURE=true. Nothing is reachable from
outside your machine, and there is no resolver running, so your own picks are never scored. To
run a real game, use Run it below with your own .env and your own MCP data server.
On an SELinux host (Fedora, RHEL), the two read-only bind mounts in the demo file may need :z
added (./demo:/demo:ro,z) before the containers can read them.
The game engine holds no setlist data of its own. Every read goes out over the Model Context Protocol to a server you point it at, so the same container runs for Phish, Umphrey's McGee, or any act you can stand up a data source for. Bring an MCP server that satisfies the setlist contract and you have a working game.
A resolved show. The setlist and the standings are rendered from the same scoring tick, so they can never disagree on screen.
| Deployment | Band | Data source | Differences from stock |
|---|---|---|---|
| tweezerpicks.com | Phish | mcp-phish | env + mounted CSS |
| wappypicks.com | Umphrey's McGee | mcp-umphreys | env + mounted CSS |
Both run the same published image. They differ only by environment config, branding, and which MCP server they read. There is no per-band fork and no band-specific code path.
The game never touches a setlist database directly. It speaks JSON-RPC over Streamable HTTP to
the MCP server named by MCP_PHISH_URL (the env name is historical, point it anywhere). The
async wrapper in src/setlist_stash/mcp_client.py calls the
tools that server exposes:
| Tool | Used for |
|---|---|
get_show(date) |
the live and resolved setlist, the scoring input |
recent_shows() |
picking the next show to open predictions on |
search_songs(q) |
the pre-lock song picker (returns slug + title + show gap only) |
validate_song_slugs([...]) |
server-side gate on every submitted pick |
get_song(slug) |
display titles and gap detail |
stats_overview() |
the optional /stats page |
To support a new band, stand up an MCP server that satisfies that contract and change one env var. Reference implementations: mcp-phish (phish.net / phish.in) and mcp-umphreys (All Things Umphreys, backed by umphreys-vault).
A deployment can also re-expose its upstream MCP publicly at /mcp (set MCP_UPSTREAM_URL),
rate limited per IP, so fans can wire the band's setlist data into their own MCP client. The
/connect page renders the copy/paste setup for it.
Pick up to 5 songs for an upcoming show (at least one required). Each song that gets played is worth 2 points. You also make one encore call: tap one of your picks as your encore guess. If it lands in the encore you get +5; if it plays elsewhere it still earns its 2.
Predictions lock at a configurable showtime. The game then scores live during the show, re-reading the partial setlist and rebuilding the leaderboard on a short interval, so scores climb in real time as songs get played. Leaderboards run per-league and global.
The song picker shows each song's show gap (how many shows since it was last played) so you do not burn a pick on something played last night. It is a fair human contest: any optional smart-pick assist is disabled during the prediction window.
Scoring a live setlist is the interesting part. A setlist typed in during the show grows set by
set with the encore entered last, so scoring the first non-empty read would freeze everyone's
encore pick against the end of Set 1. The resolver only finalizes a show once an encore is
detected and the track count has held steady for a configurable quiet window, with a time
backstop. See completeness.py.
cp .env.example .env
# edit .env: set PG_PASSWORD, SESSION_SECRET, and MCP_PHISH_URL (your MCP data server)
docker compose up -d
curl http://localhost:3706/healthzOr pull the published image instead of building:
docker pull ghcr.io/pete-builds/open-setlist-stash:latestSESSION_SECRET is not optional on a production deployment. The app refuses to boot if
COOKIE_SECURE=true or BASE_URL is https while the session secret is still the shipped
development default, so nobody accidentally signs cookies with a publicly known key.
All branding is deployment-specific (config plus mounted assets), so the public repo carries no operator-specific identity.
- Name: every page title and the brand wordmark read from
SITE_NAME. Emoji work (e.g.SITE_NAME="🎸 Wappy Picks 🤘"). - Footer credit: set
FOOTER_CREDITandFOOTER_CREDIT_URLto add an attribution line (defaults empty, so a self-host shows none). - Theme: the platform ships a clean default (
static/style.css). Layer your own CSS on top via either:- Bundle it (good for forks): drop a file in
src/setlist_stash/static/themes/your-theme.css, setTHEME_FILE=themes/your-theme.css, rebuild. - Keep it private (good if the branding is yours): mount the CSS at runtime via
docker-compose.override.yml(seedocker-compose.override.yml.example). The CSS lives outside the repo on the host; compose merges the override onup.
- Bundle it (good for forks): drop a file in
- Email signup is gated on
EMAIL_PROVIDER: with itdisabled(the default), the magic-link UI is hidden and players join with an anonymous handle plus cookie. Set a real provider to enable email magic-link auth. - Google sign-in (SSO) is gated on
GOOGLE_CLIENT_ID+GOOGLE_CLIENT_SECRET: with both blank (the default) no Google buttons render and the/auth/google/*routes redirect home. Provision a Google OAuth "Web application" client, set the two env vars, pointBASE_URLat your public origin (the redirect URI is{BASE_URL}/auth/google/callback), and setCOOKIE_SECURE=trueon HTTPS. Signing in with Google while already holding a handle cookie links Google to that same account, so existing players keep their handle, picks, and games. See.env.examplefor the full setup notes. - Show-day pick reminders are gated on
REMINDER_ENABLED(default off) and require a workingEMAIL_PROVIDER. When on, thesetlist-stash-remindersservice wakes atREMINDER_HOUR_LOCAL:REMINDER_MINUTE_LOCALinDISPLAY_TZ(default 09:00 Eastern) and, only on days a show is scheduled, mails players who have a verified email, have not unsubscribed, and have not submitted picks yet. One message per player per show, enforced by a unique row inemail_sendsso a container restart cannot double-send. Every message carries a signed unsubscribe link plus RFC 8058List-Unsubscribe/List-Unsubscribe-Postheaders, and players can also toggle reminders from/account. Nothing is sent withinREMINDER_MIN_LEAD_MINUTESof the pick cutoff. Unsubscribing never affects sign-in mail. - Private leagues are gated on
ENABLE_GAMESand per-show comment threads onENABLE_COMMENTS. Both default on; setting either false hides the UI and 404s the routes without deleting a table.
Drop markdown files (optional title/date/summary frontmatter) into the directory named by
BLOG_DIR (default content/blog, typically a mounted volume so posts stay deployment-specific).
Posts render at /blog and /blog/{slug}; the nav "Blog" link only appears when at least one
post is present.
- Python 3.13, FastAPI, Jinja2, HTMX (server-rendered, no build step, no SPA)
- PostgreSQL for game state only. Setlist data is never stored, it is read over MCP.
- Docker multi-stage build, non-root, hash-pinned lockfiles
- CI: ruff, mypy
--strict, pytest against a real Postgres, Trivy scan of the built image - Deployable LAN-only, over Tailscale, or publicly (e.g. behind a Cloudflare Tunnel)
Security posture is documented in SECURITY.md. Design notes for the original build are in docs/PHASE-4-PLAN.md.
MIT, see LICENSE.
This project consumes setlist data through MCP servers. The Phish deployment uses data from phish.net and phish.in via mcp-phish; the Umphrey's deployment uses All Things Umphreys via mcp-umphreys. Not affiliated with those data sources or with the artists.

