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/tools/leagues.py ADDED
@@ -0,0 +1,590 @@
1
+ """FPL League Tools - MCP tools for league standings and manager analysis."""
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
4
+
5
+ from ..client import FPLClient
6
+ from ..constants import CHARACTER_LIMIT
7
+ from ..formatting import format_manager_squad
8
+ from ..state import store
9
+ from ..utils import (
10
+ ResponseFormat,
11
+ check_and_truncate,
12
+ format_json_response,
13
+ handle_api_error,
14
+ )
15
+
16
+ # Import shared mcp instance
17
+ from . import mcp
18
+
19
+ # =============================================================================
20
+ # Pydantic Input Models
21
+ # =============================================================================
22
+
23
+
24
+ class GetLeagueStandingsInput(BaseModel):
25
+ """Input model for getting league standings."""
26
+
27
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
28
+
29
+ league_id: int = Field(
30
+ ...,
31
+ description="League ID from FPL URL (e.g., for /leagues/12345/standings/ use 12345)",
32
+ ge=1,
33
+ )
34
+ page: int = Field(default=1, description="Page number for pagination (default: 1)", ge=1)
35
+ response_format: ResponseFormat = Field(
36
+ default=ResponseFormat.MARKDOWN,
37
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
38
+ )
39
+
40
+
41
+ class GetManagerGameweekTeamInput(BaseModel):
42
+ """Input model for getting manager's team for a gameweek."""
43
+
44
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
45
+
46
+ manager_name: str = Field(
47
+ ...,
48
+ description="Manager's name or team name (e.g., 'John Smith', 'FC Warriors')",
49
+ min_length=2,
50
+ max_length=100,
51
+ )
52
+ league_id: int = Field(..., description="League ID where the manager is found", ge=1)
53
+ gameweek: int = Field(..., description="Gameweek number (1-38)", ge=1, le=38)
54
+
55
+
56
+ class CompareManagersInput(BaseModel):
57
+ """Input model for comparing managers."""
58
+
59
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
60
+
61
+ manager_names: list[str] = Field(
62
+ ...,
63
+ description="List of 2-4 manager names to compare (e.g., ['John', 'Sarah', 'Mike'])",
64
+ min_length=2,
65
+ max_length=4,
66
+ )
67
+ league_id: int = Field(..., description="League ID where managers are found", ge=1)
68
+ gameweek: int = Field(..., description="Gameweek number to compare (1-38)", ge=1, le=38)
69
+
70
+ @field_validator("manager_names")
71
+ @classmethod
72
+ def validate_manager_names(cls, v: list[str]) -> list[str]:
73
+ if len(v) < 2:
74
+ raise ValueError("Must provide at least 2 managers to compare")
75
+ if len(v) > 4:
76
+ raise ValueError("Cannot compare more than 4 managers at once")
77
+ return v
78
+
79
+
80
+ class GetManagerSquadInput(BaseModel):
81
+ """Input model for getting manager's squad by team ID."""
82
+
83
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
84
+
85
+ team_id: int = Field(
86
+ ...,
87
+ description="Manager's team ID (entry ID)",
88
+ ge=1,
89
+ )
90
+ gameweek: int | None = Field(
91
+ default=None,
92
+ description="Gameweek number (1-38). If not provided, uses current gameweek",
93
+ ge=1,
94
+ le=38,
95
+ )
96
+ response_format: ResponseFormat = Field(
97
+ default=ResponseFormat.MARKDOWN,
98
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
99
+ )
100
+
101
+
102
+ # =============================================================================
103
+ # Helper Functions
104
+ # =============================================================================
105
+
106
+
107
+ async def _create_client():
108
+ """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
109
+ client = FPLClient(store=store)
110
+ await store.ensure_bootstrap_data(client)
111
+ await store.ensure_fixtures_data(client)
112
+ return client
113
+
114
+
115
+ # =============================================================================
116
+ # MCP Tools
117
+ # =============================================================================
118
+
119
+
120
+ @mcp.tool(
121
+ name="fpl_get_league_standings",
122
+ annotations={
123
+ "title": "Get FPL League Standings",
124
+ "readOnlyHint": True,
125
+ "destructiveHint": False,
126
+ "idempotentHint": True,
127
+ "openWorldHint": True,
128
+ },
129
+ )
130
+ async def fpl_get_league_standings(params: GetLeagueStandingsInput) -> str:
131
+ """
132
+ Get standings for a specific Fantasy Premier League league.
133
+
134
+ Returns manager rankings, points, team names, and rank changes within the league.
135
+ Supports pagination for large leagues. Find league ID in the FPL website URL
136
+ (e.g., for /leagues/12345/standings/ use league_id=12345).
137
+
138
+ Args:
139
+ params (GetLeagueStandingsInput): Validated input parameters containing:
140
+ - league_id (int): League ID from FPL URL
141
+ - page (int): Page number for pagination (default: 1)
142
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
143
+
144
+ Returns:
145
+ str: League standings with rankings and pagination info
146
+
147
+ Examples:
148
+ - View league: league_id=12345
149
+ - Next page: league_id=12345, page=2
150
+ - Get as JSON: league_id=12345, response_format="json"
151
+
152
+ Error Handling:
153
+ - Returns error if league not found
154
+ - Returns error if page number invalid
155
+ - Returns formatted error message if API fails
156
+ """
157
+ try:
158
+ client = await _create_client()
159
+ standings_data = await client.get_league_standings(
160
+ league_id=params.league_id, page_standings=params.page
161
+ )
162
+
163
+ league_data = standings_data.get("league", {})
164
+ standings = standings_data.get("standings", {})
165
+ results = standings.get("results", [])
166
+
167
+ if not results:
168
+ return f"No standings found for league ID {params.league_id}. The league may not exist or you may not have access."
169
+
170
+ if params.response_format == ResponseFormat.JSON:
171
+ result = {
172
+ "league": {
173
+ "id": params.league_id,
174
+ "name": league_data.get("name", f"League {params.league_id}"),
175
+ },
176
+ "pagination": {
177
+ "page": params.page,
178
+ "has_next": standings.get("has_next", False),
179
+ },
180
+ "standings": [
181
+ {
182
+ "rank": entry["rank"],
183
+ "last_rank": entry["last_rank"],
184
+ "rank_change": entry["rank"] - entry["last_rank"],
185
+ "team_id": entry["entry"],
186
+ "entry_name": entry["entry_name"],
187
+ "player_name": entry["player_name"],
188
+ "gameweek_points": entry["event_total"],
189
+ "total_points": entry["total"],
190
+ }
191
+ for entry in results
192
+ ],
193
+ }
194
+ return format_json_response(result)
195
+ else:
196
+ output = [
197
+ f"**{league_data.get('name', f'League {params.league_id}')}**",
198
+ f"Page: {params.page}",
199
+ "",
200
+ "**Standings:**",
201
+ "",
202
+ ]
203
+
204
+ for entry in results:
205
+ rank_change = entry["rank"] - entry["last_rank"]
206
+ rank_indicator = "↑" if rank_change < 0 else "↓" if rank_change > 0 else "="
207
+
208
+ output.append(
209
+ f"{entry['rank']:3d}. {rank_indicator} {entry['entry_name']:30s} | "
210
+ f"{entry['player_name']:20s} | "
211
+ f"Team ID: {entry['entry']:7d} | "
212
+ f"GW: {entry['event_total']:3d} | Total: {entry['total']:4d}"
213
+ )
214
+
215
+ if standings.get("has_next"):
216
+ output.append(
217
+ f"\nšŸ“„ More entries available. Use page={params.page + 1} to see the next page."
218
+ )
219
+
220
+ result = "\n".join(output)
221
+ truncated, _ = check_and_truncate(
222
+ result, CHARACTER_LIMIT, f"Use page={params.page + 1} for more results"
223
+ )
224
+ return truncated
225
+
226
+ except Exception as e:
227
+ return handle_api_error(e)
228
+
229
+
230
+ @mcp.tool(
231
+ name="fpl_get_manager_gameweek_team",
232
+ annotations={
233
+ "title": "Get Manager's FPL Gameweek Team",
234
+ "readOnlyHint": True,
235
+ "destructiveHint": False,
236
+ "idempotentHint": True,
237
+ "openWorldHint": True,
238
+ },
239
+ )
240
+ async def fpl_get_manager_gameweek_team(params: GetManagerGameweekTeamInput) -> str:
241
+ """
242
+ Get a manager's team selection for a specific gameweek.
243
+
244
+ Shows the 15 players picked, captain/vice-captain choices, formation, points scored,
245
+ transfers made, and automatic substitutions. Find manager by their name or team name
246
+ within a specific league.
247
+
248
+ Args:
249
+ params (GetManagerGameweekTeamInput): Validated input parameters containing:
250
+ - manager_name (str): Manager's name or team name
251
+ - league_id (int): League ID where manager is found
252
+ - gameweek (int): Gameweek number (1-38)
253
+
254
+ Returns:
255
+ str: Complete team sheet with starting XI, bench, and statistics
256
+
257
+ Examples:
258
+ - View team: manager_name="John Smith", league_id=12345, gameweek=13
259
+ - Check transfers: manager_name="FC Warriors", league_id=12345, gameweek=15
260
+
261
+ Error Handling:
262
+ - Returns error if manager not found in league
263
+ - Returns helpful message suggesting correct name if ambiguous
264
+ - Returns formatted error message if API fails
265
+ """
266
+ try:
267
+ client = await _create_client()
268
+
269
+ # Find manager in league
270
+ manager_info = await store.find_manager_by_name(
271
+ client, params.league_id, params.manager_name
272
+ )
273
+ if not manager_info:
274
+ return f"Could not find manager '{params.manager_name}' in league ID {params.league_id}. Try using the exact name from the league standings."
275
+
276
+ manager_team_id = manager_info["entry"]
277
+
278
+ # Fetch gameweek picks from API
279
+ picks_data = await client.get_manager_gameweek_picks(manager_team_id, params.gameweek)
280
+
281
+ picks = picks_data.get("picks", [])
282
+ entry_history = picks_data.get("entry_history", {})
283
+ auto_subs = picks_data.get("automatic_subs", [])
284
+
285
+ if not picks:
286
+ return f"No team data found for {manager_info['player_name']} in gameweek {params.gameweek}. The gameweek may not have started yet."
287
+
288
+ # Rehydrate player names
289
+ element_ids = [pick["element"] for pick in picks]
290
+ players_info = store.rehydrate_player_names(element_ids)
291
+
292
+ result = format_manager_squad(
293
+ team_name=manager_info["entry_name"],
294
+ player_name=manager_info["player_name"],
295
+ team_id=manager_team_id,
296
+ gameweek=params.gameweek,
297
+ entry_history=entry_history,
298
+ picks=picks,
299
+ players_info=players_info,
300
+ active_chip=picks_data.get("active_chip"),
301
+ )
302
+
303
+ if auto_subs:
304
+ result += "\n\n**Automatic Substitutions:**"
305
+ for sub in auto_subs:
306
+ player_out = store.get_player_name(sub["element_out"])
307
+ player_in = store.get_player_name(sub["element_in"])
308
+ result += f"\nā”œā”€ {player_out} → {player_in}"
309
+
310
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
311
+ return truncated
312
+
313
+ except Exception as e:
314
+ return handle_api_error(e)
315
+
316
+
317
+ @mcp.tool(
318
+ name="fpl_compare_managers",
319
+ annotations={
320
+ "title": "Compare FPL Managers",
321
+ "readOnlyHint": True,
322
+ "destructiveHint": False,
323
+ "idempotentHint": True,
324
+ "openWorldHint": True,
325
+ },
326
+ )
327
+ async def fpl_compare_managers(params: CompareManagersInput) -> str:
328
+ """
329
+ Compare multiple managers' teams for a specific gameweek side-by-side.
330
+
331
+ Shows differences in player selection, captaincy choices, points scored, common
332
+ players, and unique differentials. Useful for mini-league rivalry analysis and
333
+ understanding what sets top managers apart.
334
+
335
+ Args:
336
+ params (CompareManagersInput): Validated input parameters containing:
337
+ - manager_names (list[str]): 2-4 manager names to compare
338
+ - league_id (int): League ID where managers are found
339
+ - gameweek (int): Gameweek number to compare (1-38)
340
+
341
+ Returns:
342
+ str: Side-by-side manager comparison with differentials
343
+
344
+ Examples:
345
+ - Compare 2 managers: manager_names=["John", "Sarah"], league_id=12345, gameweek=13
346
+ - Compare 4 managers: manager_names=["A", "B", "C", "D"], league_id=12345, gameweek=10
347
+
348
+ Error Handling:
349
+ - Returns error if fewer than 2 or more than 4 managers provided
350
+ - Returns error if any manager not found (with helpful message)
351
+ - Returns formatted error message if API fails
352
+ """
353
+ try:
354
+ client = await _create_client()
355
+
356
+ # Find all managers
357
+ manager_ids = []
358
+ manager_infos = []
359
+ for name in params.manager_names:
360
+ manager_info = await store.find_manager_by_name(client, params.league_id, name)
361
+ if not manager_info:
362
+ return f"Could not find manager '{name}' in league ID {params.league_id}. Try using the exact name from league standings."
363
+ manager_ids.append(manager_info["entry"])
364
+ manager_infos.append(manager_info)
365
+
366
+ # Fetch all teams
367
+ teams_data = []
368
+ for team_id in manager_ids:
369
+ picks_data = await client.get_manager_gameweek_picks(team_id, params.gameweek)
370
+ teams_data.append((team_id, picks_data))
371
+
372
+ output = [f"**Manager Comparison - Gameweek {params.gameweek}**\n"]
373
+
374
+ # Performance summary
375
+ output.append("**Performance Summary:**")
376
+ for i, (_team_id, data) in enumerate(teams_data):
377
+ entry_history = data.get("entry_history", {})
378
+ manager_info = manager_infos[i]
379
+ output.append(
380
+ f"ā”œā”€ {manager_info['player_name']} ({manager_info['entry_name']}): "
381
+ f"{entry_history.get('points', 0)}pts | "
382
+ f"Rank: {entry_history.get('overall_rank', 'N/A'):,} | "
383
+ f"Transfers: {entry_history.get('event_transfers', 0)} "
384
+ f"(-{entry_history.get('event_transfers_cost', 0)}pts)"
385
+ )
386
+
387
+ # Captain choices
388
+ output.append("\n**Captain Choices:**")
389
+ for i, (_team_id, data) in enumerate(teams_data):
390
+ picks = data.get("picks", [])
391
+ captain_pick = next((p for p in picks if p["is_captain"]), None)
392
+ if captain_pick:
393
+ captain_name = store.get_player_name(captain_pick["element"])
394
+ multiplier = captain_pick.get("multiplier", 2)
395
+ manager_info = manager_infos[i]
396
+ output.append(f"ā”œā”€ {manager_info['player_name']}: {captain_name} (x{multiplier})")
397
+
398
+ # Find common and unique players
399
+ all_players = {}
400
+ for _i, (team_id, data) in enumerate(teams_data):
401
+ picks = data.get("picks", [])
402
+ starting_xi = [p["element"] for p in picks if p["position"] <= 11]
403
+ all_players[team_id] = set(starting_xi)
404
+
405
+ common_players = set.intersection(*all_players.values()) if len(all_players) > 1 else set()
406
+
407
+ if common_players:
408
+ output.append(f"\n**Common Players ({len(common_players)}):**")
409
+ for element_id in list(common_players)[:10]:
410
+ player_name = store.get_player_name(element_id)
411
+ output.append(f"ā”œā”€ {player_name}")
412
+
413
+ # Unique players per team (differentials)
414
+ output.append("\n**Unique Selections (Differentials):**")
415
+ for i, team_id in enumerate(manager_ids):
416
+ other_teams = [t for t in manager_ids if t != team_id]
417
+ other_players = set()
418
+ for other_id in other_teams:
419
+ other_players.update(all_players.get(other_id, set()))
420
+
421
+ unique = all_players[team_id] - other_players
422
+ if unique:
423
+ manager_info = manager_infos[i]
424
+ output.append(f"\n{manager_info['player_name']} only:")
425
+ for element_id in list(unique)[:5]:
426
+ player_name = store.get_player_name(element_id)
427
+ output.append(f"ā”œā”€ {player_name}")
428
+
429
+ result = "\n".join(output)
430
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
431
+ return truncated
432
+
433
+ except Exception as e:
434
+ return handle_api_error(e)
435
+
436
+
437
+ @mcp.tool(
438
+ name="fpl_get_manager_squad",
439
+ annotations={
440
+ "title": "Get Manager's FPL Squad by Team ID",
441
+ "readOnlyHint": True,
442
+ "destructiveHint": False,
443
+ "idempotentHint": True,
444
+ "openWorldHint": True,
445
+ },
446
+ )
447
+ async def fpl_get_manager_squad(params: GetManagerSquadInput) -> str:
448
+ """
449
+ Get a manager's squad selection for a specific gameweek using their team ID.
450
+
451
+ Shows the 15 players picked, captain/vice-captain choices, formation, points scored,
452
+ transfers made, and automatic substitutions. This is a simpler alternative to
453
+ fpl_get_manager_gameweek_team that uses team ID directly instead of requiring
454
+ manager name and league ID lookup.
455
+
456
+ Args:
457
+ params (GetManagerSquadInput): Validated input parameters containing:
458
+ - team_id (int): Manager's team ID (entry ID)
459
+ - gameweek (int | None): Gameweek number (1-38), defaults to current GW
460
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
461
+
462
+ Returns:
463
+ str: Complete team sheet with starting XI, bench, and statistics
464
+
465
+ Examples:
466
+ - View current team: team_id=123456
467
+ - View specific gameweek: team_id=123456, gameweek=13
468
+ - Get as JSON: team_id=123456, gameweek=15, response_format="json"
469
+
470
+ Error Handling:
471
+ - Returns error if team ID not found
472
+ - Returns error if gameweek not started yet
473
+ - Returns formatted error message if API fails
474
+ """
475
+ try:
476
+ client = await _create_client()
477
+
478
+ # Fetch manager entry to get team name
479
+ entry_data = await client.get_manager_entry(params.team_id)
480
+ team_name = entry_data.get("name", "Unknown Team")
481
+ player_name = f"{entry_data.get('player_first_name', '')} {entry_data.get('player_last_name', '')}".strip()
482
+
483
+ # Determine which gameweek to use
484
+ gameweek = params.gameweek
485
+ if gameweek is None:
486
+ current_gw = store.get_current_gameweek()
487
+ if not current_gw:
488
+ return (
489
+ "Error: Could not determine current gameweek. Please specify a gameweek number."
490
+ )
491
+ gameweek = current_gw.id
492
+
493
+ # Fetch gameweek picks from API
494
+ picks_data = await client.get_manager_gameweek_picks(params.team_id, gameweek)
495
+
496
+ picks = picks_data.get("picks", [])
497
+ entry_history = picks_data.get("entry_history", {})
498
+ auto_subs = picks_data.get("automatic_subs", [])
499
+
500
+ if not picks:
501
+ return f"No team data found for team ID {params.team_id} in gameweek {gameweek}. The gameweek may not have started yet or the team ID may be invalid."
502
+
503
+ # Rehydrate player names
504
+ element_ids = [pick["element"] for pick in picks]
505
+ players_info = store.rehydrate_player_names(element_ids)
506
+
507
+ if params.response_format == ResponseFormat.JSON:
508
+ starting_xi = [p for p in picks if p["position"] <= 11]
509
+ bench = [p for p in picks if p["position"] > 11]
510
+
511
+ result = {
512
+ "team_id": params.team_id,
513
+ "team_name": team_name,
514
+ "player_name": player_name,
515
+ "gameweek": gameweek,
516
+ "stats": {
517
+ "points": entry_history.get("points", 0),
518
+ "total_points": entry_history.get("total_points", 0),
519
+ "overall_rank": entry_history.get("overall_rank"),
520
+ "team_value": entry_history.get("value", 0) / 10,
521
+ "bank": entry_history.get("bank", 0) / 10,
522
+ "transfers": entry_history.get("event_transfers", 0),
523
+ "transfer_cost": entry_history.get("event_transfers_cost", 0),
524
+ "points_on_bench": entry_history.get("points_on_bench", 0),
525
+ },
526
+ "active_chip": picks_data.get("active_chip"),
527
+ "starting_xi": [
528
+ {
529
+ "position": pick["position"],
530
+ "player_name": players_info.get(pick["element"], {}).get(
531
+ "web_name", "Unknown"
532
+ ),
533
+ "team": players_info.get(pick["element"], {}).get("team", "UNK"),
534
+ "player_position": players_info.get(pick["element"], {}).get(
535
+ "position", "UNK"
536
+ ),
537
+ "price": players_info.get(pick["element"], {}).get("price", 0),
538
+ "is_captain": pick["is_captain"],
539
+ "is_vice_captain": pick["is_vice_captain"],
540
+ "multiplier": pick["multiplier"],
541
+ }
542
+ for pick in starting_xi
543
+ ],
544
+ "bench": [
545
+ {
546
+ "position": pick["position"],
547
+ "player_name": players_info.get(pick["element"], {}).get(
548
+ "web_name", "Unknown"
549
+ ),
550
+ "team": players_info.get(pick["element"], {}).get("team", "UNK"),
551
+ "player_position": players_info.get(pick["element"], {}).get(
552
+ "position", "UNK"
553
+ ),
554
+ "price": players_info.get(pick["element"], {}).get("price", 0),
555
+ }
556
+ for pick in bench
557
+ ],
558
+ "automatic_subs": [
559
+ {
560
+ "player_out": store.get_player_name(sub["element_out"]),
561
+ "player_in": store.get_player_name(sub["element_in"]),
562
+ }
563
+ for sub in auto_subs
564
+ ],
565
+ }
566
+ return format_json_response(result)
567
+ else:
568
+ result = format_manager_squad(
569
+ team_name=team_name,
570
+ player_name=player_name,
571
+ team_id=params.team_id,
572
+ gameweek=gameweek,
573
+ entry_history=entry_history,
574
+ picks=picks,
575
+ players_info=players_info,
576
+ active_chip=picks_data.get("active_chip"),
577
+ )
578
+
579
+ if auto_subs:
580
+ result += "\n\n**Automatic Substitutions:**"
581
+ for sub in auto_subs:
582
+ player_out = store.get_player_name(sub["element_out"])
583
+ player_in = store.get_player_name(sub["element_in"])
584
+ result += f"\nā”œā”€ {player_out} → {player_in}"
585
+
586
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
587
+ return truncated
588
+
589
+ except Exception as e:
590
+ return handle_api_error(e)