From f78957d84948cf2711036f7853b3e8a22bd506de Mon Sep 17 00:00:00 2001 From: Darsh Joshi Date: Tue, 30 Jun 2026 02:53:50 -0400 Subject: [PATCH 1/3] Fix release-readiness bugs: restore truncated history + long-run pace, polish errors Public-release audit fixes (all verified against real F1 data): HIGH (broken features) - get_historical_results: paginate Jolpica (it caps limit at 100) and merge results by round so season queries return full seasons (2008: 2 -> 18 races), not ~2 - analyze_long_run_pace: fix bool(pd.NaT)==True guard that rejected every lap, so it now finds long runs instead of always returning "No long runs found" MEDIUM (silent-wrong / raw-error UX) - get_lap_times: return "driver not found" for bad codes instead of all 22 drivers - get_live_weather: add is_live guard so off-session returns "no live session" instead of stale last-session weather - _get_json: translate static-archive 403/404 into a friendly message (no raw URL) - get_circuit_info: validate event + route FastF1 errors through _ff1_error LOW / docs - pick_driver -> pick_drivers (5 sites; silence FastF1 FutureWarning) - compare_sector_times: say "equal" at delta 0 instead of " faster" - clearer auth-setup wording for new users - print startup banner to stderr (stdout is the stdio MCP JSON-RPC channel) - _fetch_live: early-exit off-session on the no-auth path (~3.7s -> ~0.7s) - document the 2022 / partial-2024 static-archive gaps (README + list_seasons) lite/full tool counts unchanged (30/79). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LNQTLY5cddQrbSbdRVrbEd --- README.md | 8 +-- pitwall.py | 142 ++++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7e14fae..d08e0a1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Pitwall connects Claude to **real F1 data**: - **Lap-level telemetry** — speed, RPM, throttle, brake, gear, DRS at 4Hz per car - **Visual plots** — speed trace comparisons, gear shift maps returned as images - **75 years of history** — every race result and championship since 1950 -- **Fresh after every session** — full telemetry and timing published ~30 min after each session ends, back to 2018 +- **Fresh after every session** — full telemetry and timing published ~30 min after each session ends, back to 2018 (2022 and early-2024 are missing from F1's free archive; historical-results tools still cover them) - **Zero API keys** — all core data is free, no account needed --- @@ -277,11 +277,13 @@ All core data is **free and requires no API keys**. | Source | Coverage | What it provides | |--------|----------|-----------------| -| [F1 Static Live Timing](https://livetiming.formula1.com/static/) | 2018-present | Telemetry, timing, strategy, pit stops, weather, race control | +| [F1 Static Live Timing](https://livetiming.formula1.com/static/) | 2018-present¹ | Telemetry, timing, strategy, pit stops, weather, race control | | [Jolpica-F1](https://api.jolpi.ca/ergast/f1/) | 1950-present | Historical results and championships | -| [FastF1](https://github.com/theOehrly/Fast-F1) (optional) | 2018-present | Enhanced telemetry analysis and visual plots | +| [FastF1](https://github.com/theOehrly/Fast-F1) (optional) | 2018-present¹ | Enhanced telemetry analysis and visual plots | | [F1 SignalR Core](https://livetiming.formula1.com/signalrcore) (optional) | Live only | Real-time race data during active sessions | +¹ F1's static archive currently has **no 2022** and only **partial early-2024**. Jolpica-backed history tools (`get_historical_results`, `get_championship_standings`) still cover those seasons. + --- ## How It Works diff --git a/pitwall.py b/pitwall.py index a3fb327..8f7c49e 100644 --- a/pitwall.py +++ b/pitwall.py @@ -99,11 +99,28 @@ def _get_json(path: str) -> dict: resp = _http.get(f"{STATIC_BASE}/{path}", timeout=15) + if resp.status_code in (403, 404): + # F1's static archive only covers 2018-2021 and 2023-present; other years 403/404. + yr = path.split("/", 1)[0] + raise ValueError( + f"Season {yr} isn't in F1's free static archive (covers 2018-2021, 2023-present). " + "Use get_championship_standings or get_historical_results for other seasons." + ) resp.raise_for_status() resp.encoding = "utf-8-sig" return resp.json() +def _ff1_error(e, year=None, gp=None) -> str: + """Friendly message for FastF1's developer-facing 'not loaded' / 'does not exist' errors, + which surface when a session has no data yet (future/just-run) or the GP name is wrong.""" + m = str(e) + if "has not been loaded" in m or "does not exist" in m or "Failed to load" in m: + where = f" for {gp} {year}" if gp else "" + return f"No FastF1 data available{where} yet (sessions appear ~30-60 min after they run)." + return f"Error: {m}" + + def _find_session(year: int, race: str, session_type: str = "Race") -> tuple: """Find session path by fuzzy matching race name.""" data = _get_json(f"{year}/Index.json") @@ -225,7 +242,9 @@ def list_seasons() -> str: lines.append(f" {year}: {n} events") except Exception: pass - return "Available seasons:\n" + "\n".join(lines) + return ("Available seasons:\n" + "\n".join(lines) + + "\n\nNote: F1's static archive is missing 2022 and early-2024 — use " + "get_championship_standings / get_historical_results for those seasons.") except Exception as e: return f"Error: {e}" @@ -342,6 +361,8 @@ def get_lap_times(year: int = 2026, race: str = "", driver: str = "", dm = _driver_map(path) target = _find_driver_num(driver, dm) if driver else None + if driver and target is None: + return f"Driver '{driver.upper()}' not found in this session." feeds = _get_json(f"{path}Index.json").get("Feeds", {}) sp = feeds.get("TimingData", {}).get("StreamPath", "") if not sp: @@ -742,6 +763,34 @@ def get_driver_comparison(driver_a: str, driver_b: str, year: int = 2026, JOLPICA = "https://api.jolpi.ca/ergast/f1" +def _jolpica_races(url_base, max_races=25): + """Fetch Ergast/Jolpica race results across pages. Jolpica caps `limit` at 100 and paginates + the flat Result list, so a single race can straddle a page boundary — merge Results by round. + Bounded to max_races (the caller displays races[:20]).""" + by_round, order = {}, [] + offset = 0 + while True: + mr = requests.get(f"{url_base}?limit=100&offset={offset}", timeout=10).json().get("MRData", {}) + page = mr.get("RaceTable", {}).get("Races", []) + if not page: + break + for r in page: + key = (r.get("season"), r.get("round")) + if key in by_round: + by_round[key].setdefault("Results", []).extend(r.get("Results", [])) + else: + by_round[key] = r + order.append(key) + offset += 100 + try: + total = int(mr.get("total", 0)) + except (TypeError, ValueError): + total = 0 + if offset >= total or len(order) >= max_races: + break + return [by_round[k] for k in order] + + @mcp.tool() def get_historical_results(year: int = 0, race: str = "", driver: str = "") -> str: """Get historical F1 race results from 1950 to present. @@ -757,23 +806,26 @@ def get_historical_results(year: int = 0, race: str = "", driver: str = "") -> s if race and not circuit_id: return (f"Couldn't map '{race}' to a circuit. Try a circuit or country name " f"like 'monza', 'monaco', 'silverstone', 'spain'.") + # Jolpica/Ergast caps `limit` at 100 and paginates the flat Result list (~20 entries + # per race), so a single fetch truncated whole seasons to ~5 races. _jolpica_races + # pages through and merges by round; output is still capped at races[:20] below. if year and driver and circuit_id: - url = f"{JOLPICA}/{year}/drivers/{driver}/circuits/{circuit_id}/results.json?limit=30" + url = f"{JOLPICA}/{year}/drivers/{driver}/circuits/{circuit_id}/results.json" elif driver and circuit_id: - url = f"{JOLPICA}/drivers/{driver}/circuits/{circuit_id}/results.json?limit=30" + url = f"{JOLPICA}/drivers/{driver}/circuits/{circuit_id}/results.json" elif year and driver: - url = f"{JOLPICA}/{year}/drivers/{driver}/results.json?limit=50" + url = f"{JOLPICA}/{year}/drivers/{driver}/results.json" elif year and circuit_id: - url = f"{JOLPICA}/{year}/circuits/{circuit_id}/results.json?limit=30" + url = f"{JOLPICA}/{year}/circuits/{circuit_id}/results.json" elif year: - url = f"{JOLPICA}/{year}/results.json?limit=30" + url = f"{JOLPICA}/{year}/results.json" elif circuit_id: - url = f"{JOLPICA}/circuits/{circuit_id}/results.json?limit=30" + url = f"{JOLPICA}/circuits/{circuit_id}/results.json" elif driver: - url = f"{JOLPICA}/drivers/{driver}/results.json?limit=30" + url = f"{JOLPICA}/drivers/{driver}/results.json" else: - url = f"{JOLPICA}/current/results.json?limit=30" - races = requests.get(url, timeout=10).json().get("MRData", {}).get("RaceTable", {}).get("Races", []) + url = f"{JOLPICA}/current/results.json" + races = _jolpica_races(url) if not races: if circuit_id: where = f" at {race}" + (f" in {year}" if year else "") @@ -959,7 +1011,7 @@ def get_fastest_lap_data(year: int, gp: str, driver: str, session: str = 'Q') -> try: s = fastf1.get_session(year, gp, session) s.load(telemetry=False) - lap = s.laps.pick_driver(driver).pick_fastest() + lap = s.laps.pick_drivers(driver).pick_fastest() if lap is None or pd.isna(lap['LapTime']): return f"No qualifying lap found for {driver} in {gp} {year} {session}. The driver may have been eliminated in an earlier session." @@ -990,8 +1042,8 @@ def plot_telemetry_comparison(year: int, gp: str, driver1: str, driver2: str, se s = fastf1.get_session(year, gp, session) s.load() - d1 = s.laps.pick_driver(driver1).pick_fastest() - d2 = s.laps.pick_driver(driver2).pick_fastest() + d1 = s.laps.pick_drivers(driver1).pick_fastest() + d2 = s.laps.pick_drivers(driver2).pick_fastest() t1 = d1.get_car_data().add_distance() t2 = d2.get_car_data().add_distance() @@ -1198,7 +1250,7 @@ def plot_gear_shifts(year: int, gp: str, driver: str, session: str = 'Q') -> Ima s.load() # Get the driver's laps - driver_laps = s.laps.pick_driver(driver) + driver_laps = s.laps.pick_drivers(driver) if driver_laps.empty: raise ValueError(f"No laps found for driver {driver}") @@ -1276,14 +1328,18 @@ def get_weather_data(year: int, gp: str, session: str = 'R') -> str: def get_circuit_info(year: int, gp: str) -> str: """Get track layout info (Corners, DRS Zones).""" try: + try: + fastf1.get_event(year, gp) + except Exception: + return f"Circuit '{gp}' not found for {year}. Use get_schedule({year}) for valid GP names." s = fastf1.get_session(year, gp, 'Q') s.load(laps=True, telemetry=True) # Telemetry needed for circuit info info = s.get_circuit_info() - + corners = info.corners[['Number', 'Letter', 'Angle', 'Distance']].to_string(index=False) return f"Circuit Rotation: {info.rotation} degrees\n\nCorners:\n{corners}" except Exception as e: - return f"Circuit Info Error: {e}" + return _ff1_error(e, year, gp) # ============================================================================== # MODULE 5: TYRE STRATEGY @@ -1295,7 +1351,7 @@ def get_driver_tyre_detail(year: int, gp: str, driver: str) -> str: try: s = fastf1.get_session(year, gp, 'R') s.load() - laps = s.laps.pick_driver(driver) + laps = s.laps.pick_drivers(driver) stints = laps.groupby('Stint').agg({ 'Compound': 'first', @@ -1493,14 +1549,14 @@ def compare_sector_times(year: int, gp: str, driver1: str, driver2: str, session time1 = lap1[sector].total_seconds() time2 = lap2[sector].total_seconds() diff = time1 - time2 - faster = driver1 if diff < 0 else driver2 - result += f"Sector {i}: {time1:.3f}s vs {time2:.3f}s (Δ {abs(diff):.3f}s, {faster} faster)\n" - + verdict = "equal" if diff == 0 else f"{driver1 if diff < 0 else driver2} faster" + result += f"Sector {i}: {time1:.3f}s vs {time2:.3f}s (Δ {abs(diff):.3f}s, {verdict})\n" + total1 = lap1['LapTime'].total_seconds() total2 = lap2['LapTime'].total_seconds() diff_total = total1 - total2 - faster_total = driver1 if diff_total < 0 else driver2 - result += f"\nTotal: {total1:.3f}s vs {total2:.3f}s (Δ {abs(diff_total):.3f}s, {faster_total} faster)" + verdict_total = "equal" if diff_total == 0 else f"{driver1 if diff_total < 0 else driver2} faster" + result += f"\nTotal: {total1:.3f}s vs {total2:.3f}s (Δ {abs(diff_total):.3f}s, {verdict_total})" return result except Exception as e: @@ -2407,7 +2463,7 @@ def analyze_long_run_pace(year: int, gp: str, driver: str, session: str = 'FP2') current_run = [] for idx, lap in laps.iterrows(): - if pd.notna(lap['LapTime']) and not lap.get('PitInTime'): + if pd.notna(lap['LapTime']) and pd.isna(lap.get('PitInTime', pd.NaT)): current_run.append(lap) else: if len(current_run) >= 5: # At least 5 consecutive laps @@ -2433,8 +2489,8 @@ def analyze_long_run_pace(year: int, gp: str, driver: str, session: str = 'FP2') return result except Exception as e: - return f"Error: {str(e)}" - + return _ff1_error(e, year, gp) + # ============================================================================== # MODULE 31: HEAD-TO-HEAD COMPARISON # ============================================================================== @@ -2724,7 +2780,20 @@ async def _run(): auth_token=auth_token, ) task = asyncio.create_task(client.connect()) - await asyncio.sleep(settle) + # Settle window: wait for the keyframe + a few seconds of deltas. Off-session this is + # wasted (most days no session is live), so on the no-auth path bail out early once a + # keyframe with session status has arrived and it's clearly not live. Live sessions + # (and any auth fetch) still get the full window. + waited = 0.0 + while waited < settle: + await asyncio.sleep(0.25) + waited += 0.25 + if auth_token is None: + snap = {t: client.get_state(t) for t in topics} + if snap.get("SessionStatus") is not None or snap.get("SessionInfo") is not None: + is_live, _, _ = _session_label(snap) + if not is_live: + break await client.stop() try: await asyncio.wait_for(task, timeout=3) @@ -2873,10 +2942,12 @@ def _format_sectors(state, driver): def _format_weather(state): is_live, label, raw = _session_label(state) + if not is_live: + return _not_live_msg(label, raw, "get_weather") w = state.get("WeatherData") or {} if not w: return _not_live_msg(label, raw, "get_weather") - header = f"\U0001F534 LIVE weather - {label}" if is_live else f"Weather (last session: {label} - NOT live)" + header = f"\U0001F534 LIVE weather - {label}" rain = str(w.get("Rainfall", "")).strip() rain_txt = "Yes" if rain in ("1", "1.0", "True", "true") else "No" return "\n".join([ @@ -3292,7 +3363,8 @@ def get_live_telemetry(driver: str) -> str: except Exception: return ( "Live telemetry needs a valid F1 TV token (CarData is auth-gated).\n" - "Run: python3 auth_setup.py then retry during a running session." + "Run: python3 auth_setup.py to enable it — live car telemetry only returns " + "data while a session is running." ) try: import jwt @@ -3368,7 +3440,8 @@ def get_live_gps_positions() -> str: except Exception: return ( "Live GPS positions needs a valid F1 TV token (Position.z is auth-gated).\n" - "Run: python3 auth_setup.py then retry during a running session." + "Run: python3 auth_setup.py to enable it — live GPS only returns data while a " + "session is running." ) try: import jwt @@ -3408,17 +3481,18 @@ def main(): mode = "full" if FASTF1_AVAILABLE else "lite" if not FASTF1_AVAILABLE: - print("Pitwall (lite) — 30 tools loaded (incl. live timing). For 79 tools with plots, deep analysis, and live car telemetry:") - print(' pip install "f1pitwall[full]"') - print() + # stderr, not stdout: stdout is the JSON-RPC channel for stdio MCP clients. + print("Pitwall (lite) — 30 tools loaded (incl. live timing). For 79 tools with plots, deep analysis, and live car telemetry:", file=sys.stderr) + print(' pip install "f1pitwall[full]"', file=sys.stderr) + print(file=sys.stderr) if args.http: # FastMCP.run() takes no host/port; they live on settings (mcp >=1.x dropped the kwargs). mcp.settings.host = args.host mcp.settings.port = args.port - print(f"Pitwall ({mode}) starting on {args.host}:{args.port}") + print(f"Pitwall ({mode}) starting on {args.host}:{args.port}", file=sys.stderr) mcp.run(transport="streamable-http") else: - print(f"Pitwall ({mode}) starting (stdio)") + print(f"Pitwall ({mode}) starting (stdio)", file=sys.stderr) mcp.run(transport="stdio") From f6a69dc41fcaec8ce0ebf6da179484679f6bf1e0 Mon Sep 17 00:00:00 2001 From: Darsh Joshi Date: Tue, 30 Jun 2026 03:06:49 -0400 Subject: [PATCH 2/3] Address review: scope 404 vs 403, narrow _ff1_error, paginate robustly Follow-up to the release-readiness review (darsh-review on PR #8): - _get_json: split 404 ("not published yet") from 403 ("season not in static archive") so a 404 on a session/feed path no longer misreports a live season as missing (review C1) - _ff1_error: narrow patterns to "has not been loaded" / "No data for this session" so real cache/network failures surface as errors instead of "no data yet, wait" (review PW-01) - _jolpica_races: keep partial results on a mid-pagination network/JSON error (re-raise only if the first page fails); fix overstated docstring bound (C2/C3) - _fetch_live: note the early-exit's SessionStatus/SessionInfo dependency (C4) Verified: lite=30 / full=79 tools; _ff1_error unit cases pass; 2008 still returns 18 races; 2022 still returns the friendly archive message. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LNQTLY5cddQrbSbdRVrbEd --- pitwall.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/pitwall.py b/pitwall.py index 8f7c49e..7051cc4 100644 --- a/pitwall.py +++ b/pitwall.py @@ -99,23 +99,31 @@ def _get_json(path: str) -> dict: resp = _http.get(f"{STATIC_BASE}/{path}", timeout=15) - if resp.status_code in (403, 404): - # F1's static archive only covers 2018-2021 and 2023-present; other years 403/404. + if resp.status_code == 403: + # F1's static archive 403s whole unsupported seasons (no 2022; pre-2018; future years). yr = path.split("/", 1)[0] raise ValueError( f"Season {yr} isn't in F1's free static archive (covers 2018-2021, 2023-present). " "Use get_championship_standings or get_historical_results for other seasons." ) + if resp.status_code == 404: + # A 404 on a session/feed path means it's just not published yet (not a whole-season gap). + raise ValueError( + "That F1 data isn't published yet — full timing/telemetry appears ~30 min after a " + "session ends." + ) resp.raise_for_status() resp.encoding = "utf-8-sig" return resp.json() def _ff1_error(e, year=None, gp=None) -> str: - """Friendly message for FastF1's developer-facing 'not loaded' / 'does not exist' errors, - which surface when a session has no data yet (future/just-run) or the GP name is wrong.""" + """Friendly message for FastF1's 'no data yet' errors — a session that hasn't run, or only just + finished. Patterns kept narrow ("has not been loaded" = NotLoadedError; "No data for this + session" = SessionNotAvailableError) so genuine cache/network failures still surface as real + errors instead of telling the user to wait.""" m = str(e) - if "has not been loaded" in m or "does not exist" in m or "Failed to load" in m: + if "has not been loaded" in m or "No data for this session" in m: where = f" for {gp} {year}" if gp else "" return f"No FastF1 data available{where} yet (sessions appear ~30-60 min after they run)." return f"Error: {m}" @@ -766,11 +774,17 @@ def get_driver_comparison(driver_a: str, driver_b: str, year: int = 2026, def _jolpica_races(url_base, max_races=25): """Fetch Ergast/Jolpica race results across pages. Jolpica caps `limit` at 100 and paginates the flat Result list, so a single race can straddle a page boundary — merge Results by round. - Bounded to max_races (the caller displays races[:20]).""" + The max_races check fires after a full page, so the real bound is ~max_races + one page; the + caller displays races[:20].""" by_round, order = {}, [] offset = 0 while True: - mr = requests.get(f"{url_base}?limit=100&offset={offset}", timeout=10).json().get("MRData", {}) + try: + mr = requests.get(f"{url_base}?limit=100&offset={offset}", timeout=10).json().get("MRData", {}) + except Exception: + if order: + break # network/JSON error mid-pagination: keep the races we already have + raise # first page failed — let the caller surface the real error page = mr.get("RaceTable", {}).get("Races", []) if not page: break @@ -2782,8 +2796,9 @@ async def _run(): task = asyncio.create_task(client.connect()) # Settle window: wait for the keyframe + a few seconds of deltas. Off-session this is # wasted (most days no session is live), so on the no-auth path bail out early once a - # keyframe with session status has arrived and it's clearly not live. Live sessions - # (and any auth fetch) still get the full window. + # keyframe with session status has arrived and it's clearly not live. Needs SessionStatus + # /SessionInfo in `topics` (all 16 no-auth callers pass them); live sessions and any auth + # fetch still get the full window. waited = 0.0 while waited < settle: await asyncio.sleep(0.25) From 6b2950cf618e0965ef81a29caddd296b196167a0 Mon Sep 17 00:00:00 2001 From: Darsh Joshi Date: Tue, 30 Jun 2026 03:13:51 -0400 Subject: [PATCH 3/3] Bump version to 1.1.1 Patch release for the release-readiness fixes (pyproject + server.json kept in sync). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LNQTLY5cddQrbSbdRVrbEd --- pyproject.toml | 2 +- server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bdd76b9..19a5ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "f1pitwall" -version = "1.1.0" +version = "1.1.1" description = "F1 MCP server for Claude — 79 tools for live timing, telemetry, strategy, and 75 years of history. pip install f1pitwall[full] for plots." readme = "README.md" license = "MIT" diff --git a/server.json b/server.json index 20cf042..8d96006 100644 --- a/server.json +++ b/server.json @@ -8,12 +8,12 @@ "url": "https://github.com/darshjoshi/pitwall", "source": "github" }, - "version": "1.1.0", + "version": "1.1.1", "packages": [ { "registryType": "pypi", "identifier": "f1pitwall", - "version": "1.1.0", + "version": "1.1.1", "transport": { "type": "stdio" },