FSPN

FSPN API and MCP connector

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_…

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:

EndpointWhat you getParams
/api/meWho the token is, entitlements, leagues and memberships
/api/team/summaryLeague header: name, my team, record, standing, current week, sync timeseason
/api/team/weekMy team this week: matchup + lineups, waiver upgrades, trends, storylinesseason*, week*
/api/team/matchupsEvery matchup this week with projections and win probabilityseason*, week*
/api/team/oddsPlayoff odds by simulation, must-win flagseason*, week*, simulations
/api/team/trade-ideasTrade finder: deals that improve both lineups rest-of-seasonseason*, week*, with, top
/api/weekFull-week NFL projections with floor/ceiling and matchup multipliersseason*, week*, top_position
/api/playerOne player's card: projection, rank, trend, splits, ownername*, 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.

EndpointWhat it does
GET /.well-known/oauth-authorization-serverRFC 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/registerRFC 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/authorizeclient_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/authorizeThe 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/tokenForm-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/revokeRFC 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.