The JSON API the app itself runs on, plus a hosted MCP server exposing the same analytics
as tools — both unlocked by the Sync + API plan (Hall of Fame, Officials and the Owner
have them included). Tokens are managed from Account → API & MCP on
https://fspn.ragealley.com/app#/account. The rest of the app is in the
user guide; setting Claude up step by step, with every tool listed, is
Connect Claude (MCP). Source of truth: interface/auth.py (Bearer
resolution, entitlements), interface/web.py (token_guard, /api/account/tokens*),
interface/mcp_http.py (POST /mcp), interface/oauth.py (the OAuth 2.1 endpoints);
tests in tests/test_api_tokens.py and tests/test_mcp_oauth.py.
Authentication
Create a personal API token on the Account page (or let an MCP client obtain one
through OAuth — the section below). The secret (sa_ + 40 URL-safe characters) is shown
once; the server keeps only its SHA-256 and the first 8 characters as a display handle.
Send it as a Bearer header:
Authorization: Bearer sa_…
- Base URL:
https://fspn.ragealley.com - Tokens are personal and non-transferable; up to 5 live tokens per account (revoke one to mint another). Each token has a name (≤ 60 characters) and a scope.
- Scopes:
read(every GET endpoint) orread_write(alsoPOST /api/team/move,POST /api/team/import,/api/run,/api/run-streamand the MCPrun_commandtool —Api.TOKEN_WRITE_PATHS). A read token on a write path answers `403 This token is read-only…`. /api/account/*,/api/billing/*and/api/ops/*never accept a token (TOKEN_SESSION_ONLY_PREFIXES,403) — manage those signed in. You cannot mint or revoke a token with a token.- A token is explicit: when both a Bearer header and a session cookie arrive, the token
wins, and a bad token is refused (
401) rather than falling back to the cookie. - The plan is checked on every request: a lapsed subscription answers `403 The API needs
the Sync + API plan.` until it is renewed (the tokens survive and resume); a revoked or
unknown token answers
401 That API token isn't valid — it may have been revoked. - The league:
?league=<league_id>(or anX-Leagueheader) picks one of your leagues; omitted, the first membership is used./api/melists them. Naming a league you are not a member of is403. - Errors are
{"error": "…"}with 400 (bad input), 401, 403 or 429. - The local install (open mode) ignores the header — every request there is already the Owner, and the Account page says tokens are a hosted feature.
Quick check:
curl -H "Authorization: Bearer sa_…" https://fspn.ragealley.com/api/me
curl -H "Authorization: Bearer sa_…" "https://fspn.ragealley.com/api/team/summary?season=2026"
Rate limits
120 requests per minute per token (sliding 60-second window, Api.TOKEN_RATE_PER_MINUTE);
past it the answer is 429 — on /mcp with a Retry-After: 60 header. The limit is per
token, so two tokens on one account count separately. Sync-heavy endpoints keep their own
per-league cadence (one provider sync per 5 minutes). last_used_at on a token is
refreshed at most once an hour, so reads stay reads.
Pure read views (/api/team/summary, allplay, storylines, matchups, edges,
simulate, /api/week, /api/ratings, and /api/team/odds when a seed is given) are
memoized server-side for five minutes per league and team; the response carries
X-Cache: hit|miss. A league sync invalidates them, so a hit is never older than the
league's updated_at.
Endpoints worth scripting
GET /api/index is the complete, machine-readable catalog (every endpoint with its
params). The ones scripts reach for:
| Endpoint | What you get | Params |
|---|---|---|
/api/me | Who the token is, entitlements, leagues and memberships | — |
/api/team/summary | League header: name, my team, record, standing, current week, sync time | season |
/api/team/week | My team this week: matchup + lineups, waiver upgrades, trends, storylines | season*, week* |
/api/team/matchups | Every matchup this week with projections and win probability | season*, week* |
/api/team/odds | Playoff odds by simulation, must-win flag | season*, week*, simulations |
/api/team/trade-ideas | Trade finder: deals that improve both lineups rest-of-season | season*, week*, with, top |
/api/week | Full-week NFL projections with floor/ceiling and matchup multipliers | season*, week*, top_position |
/api/player | One player's card: projection, rank, trend, splits, owner | name*, season, week |
* = required. Every /api/team/* answer is scoped to the token's user and league. All
outputs are statistical estimates for information and entertainment — see /terms.
MCP connector
POST https://fspn.ragealley.com/mcp speaks MCP over streamable HTTP: JSON-RPC 2.0 in the
body (one message, or an array as a batch), JSON back. Notifications are acknowledged with
202 and no body; the server opens no stream of its own, so GET /mcp is 405; bodies
over 256 KiB are 413. Nothing is kept between requests — every call authenticates
afresh, so there is no session id to track. The server negotiates protocol versions
2025-06-18, 2025-03-26 and 2024-11-05 (an initialize naming one of those gets it
echoed back; anything else gets the newest) and answers with an MCP-Protocol-Version
header. Methods: initialize, tools/list, tools/call, ping; anything else is
-32601.
It exposes the same 36 tools as the local stdio server (`python -m
sports_analytics.main mcp): get_my_week, simulate_matchup, find_trades`,
get_playoff_odds, get_player_card, search_players, get_weekly_digest,
get_waiver_advice, play_redacted, play_trivia and the rest (the full catalogue, grouped,
is on Connect Claude). The only credential is the
Bearer token — cookies are never read, so a browser tab cannot be tricked into calling
tools and there is no CSRF surface — and each call is scoped to the token's league
(?league= / X-League as on the JSON API). Every tools/call is written to the audit log
as mcp.call (tool name only, never the arguments).
Claude Code:
claude mcp add --transport http fspn https://fspn.ragealley.com/mcp \
--header "Authorization: Bearer sa_…"
Claude Desktop's Add custom connector dialog, the Claude web app and the connector
directories take the URL alone and sign in with OAuth (next section). A personal token
also works on Desktop through the mcp-remote bridge in claude_desktop_config.json
— the walkthrough is on Connect Claude (MCP):
{
"mcpServers": {
"fspn": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://fspn.ragealley.com/mcp",
"--header", "Authorization: Bearer sa_…"]
}
}
}
By hand:
curl -X POST https://fspn.ragealley.com/mcp \
-H "Authorization: Bearer sa_…" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Auth failures come back as HTTP 401/403/429 with a JSON-RPC error body
(-32001 unauthorized, -32003 forbidden — a valid token whose plan lapsed, -32029 rate
limited) and, on 401, a `WWW-Authenticate: Bearer realm="fspn",
resource_metadata="https://fspn.ragealley.com/.well-known/oauth-protected-resource"`
header (error="invalid_token" is added when a token was actually presented); a body
that is not JSON is 400 / -32700. Tool-level problems are isError: true results, as
on stdio. The one write tool, run_command, needs a read_write token. The hosted
endpoint is hosted-only: on the local install POST /mcp answers 401 and points at the
stdio server.
OAuth 2.1 (connector directories, Claude Desktop)
MCP clients that speak OAuth — Claude Desktop's connector dialog, the Claude web app, the
Claude and OpenAI connector directories — obtain a token by themselves: they read the
resource_metadata URL off the 401, register, send the member to the consent page and
exchange the code. What they get is an ordinary sa_… token (an api_tokens row that
names its client_id) with the read scope, so everything above applies unchanged:
same entitlement check on every request, same rate limit, same league scoping, never the
account/billing/ops routes. A write scope for OAuth clients is not offered (personal
tokens keep read_write). Hosted-only; the local install serves the metadata but
answers every flow with the hosted-feature note.
| Endpoint | What it does |
|---|---|
GET /.well-known/oauth-authorization-server | RFC 8414 metadata: issuer https://fspn.ragealley.com, the four endpoints below, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["mcp"]. |
GET /.well-known/oauth-protected-resource (also …/mcp) | RFC 9728 metadata: resource https://fspn.ragealley.com/mcp, authorization_servers, bearer_methods_supported: ["header"]. |
POST /oauth/register | RFC 7591 dynamic registration, JSON in and out, public clients only (token_endpoint_auth_method must be none, no secret is issued). redirect_uris: 1-10 https URLs, or http on 127.0.0.1 / localhost / [::1] for native apps; no fragments. Optional client_name (100 characters). Answers 201 with client_id, client_id_issued_at and the accepted metadata; 400 invalid_redirect_uri / invalid_client_metadata; 10 registrations an hour per address (429, Retry-After: 3600). |
GET /oauth/authorize | client_id, redirect_uri (exact match with the registration; a loopback URI may change its port), response_type=code, code_challenge + code_challenge_method=S256 (required), state, optional scope=mcp and resource. Needs a signed-in session — otherwise 303 to /login?next=… and back. An unknown client or a mismatched redirect_uri renders an error page (nothing is sent to an unverified URL); other faults redirect with error=invalid_request / unsupported_response_type / invalid_scope and state. A member without the api entitlement sees a Sync + API needed page (403). Otherwise the consent page: the app's name, what it may do, Approve / Deny. |
POST /oauth/authorize | The consent form (CSRF-checked, same-origin). Approve → 302 to redirect_uri with code (single-use, 10 minutes) and state; Deny → error=access_denied. Approving the same client again replaces its earlier grant; past ten connected apps the oldest is retired. |
POST /oauth/token | Form-encoded (JSON accepted). grant_type=authorization_code with code, client_id, redirect_uri, code_verifier (S256, checked in constant time; a wrong verifier burns the code); grant_type=refresh_token with refresh_token (and client_id if the client sends it). Answers {"access_token": "sa_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "sr_…", "scope": "mcp"} with Cache-Control: no-store. Every refresh rotates the refresh token (30-day life); presenting a rotated-away one revokes the whole grant. Errors are RFC 6749 bodies: 400 invalid_request / invalid_grant / unsupported_grant_type, 401 invalid_client; a lapsed plan is invalid_grant with the plan message. |
POST /oauth/revoke | RFC 7009: token (an access or refresh token, token_type_hint optional). Always 200 with {}. |
The register, token and revoke endpoints and the metadata documents answer
Access-Control-Allow-Origin: * and OPTIONS preflights; they run before the app's
same-origin check because the code, the PKCE verifier and the refresh token are the
proof — no cookie is read there. The consent page's policy allows the form to redirect
to the registered client origin and nothing else. Everything is audited: oauth.register,
oauth.authorize, oauth.deny, oauth.token (grant, client, prefix — never a secret),
oauth.code_replay, oauth.refresh_replay, oauth.revoke.
Revocation
Revoke from the Account page (or GET /api/account/tokens/revoke?id=… while signed in;
GET /api/account/tokens lists ids, names, prefixes, scopes, created and last-used times
and, for an OAuth-issued token, its client_id — never the secret). Connected apps sit
in the same list, labelled, and do not count against the five personal tokens. The token
stops working immediately; every create and revoke (token.create, token.revoke) and
every MCP call (mcp.call) is in the audit log the Officials read on /ops. Deleting the
account deletes its tokens. Signing out does not — tokens are independent of browser
sessions.