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/players.py ADDED
@@ -0,0 +1,840 @@
1
+ """FPL Player Tools - MCP tools for player search, analysis, and comparison."""
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_player_details
8
+ from ..state import store
9
+ from ..utils import (
10
+ ResponseFormat,
11
+ check_and_truncate,
12
+ format_json_response,
13
+ format_player_price,
14
+ format_player_status,
15
+ format_status_indicator,
16
+ handle_api_error,
17
+ )
18
+
19
+ # Import shared mcp instance
20
+ from . import mcp
21
+
22
+ # =============================================================================
23
+ # Pydantic Input Models
24
+ # =============================================================================
25
+
26
+
27
+ class SearchPlayersInput(BaseModel):
28
+ """Input model for searching players by name."""
29
+
30
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
31
+
32
+ name_query: str = Field(
33
+ ...,
34
+ description="Player name to search for (e.g., 'Salah', 'Haaland', 'Son')",
35
+ min_length=2,
36
+ max_length=100,
37
+ )
38
+ limit: int | None = Field(
39
+ default=10, description="Maximum number of results to return", ge=1, le=50
40
+ )
41
+ response_format: ResponseFormat = Field(
42
+ default=ResponseFormat.MARKDOWN,
43
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
44
+ )
45
+
46
+
47
+ class SearchPlayersByTeamInput(BaseModel):
48
+ """Input model for searching players by team."""
49
+
50
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
51
+
52
+ team_name: str = Field(
53
+ ...,
54
+ description="Team name to search for (e.g., 'Arsenal', 'Liverpool', 'Man City')",
55
+ min_length=2,
56
+ max_length=50,
57
+ )
58
+ response_format: ResponseFormat = Field(
59
+ default=ResponseFormat.MARKDOWN,
60
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
61
+ )
62
+
63
+
64
+ class FindPlayerInput(BaseModel):
65
+ """Input for finding a player with fuzzy matching."""
66
+
67
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
68
+
69
+ player_name: str = Field(
70
+ ...,
71
+ description="Player name with fuzzy matching support (e.g., 'Haalnd' will match 'Haaland')",
72
+ min_length=2,
73
+ max_length=100,
74
+ )
75
+
76
+
77
+ class GetPlayerDetailsInput(BaseModel):
78
+ """Input model for getting player details."""
79
+
80
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
81
+
82
+ player_name: str = Field(
83
+ ...,
84
+ description="Player name (e.g., 'Mohamed Salah', 'Erling Haaland')",
85
+ min_length=2,
86
+ max_length=100,
87
+ )
88
+
89
+
90
+ class ComparePlayersInput(BaseModel):
91
+ """Input model for comparing multiple players."""
92
+
93
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
94
+
95
+ player_names: list[str] = Field(
96
+ ...,
97
+ description="List of 2-5 player names to compare (e.g., ['Salah', 'Saka', 'Palmer'])",
98
+ min_length=2,
99
+ max_length=5,
100
+ )
101
+
102
+ @field_validator("player_names")
103
+ @classmethod
104
+ def validate_player_names(cls, v: list[str]) -> list[str]:
105
+ if len(v) < 2:
106
+ raise ValueError("Must provide at least 2 players to compare")
107
+ if len(v) > 5:
108
+ raise ValueError("Cannot compare more than 5 players at once")
109
+ return v
110
+
111
+
112
+ class GetTopPlayersByMetricInput(BaseModel):
113
+ """Input model for getting top players by various metrics over last N gameweeks."""
114
+
115
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
116
+
117
+ num_gameweeks: int = Field(
118
+ default=5,
119
+ description="Number of recent gameweeks to analyze (1-10)",
120
+ ge=1,
121
+ le=10,
122
+ )
123
+ response_format: ResponseFormat = Field(
124
+ default=ResponseFormat.MARKDOWN,
125
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
126
+ )
127
+
128
+
129
+ # =============================================================================
130
+ # Helper Functions
131
+ # =============================================================================
132
+
133
+
134
+ async def _create_client():
135
+ """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
136
+ client = FPLClient(store=store)
137
+ # Ensure bootstrap data is loaded
138
+ await store.ensure_bootstrap_data(client)
139
+ # Ensure fixtures data is loaded
140
+ await store.ensure_fixtures_data(client)
141
+ return client
142
+
143
+
144
+ async def _aggregate_player_stats_from_fixtures(client: FPLClient, num_gameweeks: int) -> dict:
145
+ """
146
+ Aggregate player statistics from finished fixtures over the last N gameweeks.
147
+
148
+ Args:
149
+ client: FPL client instance
150
+ num_gameweeks: Number of recent gameweeks to analyze
151
+
152
+ Returns:
153
+ Dictionary with aggregated stats by metric and player info
154
+ """
155
+ import asyncio
156
+ from collections import defaultdict
157
+
158
+ # Get current gameweek
159
+ current_gw = store.get_current_gameweek()
160
+ if not current_gw:
161
+ return {}
162
+
163
+ current_gw_id = current_gw.id
164
+
165
+ # Determine gameweek range
166
+ start_gw = max(1, current_gw_id - num_gameweeks)
167
+ end_gw = current_gw_id - 1 # Only include finished gameweeks
168
+
169
+ # Filter fixtures to the target gameweek range and finished status
170
+ if not store.fixtures_data:
171
+ return {}
172
+
173
+ target_fixtures = [
174
+ f
175
+ for f in store.fixtures_data
176
+ if f.event is not None and start_gw <= f.event <= end_gw and f.finished
177
+ ]
178
+
179
+ if not target_fixtures:
180
+ return {
181
+ "gameweek_range": f"GW {start_gw}-{end_gw}",
182
+ "fixtures_analyzed": 0,
183
+ "error": "No finished fixtures found in the specified gameweek range",
184
+ }
185
+
186
+ # Aggregate stats by player
187
+ player_stats = defaultdict(
188
+ lambda: {
189
+ "goals_scored": 0,
190
+ "assists": 0,
191
+ "expected_goals": 0.0,
192
+ "expected_assists": 0.0,
193
+ "expected_goal_involvements": 0.0,
194
+ "defensive_contribution": 0,
195
+ "matches_played": 0,
196
+ }
197
+ )
198
+
199
+ # Fetch fixture stats concurrently (with a reasonable limit to avoid overwhelming the API)
200
+ async def fetch_fixture_stats(fixture_id: int):
201
+ try:
202
+ return await client.get_fixture_stats(fixture_id)
203
+ except Exception:
204
+ # Silently skip fixtures that fail to fetch
205
+ return None
206
+
207
+ # Fetch fixture stats in batches of 10 to avoid overwhelming the API
208
+ batch_size = 10
209
+ fixture_ids = [f.id for f in target_fixtures]
210
+
211
+ for i in range(0, len(fixture_ids), batch_size):
212
+ batch = fixture_ids[i : i + batch_size]
213
+ results = await asyncio.gather(
214
+ *[fetch_fixture_stats(fid) for fid in batch], return_exceptions=True
215
+ )
216
+
217
+ for fixture_stats in results:
218
+ if fixture_stats and isinstance(fixture_stats, dict):
219
+ # Process both home ('h') and away ('a') players
220
+ for team_key in ["h", "a"]:
221
+ for player_stat in fixture_stats.get(team_key, []):
222
+ element_id = player_stat.get("element")
223
+ if not element_id or player_stat.get("minutes", 0) == 0:
224
+ continue
225
+
226
+ stats = player_stats[element_id]
227
+ stats["goals_scored"] += player_stat.get("goals_scored", 0)
228
+ stats["assists"] += player_stat.get("assists", 0)
229
+ stats["expected_goals"] += float(player_stat.get("expected_goals", "0.0"))
230
+ stats["expected_assists"] += float(
231
+ player_stat.get("expected_assists", "0.0")
232
+ )
233
+ stats["expected_goal_involvements"] += float(
234
+ player_stat.get("expected_goal_involvements", "0.0")
235
+ )
236
+ stats["defensive_contribution"] += player_stat.get(
237
+ "defensive_contribution", 0
238
+ )
239
+ if player_stat.get("minutes", 0) > 0:
240
+ stats["matches_played"] += 1
241
+
242
+ # Enrich with player details and sort by each metric
243
+ metrics = {
244
+ "goals_scored": [],
245
+ "expected_goals": [],
246
+ "assists": [],
247
+ "expected_assists": [],
248
+ "expected_goal_involvements": [],
249
+ "defensive_contribution": [],
250
+ }
251
+
252
+ for element_id, stats in player_stats.items():
253
+ player = store.get_player_by_id(element_id)
254
+ if not player:
255
+ continue
256
+
257
+ player_data = {
258
+ "element_id": element_id,
259
+ "name": player.web_name,
260
+ "full_name": f"{player.first_name} {player.second_name}",
261
+ "team": player.team_name,
262
+ "position": player.position,
263
+ "matches_played": stats["matches_played"],
264
+ }
265
+
266
+ # Add to each metric list with the stat value
267
+ for metric in metrics:
268
+ metric_value = stats[metric]
269
+ if metric_value > 0: # Only include players with non-zero stats
270
+ metrics[metric].append({**player_data, "value": metric_value})
271
+
272
+ # Sort each metric list by value (descending) and take top 10
273
+ for metric in metrics:
274
+ metrics[metric] = sorted(metrics[metric], key=lambda x: x["value"], reverse=True)[:10]
275
+
276
+ return {
277
+ "gameweek_range": f"GW {start_gw}-{end_gw}",
278
+ "fixtures_analyzed": len(target_fixtures),
279
+ "metrics": metrics,
280
+ }
281
+
282
+
283
+ # =============================================================================
284
+ # MCP Tools
285
+ # =============================================================================
286
+
287
+
288
+ @mcp.tool(
289
+ name="fpl_search_players",
290
+ annotations={
291
+ "title": "Search FPL Players",
292
+ "readOnlyHint": True,
293
+ "destructiveHint": False,
294
+ "idempotentHint": True,
295
+ "openWorldHint": True,
296
+ },
297
+ )
298
+ async def fpl_search_players(params: SearchPlayersInput) -> str:
299
+ """
300
+ Search for Fantasy Premier League players by name.
301
+
302
+ Returns basic player information including price, form, and stats. Use player names
303
+ (not IDs) for all operations. Supports partial name matching.
304
+
305
+ Args:
306
+ params (SearchPlayersInput): Validated input parameters containing:
307
+ - name_query (str): Player name to search (e.g., 'Salah', 'Haaland')
308
+ - limit (Optional[int]): Max results to return, 1-50 (default: 10)
309
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
310
+
311
+ Returns:
312
+ str: Formatted player search results
313
+
314
+ Examples:
315
+ - Search for Egyptian players: name_query="Salah"
316
+ - Find strikers named Kane: name_query="Kane"
317
+ - Get top 20 results: name_query="Son", limit=20
318
+
319
+ Error Handling:
320
+ - Returns "No players found" if no matches
321
+ - Returns formatted error message if API fails
322
+ """
323
+ try:
324
+ await _create_client()
325
+ if not store.bootstrap_data:
326
+ return "Error: Player data not available. Please try again later."
327
+
328
+ # Use bootstrap data which has all attributes
329
+ players = store.bootstrap_data.elements
330
+ matches = [p for p in players if params.name_query.lower() in p.web_name.lower()]
331
+
332
+ if not matches:
333
+ return f"No players found matching '{params.name_query}'. Try a different search term."
334
+
335
+ # Limit results
336
+ matches = matches[: params.limit]
337
+
338
+ if params.response_format == ResponseFormat.JSON:
339
+ result = {
340
+ "query": params.name_query,
341
+ "count": len(matches),
342
+ "players": [
343
+ {
344
+ "name": p.web_name,
345
+ "full_name": f"{p.first_name} {p.second_name}",
346
+ "team": p.team_name,
347
+ "position": p.position,
348
+ "price": format_player_price(p.now_cost),
349
+ "form": str(p.form),
350
+ "points_per_game": str(p.points_per_game),
351
+ "status": p.status,
352
+ "news": p.news or None,
353
+ }
354
+ for p in matches
355
+ ],
356
+ }
357
+ return format_json_response(result)
358
+ else:
359
+ output = [
360
+ f"# Player Search Results: '{params.name_query}'",
361
+ f"\nFound {len(matches)} players:\n",
362
+ ]
363
+ for p in matches:
364
+ price = format_player_price(p.now_cost)
365
+ status_ind = format_status_indicator(p.status, p.news)
366
+ output.append(
367
+ f"├─ **{p.web_name}** ({p.team_name}) | {price} | Form: {p.form} | PPG: {p.points_per_game}{status_ind}"
368
+ )
369
+
370
+ result = "\n".join(output)
371
+ truncated, was_truncated = check_and_truncate(
372
+ result,
373
+ CHARACTER_LIMIT,
374
+ "Use a more specific name_query to narrow results",
375
+ )
376
+ return truncated
377
+
378
+ except Exception as e:
379
+ return handle_api_error(e)
380
+
381
+
382
+ @mcp.tool(
383
+ name="fpl_search_players_by_team",
384
+ annotations={
385
+ "title": "Search FPL Players by Team",
386
+ "readOnlyHint": True,
387
+ "destructiveHint": False,
388
+ "idempotentHint": True,
389
+ "openWorldHint": True,
390
+ },
391
+ )
392
+ async def fpl_search_players_by_team(params: SearchPlayersByTeamInput) -> str:
393
+ """
394
+ Search for all Fantasy Premier League players from a specific team.
395
+
396
+ Returns all players from the team organized by position, with prices, form, and stats.
397
+ Useful for analyzing team squads or finding budget options from specific teams.
398
+
399
+ Args:
400
+ params (SearchPlayersByTeamInput): Validated input parameters containing:
401
+ - team_name (str): Team name (e.g., 'Arsenal', 'Liverpool', 'Man City')
402
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
403
+
404
+ Returns:
405
+ str: Formatted team squad listing organized by position
406
+
407
+ Examples:
408
+ - Get Arsenal squad: team_name="Arsenal"
409
+ - Search by short name: team_name="MCI"
410
+ - Get Liverpool in JSON: team_name="Liverpool", response_format="json"
411
+
412
+ Error Handling:
413
+ - Returns error if no team found
414
+ - Returns error if multiple teams match (asks user to be more specific)
415
+ - Returns formatted error message if API fails
416
+ """
417
+ try:
418
+ await _create_client()
419
+ if not store.bootstrap_data:
420
+ return "Error: Player data not available. Please try again later."
421
+
422
+ matching_teams = [
423
+ t
424
+ for t in store.bootstrap_data.teams
425
+ if params.team_name.lower() in t.name.lower()
426
+ or params.team_name.lower() in t.short_name.lower()
427
+ ]
428
+
429
+ if not matching_teams:
430
+ return f"No teams found matching '{params.team_name}'. Try using the full team name or abbreviation."
431
+
432
+ if len(matching_teams) > 1:
433
+ team_list = ", ".join([f"{t.name} ({t.short_name})" for t in matching_teams])
434
+ return f"Multiple teams found: {team_list}. Please be more specific."
435
+
436
+ team = matching_teams[0]
437
+ players = [p for p in store.bootstrap_data.elements if p.team == team.id]
438
+
439
+ if not players:
440
+ return f"No players found for {team.name}. This may be a data issue."
441
+
442
+ # Sort by position and price
443
+ position_order = {"GKP": 1, "DEF": 2, "MID": 3, "FWD": 4}
444
+ players_sorted = sorted(
445
+ players,
446
+ key=lambda p: (position_order.get(p.position or "ZZZ", 5), -p.now_cost),
447
+ )
448
+
449
+ if params.response_format == ResponseFormat.JSON:
450
+ result = {
451
+ "team": {"name": team.name, "short_name": team.short_name},
452
+ "player_count": len(players_sorted),
453
+ "players": [
454
+ {
455
+ "name": p.web_name,
456
+ "full_name": f"{p.first_name} {p.second_name}",
457
+ "position": p.position,
458
+ "price": format_player_price(p.now_cost),
459
+ "form": str(p.form),
460
+ "points_per_game": str(p.points_per_game),
461
+ "status": p.status,
462
+ "news": p.news or None,
463
+ }
464
+ for p in players_sorted
465
+ ],
466
+ }
467
+ return format_json_response(result)
468
+ else:
469
+ output = [f"**{team.name} ({team.short_name}) Squad:**\n"]
470
+
471
+ current_position = None
472
+ for p in players_sorted:
473
+ if p.position != current_position:
474
+ current_position = p.position
475
+ output.append(f"\n**{current_position}:**")
476
+
477
+ price = format_player_price(p.now_cost)
478
+ status_ind = format_status_indicator(p.status, p.news)
479
+
480
+ output.append(
481
+ f"├─ {p.web_name:20s} | {price} | "
482
+ f"Form: {p.form:4s} | PPG: {p.points_per_game:4s}{status_ind}"
483
+ )
484
+
485
+ result = "\n".join(output)
486
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
487
+ return truncated
488
+
489
+ except Exception as e:
490
+ return handle_api_error(e)
491
+
492
+
493
+ @mcp.tool(
494
+ name="fpl_find_player",
495
+ annotations={
496
+ "title": "Find FPL Player with Fuzzy Matching",
497
+ "readOnlyHint": True,
498
+ "destructiveHint": False,
499
+ "idempotentHint": True,
500
+ "openWorldHint": True,
501
+ },
502
+ )
503
+ async def fpl_find_player(params: FindPlayerInput) -> str:
504
+ """
505
+ Find a Fantasy Premier League player by name with intelligent fuzzy matching.
506
+
507
+ Handles variations in spelling, partial names, and common nicknames. If multiple
508
+ players match, returns disambiguation options. More forgiving than exact search.
509
+
510
+ Args:
511
+ params (FindPlayerInput): Validated input parameters containing:
512
+ - player_name (str): Player name with fuzzy support (e.g., 'Haalnd' matches 'Haaland')
513
+
514
+ Returns:
515
+ str: Player details if unique match, or list of matching players if ambiguous
516
+
517
+ Examples:
518
+ - Find with typo: player_name="Haalnd" (finds Haaland)
519
+ - Partial name: player_name="Mo Salah" (finds Mohamed Salah)
520
+ - Surname only: player_name="Son" (finds Son Heung-min)
521
+
522
+ Error Handling:
523
+ - Returns helpful message if no players found
524
+ - Returns disambiguation list if multiple matches
525
+ - Returns formatted error message if API fails
526
+ """
527
+ try:
528
+ await _create_client()
529
+ if not store.bootstrap_data:
530
+ return "Error: Player data not available. Please try again later."
531
+
532
+ matches = store.find_players_by_name(params.player_name, fuzzy=True)
533
+
534
+ if not matches:
535
+ return f"No players found matching '{params.player_name}'. Try a different spelling or use the player's surname."
536
+
537
+ if len(matches) == 1 or (
538
+ matches[0][1] >= 0.95 and len(matches) > 1 and matches[0][1] - matches[1][1] > 0.2
539
+ ):
540
+ player = matches[0][0]
541
+ return format_player_details(player)
542
+
543
+ # Multiple matches - show disambiguation
544
+ output = [f"Found {len(matches)} players matching '{params.player_name}':\n"]
545
+
546
+ for player, _score in matches[:10]:
547
+ price = format_player_price(player.now_cost)
548
+ status_ind = format_status_indicator(player.status, player.news)
549
+
550
+ output.append(
551
+ f"├─ {player.first_name} {player.second_name} ({player.web_name}) - "
552
+ f"{player.team_name} {player.position} | {price} | "
553
+ f"Form: {player.form} | PPG: {player.points_per_game}{status_ind}"
554
+ )
555
+
556
+ output.append("\nPlease use the full name or be more specific for detailed information.")
557
+ result = "\n".join(output)
558
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
559
+ return truncated
560
+
561
+ except Exception as e:
562
+ return handle_api_error(e)
563
+
564
+
565
+ @mcp.tool(
566
+ name="fpl_get_player_details",
567
+ annotations={
568
+ "title": "Get FPL Player Details",
569
+ "readOnlyHint": True,
570
+ "destructiveHint": False,
571
+ "idempotentHint": True,
572
+ "openWorldHint": True,
573
+ },
574
+ )
575
+ async def fpl_get_player_details(params: GetPlayerDetailsInput) -> str:
576
+ """
577
+ Get comprehensive information about a specific Fantasy Premier League player.
578
+
579
+ Returns detailed player information including price, form, team, position,
580
+ upcoming fixtures with difficulty ratings, recent gameweek performance,
581
+ popularity, and season stats. Most comprehensive player tool.
582
+
583
+ Args:
584
+ params (GetPlayerDetailsInput): Validated input parameters containing:
585
+ - player_name (str): Player name (e.g., 'Mohamed Salah', 'Erling Haaland')
586
+
587
+ Returns:
588
+ str: Comprehensive player information with fixtures, form, and stats
589
+
590
+ Examples:
591
+ - Get player info: player_name="Mohamed Salah"
592
+ - Check fixtures: player_name="Bukayo Saka"
593
+ - Review form: player_name="Erling Haaland"
594
+
595
+ Error Handling:
596
+ - Returns error if player not found
597
+ - Suggests using fpl_find_player if name is ambiguous
598
+ - Returns formatted error message if API fails
599
+ """
600
+ try:
601
+ client = await _create_client()
602
+ matches = store.find_players_by_name(params.player_name, fuzzy=True)
603
+
604
+ if not matches:
605
+ return f"No player found matching '{params.player_name}'. Use fpl_search_players to find the correct name."
606
+
607
+ if len(matches) > 1 and matches[0][1] < 0.95:
608
+ return f"Ambiguous player name. Use fpl_find_player to see all matches for '{params.player_name}'"
609
+
610
+ player = matches[0][0]
611
+ player_id = player.id
612
+
613
+ # Fetch detailed summary from API including fixtures and history
614
+ summary_data = await client.get_element_summary(player_id)
615
+
616
+ # Enrich history and fixtures with team names
617
+ history = summary_data.get("history", [])
618
+ history = store.enrich_gameweek_history(history)
619
+
620
+ fixtures = summary_data.get("fixtures", [])
621
+ fixtures = store.enrich_fixtures(fixtures)
622
+
623
+ # Format with comprehensive data
624
+ result = format_player_details(player, history, fixtures)
625
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
626
+ return truncated
627
+
628
+ except Exception as e:
629
+ return handle_api_error(e)
630
+
631
+
632
+ @mcp.tool(
633
+ name="fpl_compare_players",
634
+ annotations={
635
+ "title": "Compare FPL Players",
636
+ "readOnlyHint": True,
637
+ "destructiveHint": False,
638
+ "idempotentHint": True,
639
+ "openWorldHint": True,
640
+ },
641
+ )
642
+ async def fpl_compare_players(params: ComparePlayersInput) -> str:
643
+ """
644
+ Compare multiple Fantasy Premier League players side-by-side.
645
+
646
+ Provides detailed comparison of 2-5 players including their stats, prices, form,
647
+ and other key metrics. Useful for making transfer decisions.
648
+
649
+ Args:
650
+ params (ComparePlayersInput): Validated input parameters containing:
651
+ - player_names (list[str]): 2-5 player names to compare
652
+
653
+ Returns:
654
+ str: Side-by-side comparison of players in markdown format
655
+
656
+ Examples:
657
+ - Compare wingers: player_names=["Salah", "Saka", "Palmer"]
658
+ - Compare strikers: player_names=["Haaland", "Isak"]
659
+ - Compare for transfers: player_names=["Son", "Maddison", "Odegaard"]
660
+
661
+ Error Handling:
662
+ - Returns error if fewer than 2 or more than 5 players provided
663
+ - Returns error if any player name is ambiguous
664
+ - Returns formatted error message if API fails
665
+ """
666
+ try:
667
+ await _create_client()
668
+ if not store.bootstrap_data:
669
+ return "Error: Player data not available. Please try again later."
670
+
671
+ players_to_compare = []
672
+ ambiguous = []
673
+
674
+ for name in params.player_names:
675
+ matches = store.find_players_by_name(name, fuzzy=True)
676
+
677
+ if not matches:
678
+ return f"Error: No player found matching '{name}'. Use fpl_search_players to find the correct name."
679
+
680
+ if len(matches) == 1 or (
681
+ matches[0][1] >= 0.95 and len(matches) > 1 and matches[0][1] - matches[1][1] > 0.2
682
+ ):
683
+ players_to_compare.append(matches[0][0])
684
+ else:
685
+ ambiguous.append((name, matches[:3]))
686
+
687
+ if ambiguous:
688
+ output = ["Cannot compare - ambiguous player names:\n"]
689
+ for name, matches in ambiguous:
690
+ output.append(f"\n'{name}' could be:")
691
+ for player, _score in matches:
692
+ output.append(
693
+ f" - {player.first_name} {player.second_name} ({player.team_name})"
694
+ )
695
+ output.append("\nPlease use more specific names or full names.")
696
+ return "\n".join(output)
697
+
698
+ output = [f"**Player Comparison ({len(players_to_compare)} players)**\n"]
699
+ output.append("=" * 80)
700
+
701
+ for player in players_to_compare:
702
+ price = format_player_price(player.now_cost)
703
+ status_ind = format_status_indicator(player.status, player.news)
704
+ full_status = format_player_status(player.status)
705
+
706
+ output.extend(
707
+ [
708
+ f"\n**{player.web_name}** ({player.first_name} {player.second_name})",
709
+ f"├─ Team: {player.team_name} | Position: {player.position}",
710
+ f"├─ Price: {price}",
711
+ f"├─ Form: {player.form} | Points per Game: {player.points_per_game}",
712
+ f"├─ Total Points: {getattr(player, 'total_points', 'N/A')}",
713
+ f"├─ Status: {full_status}{status_ind}",
714
+ ]
715
+ )
716
+
717
+ if player.news:
718
+ output.append(f"├─ News: {player.news}")
719
+
720
+ # Popularity stats
721
+ if hasattr(player, "selected_by_percent"):
722
+ output.append(f"├─ Selected by: {getattr(player, 'selected_by_percent', 'N/A')}%")
723
+
724
+ if hasattr(player, "minutes"):
725
+ output.append(f"├─ Minutes: {getattr(player, 'minutes', 'N/A')}")
726
+
727
+ # Detailed Season Statistics
728
+ output.extend(
729
+ [
730
+ f"├─ Goals: {getattr(player, 'goals_scored', 0)} | xG: {getattr(player, 'expected_goals', '0.00')}",
731
+ f"├─ Assists: {getattr(player, 'assists', 0)} | xA: {getattr(player, 'expected_assists', '0.00')}",
732
+ f"├─ BPS: {getattr(player, 'bps', 0)} | Bonus: {getattr(player, 'bonus', 0)}",
733
+ f"├─ Clean Sheets: {getattr(player, 'clean_sheets', 0)}",
734
+ f"├─ Defensive Contribution: {getattr(player, 'defensive_contribution', 0)}",
735
+ f"├─ Yellow Cards: {getattr(player, 'yellow_cards', 0)} | Red Cards: {getattr(player, 'red_cards', 0)}",
736
+ ]
737
+ )
738
+
739
+ output.append("=" * 80)
740
+
741
+ result = "\n".join(output)
742
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
743
+ return truncated
744
+
745
+ except Exception as e:
746
+ return handle_api_error(e)
747
+
748
+
749
+ @mcp.tool(
750
+ name="fpl_get_top_performers",
751
+ annotations={
752
+ "title": "Get Top FPL Performers",
753
+ "readOnlyHint": True,
754
+ "destructiveHint": False,
755
+ "idempotentHint": True,
756
+ "openWorldHint": True,
757
+ },
758
+ )
759
+ async def fpl_get_top_performers(params: GetTopPlayersByMetricInput) -> str:
760
+ """
761
+ Get top 10 Fantasy Premier League performers over recent gameweeks.
762
+
763
+ Analyzes player performance over the last N gameweeks and returns the top 10 players for
764
+ each metric: Goals, Expected Goals (xG), Assists, Expected Assists (xA), and
765
+ Expected Goal Involvements (xGI). Perfect for identifying in-form players for transfers.
766
+
767
+ Args:
768
+ params (GetTopPlayersByMetricInput): Validated input parameters containing:
769
+ - num_gameweeks (int): Number of recent gameweeks to analyze, 1-10 (default: 5)
770
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
771
+
772
+ Returns:
773
+ str: Top 10 players for each metric with their stats and team info
774
+
775
+ Examples:
776
+ - Last 5 gameweeks: num_gameweeks=5
777
+ - Last 10 gameweeks: num_gameweeks=10
778
+ - Get as JSON: num_gameweeks=5, response_format="json"
779
+
780
+ Error Handling:
781
+ - Returns error if no finished fixtures in range
782
+ - Gracefully handles API failures for individual fixtures
783
+ - Returns formatted error message if data unavailable
784
+
785
+ Note:
786
+ This tool might take a few seconds to complete due to the number of
787
+ data points it needs to process.
788
+ """
789
+ try:
790
+ client = await _create_client()
791
+
792
+ # Aggregate stats from fixtures
793
+ result = await _aggregate_player_stats_from_fixtures(client, params.num_gameweeks)
794
+
795
+ if "error" in result:
796
+ return f"Error: {result['error']}\nGameweek range: {result.get('gameweek_range', 'Unknown')}"
797
+
798
+ if params.response_format == ResponseFormat.JSON:
799
+ return format_json_response(result)
800
+ else:
801
+ # Format as markdown
802
+ output = [
803
+ f"# Top Performers ({result['gameweek_range']})\n",
804
+ ]
805
+
806
+ metric_names = {
807
+ "goals_scored": "⚽ Goals Scored",
808
+ "expected_goals": "📊 Expected Goals (xG)",
809
+ "assists": "🎯 Assists",
810
+ "expected_assists": "📈 Expected Assists (xA)",
811
+ "expected_goal_involvements": "🔥 Expected Goal Involvements (xGI)",
812
+ "defensive_contribution": "🛡️ Defensive Contribution",
813
+ }
814
+
815
+ for metric_key, metric_name in metric_names.items():
816
+ players = result["metrics"].get(metric_key, [])
817
+ if not players:
818
+ continue
819
+
820
+ output.append(f"\n## {metric_name}\n")
821
+
822
+ for i, player in enumerate(players, 1):
823
+ value = player["value"]
824
+ # Format value based on metric type
825
+ if metric_key in ("goals_scored", "assists", "defensive_contribution"):
826
+ value_str = f"{int(value)}"
827
+ else:
828
+ value_str = f"{value:.2f}"
829
+
830
+ output.append(
831
+ f"{i}. **{player['name']}** ({player['team']} - {player['position']}) | "
832
+ f"{value_str} | {player['matches_played']} matches"
833
+ )
834
+
835
+ result_text = "\n".join(output)
836
+ truncated, _ = check_and_truncate(result_text, CHARACTER_LIMIT)
837
+ return truncated
838
+
839
+ except Exception as e:
840
+ return handle_api_error(e)