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.
- fpl_mcp_server-0.1.3.dist-info/METADATA +137 -0
- fpl_mcp_server-0.1.3.dist-info/RECORD +33 -0
- fpl_mcp_server-0.1.3.dist-info/WHEEL +4 -0
- fpl_mcp_server-0.1.3.dist-info/entry_points.txt +2 -0
- fpl_mcp_server-0.1.3.dist-info/licenses/LICENSE +21 -0
- src/cache.py +162 -0
- src/client.py +273 -0
- src/config.py +33 -0
- src/constants.py +118 -0
- src/exceptions.py +114 -0
- src/formatting.py +299 -0
- src/main.py +41 -0
- src/models.py +526 -0
- src/prompts/__init__.py +18 -0
- src/prompts/chips.py +127 -0
- src/prompts/league_analysis.py +250 -0
- src/prompts/player_analysis.py +141 -0
- src/prompts/squad_analysis.py +136 -0
- src/prompts/team_analysis.py +121 -0
- src/prompts/transfers.py +167 -0
- src/rate_limiter.py +101 -0
- src/resources/__init__.py +13 -0
- src/resources/bootstrap.py +183 -0
- src/state.py +443 -0
- src/tools/__init__.py +25 -0
- src/tools/fixtures.py +162 -0
- src/tools/gameweeks.py +392 -0
- src/tools/leagues.py +590 -0
- src/tools/players.py +840 -0
- src/tools/teams.py +397 -0
- src/tools/transfers.py +629 -0
- src/utils.py +226 -0
- src/validators.py +290 -0
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)
|