fpl-mcp-server 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/state.py ADDED
@@ -0,0 +1,443 @@
1
+ from difflib import SequenceMatcher
2
+ import logging
3
+
4
+ from .cache import cache_manager
5
+ from .client import FPLClient
6
+ from .config import settings
7
+ from .models import BootstrapData, ElementData, EventData, FixtureData
8
+
9
+ logger = logging.getLogger("fpl_state")
10
+
11
+
12
+ class SessionStore:
13
+ def __init__(self):
14
+ # Bootstrap data loaded on-demand from API
15
+ self.bootstrap_data: BootstrapData | None = None
16
+
17
+ # Fixtures data loaded on-demand from API
18
+ self.fixtures_data: list[FixtureData] | None = None
19
+
20
+ # Player name lookup maps for intelligent searching
21
+ # Maps normalized name -> list of player IDs (handles duplicates)
22
+ self.player_name_map: dict[str, list[int]] = {}
23
+ self.player_id_map: dict[int, ElementData] = {}
24
+
25
+ def _normalize_name(self, name: str) -> str:
26
+ """Normalize a name for matching: lowercase, remove extra spaces"""
27
+ return " ".join(name.lower().strip().split())
28
+
29
+ async def ensure_bootstrap_data(self, client: FPLClient):
30
+ """Ensure bootstrap data is loaded, fetching from API if needed or expired"""
31
+ # Check cache first
32
+ cached_data = cache_manager.get("bootstrap_data")
33
+
34
+ if cached_data is not None:
35
+ self.bootstrap_data = cached_data
36
+ logger.debug("Using cached bootstrap data")
37
+ return
38
+
39
+ # Cache miss or expired - fetch from API
40
+ try:
41
+ logger.info("Fetching bootstrap data from API...")
42
+ raw_data = await client.get_bootstrap_data()
43
+ self.bootstrap_data = BootstrapData(**raw_data)
44
+
45
+ # Cache with configured TTL
46
+ cache_manager.set("bootstrap_data", self.bootstrap_data, settings.bootstrap_cache_ttl)
47
+
48
+ self._build_player_indices()
49
+ logger.info(
50
+ f"Loaded {len(self.bootstrap_data.elements)} players from API "
51
+ f"(cached for {settings.bootstrap_cache_ttl}s)"
52
+ )
53
+ except Exception as e:
54
+ logger.error(f"Failed to load bootstrap data: {e}")
55
+ raise
56
+
57
+ async def ensure_fixtures_data(self, client: FPLClient):
58
+ """Ensure fixtures data is loaded, fetching from API if needed or expired"""
59
+ cached_data = cache_manager.get("fixtures_data")
60
+
61
+ if cached_data is None:
62
+ try:
63
+ logger.info("Fetching fixtures data from API...")
64
+ raw_data = await client.get_fixtures()
65
+ self.fixtures_data = [FixtureData(**fixture) for fixture in raw_data]
66
+
67
+ # Cache with configured TTL
68
+ cache_manager.set("fixtures_data", self.fixtures_data, settings.fixtures_cache_ttl)
69
+
70
+ logger.info(f"Loaded {len(self.fixtures_data)} fixtures from API")
71
+ except Exception as e:
72
+ logger.error(f"Failed to load fixtures data: {e}")
73
+ raise
74
+
75
+ def _build_player_indices(self):
76
+ """Build player name and ID indices from bootstrap data"""
77
+ bootstrap_data = self.bootstrap_data
78
+ if not bootstrap_data:
79
+ return
80
+
81
+ # Enrich elements with team names for faster lookups
82
+ team_map = {t.id: t.name for t in bootstrap_data.teams}
83
+ position_map = {t.id: t.singular_name_short for t in bootstrap_data.element_types}
84
+
85
+ # Build player name index and ID map
86
+ self.player_name_map.clear()
87
+ self.player_id_map.clear()
88
+
89
+ for element in bootstrap_data.elements:
90
+ # Add team_name and position to each element
91
+ element.team_name = team_map.get(element.team, "Unknown")
92
+ element.position = position_map.get(element.element_type, "UNK")
93
+
94
+ # Store in ID map
95
+ self.player_id_map[element.id] = element
96
+
97
+ # Build name index with multiple keys for flexible matching
98
+ # 1. Web name (most common)
99
+ web_key = self._normalize_name(element.web_name)
100
+ if web_key not in self.player_name_map:
101
+ self.player_name_map[web_key] = []
102
+ self.player_name_map[web_key].append(element.id)
103
+
104
+ # 2. Full name (first + second)
105
+ full_key = self._normalize_name(f"{element.first_name} {element.second_name}")
106
+ if full_key not in self.player_name_map:
107
+ self.player_name_map[full_key] = []
108
+ if element.id not in self.player_name_map[full_key]:
109
+ self.player_name_map[full_key].append(element.id)
110
+
111
+ # 3. Second name only (surname)
112
+ surname_key = self._normalize_name(element.second_name)
113
+ if surname_key not in self.player_name_map:
114
+ self.player_name_map[surname_key] = []
115
+ if element.id not in self.player_name_map[surname_key]:
116
+ self.player_name_map[surname_key].append(element.id)
117
+
118
+ # 4. First name + web name (for cases like "Mohamed Salah")
119
+ if element.first_name and element.web_name != element.second_name:
120
+ first_web_key = self._normalize_name(f"{element.first_name} {element.web_name}")
121
+ if first_web_key not in self.player_name_map:
122
+ self.player_name_map[first_web_key] = []
123
+ if element.id not in self.player_name_map[first_web_key]:
124
+ self.player_name_map[first_web_key].append(element.id)
125
+
126
+ if bootstrap_data:
127
+ logger.info(
128
+ f"Built player indices: {len(bootstrap_data.elements)} players, "
129
+ f"{len(bootstrap_data.teams)} teams, "
130
+ f"{len(bootstrap_data.events)} gameweeks. "
131
+ f"Name index has {len(self.player_name_map)} keys."
132
+ )
133
+
134
+ def get_team_by_id(self, team_id: int) -> dict | None:
135
+ """Get team information by ID"""
136
+ if not self.bootstrap_data:
137
+ return None
138
+
139
+ team = next((t for t in self.bootstrap_data.teams if t.id == team_id), None)
140
+ if not team:
141
+ return None
142
+
143
+ return {
144
+ "id": team.id,
145
+ "name": team.name,
146
+ "short_name": team.short_name,
147
+ "strength": getattr(team, "strength", None),
148
+ "strength_overall_home": getattr(team, "strength_overall_home", None),
149
+ "strength_overall_away": getattr(team, "strength_overall_away", None),
150
+ "strength_attack_home": getattr(team, "strength_attack_home", None),
151
+ "strength_attack_away": getattr(team, "strength_attack_away", None),
152
+ "strength_defence_home": getattr(team, "strength_defence_home", None),
153
+ "strength_defence_away": getattr(team, "strength_defence_away", None),
154
+ }
155
+
156
+ def get_all_teams(self) -> list:
157
+ """Get all teams with their information"""
158
+ if not self.bootstrap_data:
159
+ return []
160
+
161
+ return [
162
+ {
163
+ "id": t.id,
164
+ "name": t.name,
165
+ "short_name": t.short_name,
166
+ "strength": getattr(t, "strength", None),
167
+ "strength_overall_home": getattr(t, "strength_overall_home", None),
168
+ "strength_overall_away": getattr(t, "strength_overall_away", None),
169
+ }
170
+ for t in self.bootstrap_data.teams
171
+ ]
172
+
173
+ def find_players_by_name(
174
+ self, name_query: str, fuzzy: bool = True
175
+ ) -> list[tuple[ElementData, float]]:
176
+ """
177
+ Find players by name with intelligent matching.
178
+ Returns list of (player, similarity_score) tuples sorted by relevance.
179
+
180
+ Args:
181
+ name_query: The name to search for
182
+ fuzzy: Whether to use fuzzy matching for close matches
183
+
184
+ Returns:
185
+ List of (ElementData, similarity_score) tuples, sorted by score descending
186
+ """
187
+ if not self.bootstrap_data:
188
+ return []
189
+
190
+ normalized_query = self._normalize_name(name_query)
191
+ results: dict[int, float] = {} # player_id -> best similarity score
192
+
193
+ # 1. Exact match
194
+ if normalized_query in self.player_name_map:
195
+ for player_id in self.player_name_map[normalized_query]:
196
+ results[player_id] = 1.0
197
+
198
+ # 2. Substring match (contains)
199
+ if not results:
200
+ for name_key, player_ids in self.player_name_map.items():
201
+ if normalized_query in name_key or name_key in normalized_query:
202
+ # Calculate similarity based on length ratio
203
+ similarity = min(len(normalized_query), len(name_key)) / max(
204
+ len(normalized_query), len(name_key)
205
+ )
206
+ for player_id in player_ids:
207
+ if player_id not in results or similarity > results[player_id]:
208
+ results[player_id] = similarity * 0.9 # Slightly lower than exact
209
+
210
+ # 3. Fuzzy matching (if enabled and no good matches yet)
211
+ if fuzzy and (not results or max(results.values()) < 0.7):
212
+ for name_key, player_ids in self.player_name_map.items():
213
+ similarity = SequenceMatcher(None, normalized_query, name_key).ratio()
214
+ if similarity >= 0.6: # Threshold for fuzzy matches
215
+ for player_id in player_ids:
216
+ if player_id not in results or similarity > results[player_id]:
217
+ results[player_id] = similarity * 0.8 # Lower than substring
218
+
219
+ # Convert to list of tuples and sort by score
220
+ player_matches = [
221
+ (self.player_id_map[player_id], score) for player_id, score in results.items()
222
+ ]
223
+ player_matches.sort(key=lambda x: x[1], reverse=True)
224
+
225
+ return player_matches
226
+
227
+ def get_player_by_id(self, player_id: int) -> ElementData | None:
228
+ """Get a player by their ID"""
229
+ return self.player_id_map.get(player_id)
230
+
231
+ def get_current_gameweek(self) -> EventData | None:
232
+ """Get the current gameweek event"""
233
+ if not self.bootstrap_data or not self.bootstrap_data.events:
234
+ return None
235
+
236
+ # First check for is_current flag
237
+ for event in self.bootstrap_data.events:
238
+ if event.is_current:
239
+ return event
240
+
241
+ # Fallback to is_next if current deadline has passed
242
+ for event in self.bootstrap_data.events:
243
+ if event.is_next:
244
+ return event
245
+
246
+ # Last resort: first unfinished gameweek
247
+ for event in self.bootstrap_data.events:
248
+ if not event.finished:
249
+ return event
250
+
251
+ return None
252
+
253
+ def rehydrate_player_names(self, element_ids: list[int]) -> dict[int, dict]:
254
+ """
255
+ Rehydrate player element IDs to full player information.
256
+
257
+ Args:
258
+ element_ids: List of player element IDs
259
+
260
+ Returns:
261
+ Dictionary mapping element_id -> player info dict
262
+ """
263
+ result = {}
264
+ for element_id in element_ids:
265
+ player = self.get_player_by_id(element_id)
266
+ if player:
267
+ result[element_id] = {
268
+ "id": player.id,
269
+ "web_name": player.web_name,
270
+ "full_name": f"{player.first_name} {player.second_name}",
271
+ "team": player.team_name,
272
+ "position": player.position,
273
+ "price": player.now_cost / 10,
274
+ "form": player.form,
275
+ "points_per_game": player.points_per_game,
276
+ "total_points": getattr(player, "total_points", 0),
277
+ "status": player.status,
278
+ "news": player.news,
279
+ }
280
+ return result
281
+
282
+ def get_player_name(self, element_id: int) -> str:
283
+ """
284
+ Get a player's web name by their element ID.
285
+
286
+ Args:
287
+ element_id: The player's element ID
288
+
289
+ Returns:
290
+ Player's web name or "Unknown Player (ID: {element_id})"
291
+ """
292
+ player = self.get_player_by_id(element_id)
293
+ if player:
294
+ return player.web_name
295
+ return f"Unknown Player (ID: {element_id})"
296
+
297
+ async def find_manager_by_name(
298
+ self, client: FPLClient, league_id: int, manager_name: str
299
+ ) -> dict | None:
300
+ """
301
+ Find a manager by name in a league's standings.
302
+
303
+ Args:
304
+ client: The authenticated FPL client
305
+ league_id: The league ID to search in
306
+ manager_name: The manager's name to find
307
+
308
+ Returns:
309
+ Manager dict with 'entry', 'entry_name', 'player_name' if found, None otherwise
310
+ """
311
+ try:
312
+ standings_data = await client.get_league_standings(league_id)
313
+ results = standings_data.get("standings", {}).get("results", [])
314
+
315
+ # Normalize search name
316
+ normalized_search = self._normalize_name(manager_name)
317
+
318
+ # Search through standings
319
+ for result in results:
320
+ # Try matching against player_name (manager name)
321
+ if self._normalize_name(result.get("player_name", "")) == normalized_search:
322
+ return {
323
+ "entry": result.get("entry"),
324
+ "entry_name": result.get("entry_name"),
325
+ "player_name": result.get("player_name"),
326
+ }
327
+
328
+ # Try matching against entry_name (team name)
329
+ if self._normalize_name(result.get("entry_name", "")) == normalized_search:
330
+ return {
331
+ "entry": result.get("entry"),
332
+ "entry_name": result.get("entry_name"),
333
+ "player_name": result.get("player_name"),
334
+ }
335
+
336
+ # Try substring matches
337
+ for result in results:
338
+ player_norm = self._normalize_name(result.get("player_name", ""))
339
+ entry_norm = self._normalize_name(result.get("entry_name", ""))
340
+
341
+ if (
342
+ normalized_search in player_norm
343
+ or player_norm in normalized_search
344
+ or normalized_search in entry_norm
345
+ or entry_norm in normalized_search
346
+ ):
347
+ return {
348
+ "entry": result.get("entry"),
349
+ "entry_name": result.get("entry_name"),
350
+ "player_name": result.get("player_name"),
351
+ }
352
+
353
+ return None
354
+
355
+ except Exception as e:
356
+ logger.error(f"Error finding manager by name: {e}")
357
+ return None
358
+
359
+ def enrich_gameweek_history(self, history: list[dict]) -> list[dict]:
360
+ """
361
+ Enrich gameweek history data with friendly names for teams.
362
+ Adds 'opponent_team_name' and 'opponent_team_short' fields.
363
+
364
+ Args:
365
+ history: List of gameweek history dicts from element-summary
366
+
367
+ Returns:
368
+ Enriched history with team names added
369
+ """
370
+ if not self.bootstrap_data:
371
+ return history
372
+
373
+ enriched = []
374
+ for gw in history:
375
+ enriched_gw = gw.copy()
376
+
377
+ # Add opponent team names
378
+ opponent_id = gw.get("opponent_team")
379
+ if opponent_id:
380
+ opponent = self.get_team_by_id(opponent_id)
381
+ if opponent:
382
+ enriched_gw["opponent_team_name"] = opponent["name"]
383
+ enriched_gw["opponent_team_short"] = opponent["short_name"]
384
+
385
+ enriched.append(enriched_gw)
386
+
387
+ return enriched
388
+
389
+ def enrich_fixtures(self, fixtures: list) -> list:
390
+ """
391
+ Enrich fixture data with friendly team names.
392
+ Adds 'team_h_name', 'team_h_short', 'team_a_name', 'team_a_short' fields.
393
+
394
+ Args:
395
+ fixtures: List of FixtureData objects or fixture dicts
396
+
397
+ Returns:
398
+ List of enriched fixture dicts
399
+ """
400
+ if not self.bootstrap_data:
401
+ return fixtures
402
+
403
+ enriched = []
404
+ for fixture in fixtures:
405
+ # Convert to dict if it's a FixtureData object
406
+ if hasattr(fixture, "model_dump"):
407
+ fixture_dict = fixture.model_dump()
408
+ elif hasattr(fixture, "__dict__"):
409
+ fixture_dict = fixture.__dict__.copy()
410
+ else:
411
+ fixture_dict = fixture.copy() if isinstance(fixture, dict) else {}
412
+
413
+ # Add home team names
414
+ team_h_id = (
415
+ fixture_dict.get("team_h")
416
+ if isinstance(fixture_dict, dict)
417
+ else getattr(fixture, "team_h", None)
418
+ )
419
+ if team_h_id:
420
+ team_h = self.get_team_by_id(team_h_id)
421
+ if team_h:
422
+ fixture_dict["team_h_name"] = team_h["name"]
423
+ fixture_dict["team_h_short"] = team_h["short_name"]
424
+
425
+ # Add away team names
426
+ team_a_id = (
427
+ fixture_dict.get("team_a")
428
+ if isinstance(fixture_dict, dict)
429
+ else getattr(fixture, "team_a", None)
430
+ )
431
+ if team_a_id:
432
+ team_a = self.get_team_by_id(team_a_id)
433
+ if team_a:
434
+ fixture_dict["team_a_name"] = team_a["name"]
435
+ fixture_dict["team_a_short"] = team_a["short_name"]
436
+
437
+ enriched.append(fixture_dict)
438
+
439
+ return enriched
440
+
441
+
442
+ # Global Instance
443
+ store = SessionStore()
src/tools/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """FPL MCP Tools - Topic-based modules."""
2
+ # ruff: noqa: E402
3
+
4
+ from mcp.server.fastmcp import FastMCP
5
+
6
+ # Create shared MCP instance following Python naming convention: {service}_mcp
7
+ mcp = FastMCP("fpl_mcp")
8
+
9
+ # Import all tool modules (this registers tools with mcp) # noqa: E402
10
+ # Import resources and prompts (this registers them with mcp)
11
+ from .. import (
12
+ prompts, # noqa: F401
13
+ resources, # noqa: F401
14
+ )
15
+ from . import (
16
+ fixtures, # noqa: F401
17
+ gameweeks, # noqa: F401
18
+ leagues, # noqa: F401
19
+ players, # noqa: F401
20
+ teams, # noqa: F401
21
+ transfers, # noqa: F401
22
+ )
23
+
24
+ # Re-export mcp instance
25
+ __all__ = ["mcp"]
src/tools/fixtures.py ADDED
@@ -0,0 +1,162 @@
1
+ """FPL Fixtures Tools - MCP tools for fixture information and analysis."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ..client import FPLClient
6
+ from ..constants import CHARACTER_LIMIT
7
+ from ..state import store
8
+ from ..utils import (
9
+ ResponseFormat,
10
+ check_and_truncate,
11
+ format_json_response,
12
+ handle_api_error,
13
+ )
14
+
15
+ # Import shared mcp instance
16
+ from . import mcp
17
+
18
+ # =============================================================================
19
+ # Pydantic Input Models
20
+ # =============================================================================
21
+
22
+
23
+ class GetFixturesForGameweekInput(BaseModel):
24
+ """Input model for getting fixtures for a gameweek."""
25
+
26
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
27
+
28
+ gameweek: int = Field(
29
+ ..., description="Gameweek number to get fixtures for (1-38)", ge=1, le=38
30
+ )
31
+ response_format: ResponseFormat = Field(
32
+ default=ResponseFormat.MARKDOWN,
33
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
34
+ )
35
+
36
+
37
+ # =============================================================================
38
+ # Helper Functions
39
+ # =============================================================================
40
+
41
+
42
+ async def _create_client():
43
+ """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
44
+ client = FPLClient(store=store)
45
+ await store.ensure_bootstrap_data(client)
46
+ await store.ensure_fixtures_data(client)
47
+ return client
48
+
49
+
50
+ # =============================================================================
51
+ # MCP Tools
52
+ # =============================================================================
53
+
54
+
55
+ @mcp.tool(
56
+ name="fpl_get_fixtures_for_gameweek",
57
+ annotations={
58
+ "title": "Get FPL Fixtures for Gameweek",
59
+ "readOnlyHint": True,
60
+ "destructiveHint": False,
61
+ "idempotentHint": True,
62
+ "openWorldHint": True,
63
+ },
64
+ )
65
+ async def fpl_get_fixtures_for_gameweek(params: GetFixturesForGameweekInput) -> str:
66
+ """
67
+ Get all Premier League fixtures for a specific gameweek.
68
+
69
+ Returns complete fixture list with team names, kickoff times, scores (if finished),
70
+ and difficulty ratings for both teams. Useful for planning transfers based on
71
+ fixture difficulty and understanding upcoming matches.
72
+
73
+ Args:
74
+ params (GetFixturesForGameweekInput): Validated input parameters containing:
75
+ - gameweek (int): Gameweek number between 1-38
76
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
77
+
78
+ Returns:
79
+ str: Complete fixture list with times and difficulty ratings
80
+
81
+ Examples:
82
+ - View GW10 fixtures: gameweek=10
83
+ - Check upcoming matches: gameweek=15
84
+ - Get as JSON: gameweek=20, response_format="json"
85
+
86
+ Error Handling:
87
+ - Returns error if gameweek number invalid (must be 1-38)
88
+ - Returns error if no fixtures found for gameweek
89
+ - Returns formatted error message if data unavailable
90
+ """
91
+ try:
92
+ await _create_client()
93
+ if not store.fixtures_data:
94
+ return "Error: Fixtures data not available. Please try again later."
95
+
96
+ gw_fixtures = [f for f in store.fixtures_data if f.event == params.gameweek]
97
+
98
+ if not gw_fixtures:
99
+ return f"No fixtures found for gameweek {params.gameweek}. This gameweek may not exist or fixtures may not be scheduled yet."
100
+
101
+ # Enrich fixtures with team names
102
+ gw_fixtures_enriched = store.enrich_fixtures(gw_fixtures)
103
+ gw_fixtures_sorted = sorted(gw_fixtures_enriched, key=lambda x: x.get("kickoff_time") or "")
104
+
105
+ if params.response_format == ResponseFormat.JSON:
106
+ result = {
107
+ "gameweek": params.gameweek,
108
+ "fixture_count": len(gw_fixtures_sorted),
109
+ "fixtures": [
110
+ {
111
+ "home_team": fixture.get("team_h_name"),
112
+ "home_team_short": fixture.get("team_h_short"),
113
+ "away_team": fixture.get("team_a_name"),
114
+ "away_team_short": fixture.get("team_a_short"),
115
+ "kickoff_time": fixture.get("kickoff_time"),
116
+ "finished": fixture.get("finished"),
117
+ "home_score": fixture.get("team_h_score")
118
+ if fixture.get("finished")
119
+ else None,
120
+ "away_score": fixture.get("team_a_score")
121
+ if fixture.get("finished")
122
+ else None,
123
+ "home_difficulty": fixture.get("team_h_difficulty"),
124
+ "away_difficulty": fixture.get("team_a_difficulty"),
125
+ }
126
+ for fixture in gw_fixtures_sorted
127
+ ],
128
+ }
129
+ return format_json_response(result)
130
+ else:
131
+ output = [
132
+ f"**Gameweek {params.gameweek} Fixtures ({len(gw_fixtures_enriched)} matches)**\n"
133
+ ]
134
+
135
+ for fixture in gw_fixtures_sorted:
136
+ home_name = fixture.get("team_h_short", "Unknown")
137
+ away_name = fixture.get("team_a_short", "Unknown")
138
+
139
+ status = "✓" if fixture.get("finished") else "○"
140
+ score = (
141
+ f"{fixture.get('team_h_score')}-{fixture.get('team_a_score')}"
142
+ if fixture.get("finished")
143
+ else "vs"
144
+ )
145
+ kickoff = (
146
+ fixture.get("kickoff_time", "")[:16].replace("T", " ")
147
+ if fixture.get("kickoff_time")
148
+ else "TBD"
149
+ )
150
+
151
+ output.append(
152
+ f"{status} {home_name} {score} {away_name} | "
153
+ f"Kickoff: {kickoff} | "
154
+ f"Difficulty: H:{fixture.get('team_h_difficulty')} A:{fixture.get('team_a_difficulty')}"
155
+ )
156
+
157
+ result = "\n".join(output)
158
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
159
+ return truncated
160
+
161
+ except Exception as e:
162
+ return handle_api_error(e)