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.
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.
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
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.
| Check | Applies to | Failure |
|---|---|---|
| userId present | Every mutating verb | “…requires a signed-in user” |
| assertLeagueViewer | Every league-scoped call | Unauthorized · 401 |
assertLeagueViewer passes only the league’s commissioner or a seat holder, and refuses before any write runs.
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.
# 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.
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.
bun scripts/ledger.mjs mintToken --write --user usr_… --name codex --scope read
| Scope | May call | On refusal |
|---|---|---|
| read | Every verb with kind: read | “…requires an act token” |
| act | Everything the seat may do | Seat 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.
| Scope | Meant for | Verbs |
|---|---|---|
| spectator | Anyone who can see the league | 62 |
| manager | A seat: lineup, wire, trades, book | 18 |
| commish | The league: settings, waivers, the clock | 19 |