open-leagues

Agents & MCP

One URL for any MCP client. The public box needs no token for reads; a league box takes a bearer token or a proxied user id.

Connect a client

The public box at leagues.waits.dev runs in substrate mode: every read verb that needs no person answers with no account and no token, rate-limited per IP. Your league box is the full product: mint a token at /account or from a shell, or let your edge pass a user id. stdio is for a commissioner who exposes no endpoint.

leagues.waits.dev · no account, no token · public read verbs
claude mcp add --transport http open-leagues https://leagues.waits.dev/api/mcp
codex  mcp add open-leagues --url https://leagues.waits.dev/api/mcp

# receipts, week boards, the season ledger, lines, cohorts, grading, staking
# rate-limited per IP · nothing here needs a person
/api/mcp rejects cookie sessions in every mode. Shell mint: bun scripts/ledger.mjs mintToken --write --user …. stdio needs a Postgres DATABASE_URL plus OPENLEAGUES_USER and exits without both.

A real session

Captured preseason 2026 against the live host, unedited. The first two calls are tools Codex assumed existed; it then introspected the catalog and answered from the league.

codex exec · preseason 2026
codex · open-leagues
› get my league context — team name, record, this week's opponent
mcp: open-leagues/getDesk failedmcp: open-leagues/getSchedule failedmcp: open-leagues/listMyLeagues completedmcp: open-leagues/getAgentContext completedmcp: open-leagues/getLeagueBundle completedmcp: open-leagues/getMatchups completedmcp: open-leagues/getSchedule completed
hands — 0-0-0 — vs Butterbean

Every mcp: value came back over the wire, none from model memory.

What gates a call

Two checks, and neither reads the catalog’s scope column. That column says which seat a verb is meant for; it is not an authorization tier.

CheckApplies toFailure
userId presentEvery mutating verb“…requires a signed-in user”
assertLeagueViewerEvery league-scoped callUnauthorized · 401

assertLeagueViewer passes only the league’s commissioner or a seat holder, and refuses before any write runs.

Every read of an lg_ league (browser, CLI, or agent) goes through the same seat check. Raw Sleeper ids are the exception: that data is already public on Sleeper.

Bring your own identity

Those checks are league rules. How a caller proved identity is yours to decide; the engine takes a user id.

OPENLEAGUES_MCP_AUTH=token · this box issues the credential
# nothing to set; token is the default
# on your league box: mint from /account, or headless:
bun scripts/ledger.mjs mintToken --write --user usr_…

# the client sends it
Authorization: Bearer ol_…

In proxy mode nothing else is trusted: no bearer, no cookie, no user id in tool arguments. Your edge authenticates (SSO, mTLS, a gateway) and passes the id.

Set OPENLEAGUES_MCP_PROXY_SECRET so only your proxy can set the header. Unset, the box logs a warning and trusts the header anyway.

Every mode fails closed. Unset means token; an unrecognised mode refuses to serve. stdio has no HTTP identity; OPENLEAGUES_USER is its trust boundary.

Token scope: read or act

Every token is minted read or act. A read token calls read verbs only; an act token does what its seat can do. This is the one scope the engine enforces.

mint a read-only token from a shell
bun scripts/ledger.mjs mintToken --write --user usr_… --name codex --scope read
ScopeMay callOn refusal
readEvery verb with kind: read“…requires an act token”
actEverything the seat may doSeat rules, as usual

Every write by an act token is logged as an agent_action event with the token’s name, so a receipt can show “codex started X over Y at 11:52am” next to the human’s line. In proxy mode x-openleagues-scope: read narrows a caller.

Reading the scope column

The scope column says who a verb is for. getAgentContext filters the tool list it hands an agent by the caller’s standing.

ScopeMeant forVerbs
spectatorAnyone who can see the league62
managerA seat: lineup, wire, trades, book18
commishThe league: settings, waivers, the clock19