Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down Expand Up @@ -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
Expand Down
157 changes: 123 additions & 34 deletions pitwall.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,36 @@

def _get_json(path: str) -> dict:
resp = _http.get(f"{STATIC_BASE}/{path}", timeout=15)
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 '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 "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}"


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")
Expand Down Expand Up @@ -225,7 +250,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}"

Expand Down Expand Up @@ -342,6 +369,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:
Expand Down Expand Up @@ -742,6 +771,40 @@ 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.
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:
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
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.
Expand All @@ -757,23 +820,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 "")
Expand Down Expand Up @@ -959,7 +1025,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."
Expand Down Expand Up @@ -990,8 +1056,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()
Expand Down Expand Up @@ -1198,7 +1264,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}")

Expand Down Expand Up @@ -1276,14 +1342,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
Expand All @@ -1295,7 +1365,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',
Expand Down Expand Up @@ -1493,14 +1563,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:
Expand Down Expand Up @@ -2407,7 +2477,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
Expand All @@ -2433,8 +2503,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
# ==============================================================================
Expand Down Expand Up @@ -2724,7 +2794,21 @@ 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. 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)
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)
Expand Down Expand Up @@ -2873,10 +2957,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([
Expand Down Expand Up @@ -3292,7 +3378,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
Expand Down Expand Up @@ -3368,7 +3455,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
Expand Down Expand Up @@ -3408,17 +3496,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")


Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down