Skip to content

feat(mcp): Live Tennis API provider — live scores, fixtures, players (#86) - #94

Open
bensynapse wants to merge 2 commits into
WFord26:mainfrom
bensynapse:add-tennis-provider
Open

feat(mcp): Live Tennis API provider — live scores, fixtures, players (#86)#94
bensynapse wants to merge 2 commits into
WFord26:mainfrom
bensynapse:add-tennis-provider

Conversation

@bensynapse

Copy link
Copy Markdown

Closes #86.

Disclosure

Vendor-authored: I run the Live Tennis API, so this provider is a vendor contribution — judge it on the merits. It follows your espn_api_handler.py pattern, shares your ResponseCache, and touches exactly one file outside its own module (diagnostics_tools.py, for the quota line you asked for).

Scope (as agreed in the thread)

v1 = live + fixtures + players only — the surface a free key can genuinely exercise. Rankings (Pro) and H2H (Basic) are left out rather than shipped as tools that 402 on the key your setup docs tell people to get. Match prices are unaffected and stay with get_odds and its tennis_atp_* passthrough.

The tier contract is in the module docstring and .env.example with the arithmetic, not just the adjectives: free tier for fixtures, player lookups and low-cadence score checks; paid tier for live match-following. At the default 30s live TTL, following one three-hour match is ~360 requests against a free key's 100/day — it exhausts the day inside one match. CACHE_TTL_TENNIS_LIVE is the survival knob (a 900s TTL keeps a whole casual Slam day inside the free allowance).

The 7 points

  1. sports_api/tennis_api_handler.py — handler + a per-minute token bucket. It does not route through key_manager.py: that models a monthly sticky-until-exhausted quota with no per-minute concept, and its marker matching would classify a 429 burst as a drained key and park it. Instead the bucket gates outgoing requests locally, and the daily quota is tracked honestly (a 429 parks the key until the next UTC midnight; /usage refreshes the numbers). No team formatters reused — tennis output is player-shaped and set-by-set.
  2. sports_api/tools/tennis_tools.py exporting register_tennis_tools(mcp, handler), re-exported from tools/__init__.py. Tool names prefixed: get_tennis_scoreboard, get_tennis_match_score, get_tennis_fixtures, get_tennis_player.
  3. Gated on TENNIS_API_KEY. No key → no handler → nothing registered (register_tennis_tools returns early when the handler is None). Verified by a test.
  4. Shared ResponseCache via constructor, "tennis" namespace in make_key, _is_cacheable copied from ESPN (failures never cache), API key kept out of the cache key. The composition root merges CACHE_TTL_TENNIS_LIVE (default 30) / _FIXTURES / _PLAYER over the handler defaults.
  5. .env.example documents the TTL knob and tier arithmetic right next to TENNIS_API_KEY.
  6. Quota reporting folded into get_api_status in diagnostics_tools.py — from the handler's tracked state, so it stays quota-free (no /usage call inside the diagnostics tool). It's the only file outside my own that I touched.
  7. Tests: tests/test_tennis_api_handler.py with aioresponses, mirroring test_espn_api_handler.py, plus a no-key case in test_tools_registration.py. The rate limiter and the daily-quota-exhausted branch are covered specifically. Response samples are real Live Tennis API shapes (the {"data": ...} envelope, players.p1/p2, the sets/games/points/server score object), not invented.

Verification

Full suite: 174 passed (was 143 before). Coverage on sports_api/: handler 95%, TOTAL 65% (target 50%). Run with cd mcp && pip install -r requirements-dev.txt && pytest tests/.

One note: I matched the existing hand-formatted style of odds_api_handler.py/espn_tools.py (which aren't Black-clean in the tree today) rather than Black-format only the new files and diverge from their neighbors — happy to run Black across the module if you'd prefer.

If a vendor-contributed provider still isn't a direction you want, close it — no hard feelings, and thanks for the detailed scoping in the thread.

Adds a native tennis data provider for the Sports Data MCP server, resolving
issue WFord26#86. Follows the espn_api_handler.py pattern, shares the existing
ResponseCache, and is gated on TENNIS_API_KEY the way ODDS_API_KEY and
DASHBOARD_API_KEY already are — no key set, no handler built, no tools
registered.

v1 scope is the surface a free key can actually exercise: live scores,
fixtures, and player lookups. Rankings (Pro) and H2H (Basic) are intentionally
left out. Match prices are unaffected and stay with get_odds' tennis_atp_*
passthrough.

- sports_api/tennis_api_handler.py: handler + a per-minute token-bucket limiter
  and honest daily-quota tracking (from a 429 or /usage), deliberately NOT
  routed through key_manager.py (wrong quota shape) or the team formatters.
- sports_api/tools/tennis_tools.py: register_tennis_tools(mcp, handler),
  prefixed tool names, registers nothing when the handler is None. Derives
  break-point state from the documented rule.
- Composition root wires the handler (gated on TENNIS_API_KEY) with a
  CACHE_TTL_TENNIS_LIVE override (default 30) and passes it to diagnostics.
- get_api_status folds in the tennis daily-quota from tracked state (no
  upstream call, so it stays quota-free).
- .env.example documents the tier contract and the TTL survival knob with the
  arithmetic, next to TENNIS_API_KEY.
- Tests: test_tennis_api_handler.py (aioresponses, real API shapes) covering
  the rate limiter and the daily-quota-exhausted branch; a no-key registration
  case in test_tools_registration.py.

Vendor-authored (Live Tennis API) — disclosed in the module docstring and PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@WFord26
WFord26 self-requested a review August 19, 2026 14:28
@WFord26 WFord26 self-assigned this Aug 19, 2026
@WFord26 WFord26 added in-progress Currently being worked on api API related labels Aug 19, 2026
@WFord26

WFord26 commented Aug 19, 2026

Copy link
Copy Markdown
Owner

@bensynapse Thanks for the detailed scoping write-up — the tier arithmetic and the "don't ship tools that 402 on the key our docs tell people to get" call are both right, and the handler tracks the existing pattern closely.

Two things I'd like fixed before this goes in. Both are in the quota state machine — the one part with no upstream to correct it, so a wrong guess there sticks.

1. Every 429 is treated as daily exhaustiontennis_api_handler.py, the 429 branch in _fetch

The comment there reasons that the token bucket already handles minute bursts, so a 429 that slips through must be the daily limit. The bucket can't carry that weight: it's process-local and starts full. Restart the server mid-minute after 30 requests have already gone out and the fresh bucket hands out 30 more — the API returns a minute-level 429, and _mark_daily_exhausted() parks the key until the next UTC midnight. Same story for a second client sharing the key. Every tennis tool then returns "Daily request quota exhausted" for up to 24 hours with ~90 of the 100 daily requests unspent.

Could you classify the 429 before parking? The response body, the rate-limit headers, and Retry-After all carry the signal. A minute-scale 429 wants a minute-scale backoff; only a genuine daily 429 should cost the day.

2. get_usage() can't recover from a bad parkget_usage(), ~line 447

It routes through _make_request_fetch, which short-circuits on _daily_is_exhausted(). So the call documented as "can be called to refresh the numbers get_quota_status() reports" — the one diagnostics_tools.py points users at — is blocked exactly when the state is wrong. And _record_usage only ever sets exhaustion on remaining <= 0; a positive remaining doesn't clear it.

Combined with (1), a park set in error has no way out short of restarting the process or waiting for UTC midnight. I'd like /usage to bypass the daily gate — it's free, and it's the only way to check the guess against the API — and a positive remaining to clear the park.

Related: /usage is cached under the live TTL, so at the 900s CACHE_TTL_TENNIS_LIVE the docs recommend, the recovery path would answer from a 15-minute-old cache. Probably wants its own short TTL.

On CI: the Backend/Frontend failures aren't yours. Both suites pass — the jobs die at the "Comment test results on PR" step, which 403s because fork PRs get a read-only GITHUB_TOKEN. That's mine to fix on the workflow side; ignore it.

I have some smaller notes beyond these two, but nothing else blocking — happy to send them if useful.

… recovery path

Addresses the two quota-state-machine issues raised in review.

1. A 429 no longer blanket-parks the day. _classify_rate_limit() reads the
   rate-limit scope/window headers, then per-window remaining, then the body
   wording, then Retry-After magnitude; only a genuine daily 429 calls
   _mark_daily_exhausted(). A minute-scale 429 drains the local bucket
   (TokenBucket.penalize) and returns rate_limited + retry_after_seconds,
   leaving the daily quota intact. Default when nothing says "daily" is
   minute-scale.

2. /usage is a recovery call again. get_usage() passes bypass_daily_gate=True
   so the free read reaches the API even while parked; _record_usage now
   clears the park on a positive remaining (was set-only); and /usage gets its
   own short TTL (CACHE_TTL_TENNIS_USAGE, default 15s) rather than sharing the
   live TTL.

+23 unit tests. Full suite 197 passed.

Signed-off-by: Ben Abulafia <ben@synapsereality.io>
@bensynapse

Copy link
Copy Markdown
Author

Both quota-state-machine issues are addressed on the branch.

1 — a 429 no longer blanket-parks the day. The _fetch 429 branch classifies before it parks. Since the local token bucket starts full, a restart mid-minute or a second client on the key produces a minute-scale 429 the bucket never saw — the old code parked until UTC midnight, burning ~90/100 daily requests. _classify_rate_limit(headers, body) now reads strongest-signal-first: explicit rate-limit scope/window header → per-window remaining (X-RateLimit-Remaining-Day/-Minute) → body wording → Retry-After magnitude (delta-seconds or HTTP-date). Only a genuine daily 429 calls _mark_daily_exhausted(); a minute-scale one returns rate_limited + retry_after_seconds and drains the bucket, leaving the day untouched. Default when nothing says "daily" is minute-scale (a wrong ~2s back-off beats a wrong day-long park).

2 — /usage is a real recovery call again. get_usage() passes bypass_daily_gate=True so the free read reaches the API even while parked (it previously routed through the very short-circuit it was meant to correct); the minute limiter still applies. _record_usage is now authoritative both ways — remaining <= 0 parks, remaining > 0 clears the park. And /usage gets its own short TTL (CACHE_TTL_TENNIS_USAGE, default 15s) instead of sharing live, so recovery is never answered from a 15-minute-old cache.

Tests: +23 unit tests (classifier per-signal + minute default, Retry-After parsing, minute-vs-daily 429 end-to-end, bucket penalty, /usage bypass/clear/short-TTL). Full suite 197 passed; the tennis handler sits at 94%.

And thanks for the CI note — agreed, the "Comment test results" 403 is the fork-token scope, not a test failure. Ignoring it. Happy to take the smaller notes too if you want to send them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API related in-progress Currently being worked on

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Tennis data provider in mcp/sports_api/, riding the #74 decomposition

2 participants