fpl-mcp-server 0.1.6__py3-none-any.whl → 0.2.0__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 CHANGED
@@ -3,7 +3,7 @@
3
3
  from pydantic import BaseModel, ConfigDict, Field, field_validator
4
4
 
5
5
  from ..client import FPLClient
6
- from ..constants import CHARACTER_LIMIT, PlayerPosition
6
+ from ..constants import CHARACTER_LIMIT
7
7
  from ..formatting import format_player_details
8
8
  from ..state import store
9
9
  from ..utils import (
@@ -18,43 +18,6 @@ from ..utils import (
18
18
  from . import mcp
19
19
 
20
20
 
21
- class SearchPlayersInput(BaseModel):
22
- """Input model for searching players by name."""
23
-
24
- model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
25
-
26
- name_query: str = Field(
27
- ...,
28
- description="Player name to search for (e.g., 'Salah', 'Haaland', 'Son')",
29
- min_length=2,
30
- max_length=100,
31
- )
32
- limit: int | None = Field(
33
- default=10, description="Maximum number of results to return", ge=1, le=50
34
- )
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 SearchPlayersByTeamInput(BaseModel):
42
- """Input model for searching players by team."""
43
-
44
- model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
45
-
46
- team_name: str = Field(
47
- ...,
48
- description="Team name to search for (e.g., 'Arsenal', 'Liverpool', 'Man City')",
49
- min_length=2,
50
- max_length=50,
51
- )
52
- response_format: ResponseFormat = Field(
53
- default=ResponseFormat.MARKDOWN,
54
- description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
55
- )
56
-
57
-
58
21
  class FindPlayerInput(BaseModel):
59
22
  """Input for finding a player with fuzzy matching."""
60
23
 
@@ -120,6 +83,21 @@ class GetTopPlayersByMetricInput(BaseModel):
120
83
  )
121
84
 
122
85
 
86
+ class GetCaptainRecommendationsInput(BaseModel):
87
+ """Input model for captain recommendations."""
88
+
89
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
90
+
91
+ team_id: int | None = Field(
92
+ default=None, description="Your team ID (to analyze your specific squad)"
93
+ )
94
+ gameweek: int | None = Field(default=None, description="Gameweek to analyze (defaults to next)")
95
+ response_format: ResponseFormat = Field(
96
+ default=ResponseFormat.MARKDOWN,
97
+ description="Output format: 'markdown' or 'json'",
98
+ )
99
+
100
+
123
101
  async def _create_client():
124
102
  """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
125
103
  client = FPLClient(store=store)
@@ -269,216 +247,6 @@ async def _aggregate_player_stats_from_fixtures(client: FPLClient, num_gameweeks
269
247
  }
270
248
 
271
249
 
272
- @mcp.tool(
273
- name="fpl_search_players",
274
- annotations={
275
- "title": "Search FPL Players",
276
- "readOnlyHint": True,
277
- "destructiveHint": False,
278
- "idempotentHint": True,
279
- "openWorldHint": True,
280
- },
281
- )
282
- async def fpl_search_players(params: SearchPlayersInput) -> str:
283
- """
284
- Search for Fantasy Premier League players by name.
285
-
286
- Returns basic player information including price, form, and stats. Use player names
287
- (not IDs) for all operations. Supports partial name matching.
288
-
289
- Args:
290
- params (SearchPlayersInput): Validated input parameters containing:
291
- - name_query (str): Player name to search (e.g., 'Salah', 'Haaland')
292
- - limit (Optional[int]): Max results to return, 1-50 (default: 10)
293
- - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
294
-
295
- Returns:
296
- str: Formatted player search results
297
-
298
- Examples:
299
- - Search for Egyptian players: name_query="Salah"
300
- - Find strikers named Kane: name_query="Kane"
301
- - Get top 20 results: name_query="Son", limit=20
302
-
303
- Error Handling:
304
- - Returns "No players found" if no matches
305
- - Returns formatted error message if API fails
306
- """
307
- try:
308
- await _create_client()
309
- if not store.bootstrap_data:
310
- return "Error: Player data not available. Please try again later."
311
-
312
- # Use bootstrap data which has all attributes
313
- players = store.bootstrap_data.elements
314
- matches = [p for p in players if params.name_query.lower() in p.web_name.lower()]
315
-
316
- if not matches:
317
- return f"No players found matching '{params.name_query}'. Try a different search term."
318
-
319
- # Limit results
320
- matches = matches[: params.limit]
321
-
322
- if params.response_format == ResponseFormat.JSON:
323
- result = {
324
- "query": params.name_query,
325
- "count": len(matches),
326
- "players": [
327
- {
328
- "name": p.web_name,
329
- "full_name": f"{p.first_name} {p.second_name}",
330
- "team": p.team_name,
331
- "position": p.position,
332
- "price": format_player_price(p.now_cost),
333
- "form": str(p.form),
334
- "points_per_game": str(p.points_per_game),
335
- "status": p.status,
336
- "news": p.news or None,
337
- }
338
- for p in matches
339
- ],
340
- }
341
- return format_json_response(result)
342
- else:
343
- output = [
344
- f"# Player Search Results: '{params.name_query}'",
345
- f"\nFound {len(matches)} players:\n",
346
- ]
347
- for p in matches:
348
- price = format_player_price(p.now_cost)
349
- status_ind = format_status_indicator(p.status, p.news)
350
- output.append(
351
- f"├─ **{p.web_name}** ({p.team_name}) | {price} | Form: {p.form} | PPG: {p.points_per_game}{status_ind}"
352
- )
353
-
354
- result = "\n".join(output)
355
- truncated, was_truncated = check_and_truncate(
356
- result,
357
- CHARACTER_LIMIT,
358
- "Use a more specific name_query to narrow results",
359
- )
360
- return truncated
361
-
362
- except Exception as e:
363
- return handle_api_error(e)
364
-
365
-
366
- @mcp.tool(
367
- name="fpl_search_players_by_team",
368
- annotations={
369
- "title": "Search FPL Players by Team",
370
- "readOnlyHint": True,
371
- "destructiveHint": False,
372
- "idempotentHint": True,
373
- "openWorldHint": True,
374
- },
375
- )
376
- async def fpl_search_players_by_team(params: SearchPlayersByTeamInput) -> str:
377
- """
378
- Search for all Fantasy Premier League players from a specific team.
379
-
380
- Returns all players from the team organized by position, with prices, form, and stats.
381
- Useful for analyzing team squads or finding budget options from specific teams.
382
-
383
- Args:
384
- params (SearchPlayersByTeamInput): Validated input parameters containing:
385
- - team_name (str): Team name (e.g., 'Arsenal', 'Liverpool', 'Man City')
386
- - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
387
-
388
- Returns:
389
- str: Formatted team squad listing organized by position
390
-
391
- Examples:
392
- - Get Arsenal squad: team_name="Arsenal"
393
- - Search by short name: team_name="MCI"
394
- - Get Liverpool in JSON: team_name="Liverpool", response_format="json"
395
-
396
- Error Handling:
397
- - Returns error if no team found
398
- - Returns error if multiple teams match (asks user to be more specific)
399
- - Returns formatted error message if API fails
400
- """
401
- try:
402
- await _create_client()
403
- if not store.bootstrap_data:
404
- return "Error: Player data not available. Please try again later."
405
-
406
- matching_teams = [
407
- t
408
- for t in store.bootstrap_data.teams
409
- if params.team_name.lower() in t.name.lower()
410
- or params.team_name.lower() in t.short_name.lower()
411
- ]
412
-
413
- if not matching_teams:
414
- return f"No teams found matching '{params.team_name}'. Try using the full team name or abbreviation."
415
-
416
- if len(matching_teams) > 1:
417
- team_list = ", ".join([f"{t.name} ({t.short_name})" for t in matching_teams])
418
- return f"Multiple teams found: {team_list}. Please be more specific."
419
-
420
- team = matching_teams[0]
421
- players = [p for p in store.bootstrap_data.elements if p.team == team.id]
422
-
423
- if not players:
424
- return f"No players found for {team.name}. This may be a data issue."
425
-
426
- # Sort by position and price
427
- position_order = {
428
- PlayerPosition.GOALKEEPER.value: 1,
429
- PlayerPosition.DEFENDER.value: 2,
430
- PlayerPosition.MIDFIELDER.value: 3,
431
- PlayerPosition.FORWARD.value: 4,
432
- }
433
- players_sorted = sorted(
434
- players,
435
- key=lambda p: (position_order.get(p.position or "ZZZ", 5), -p.now_cost),
436
- )
437
-
438
- if params.response_format == ResponseFormat.JSON:
439
- result = {
440
- "team": {"name": team.name, "short_name": team.short_name},
441
- "player_count": len(players_sorted),
442
- "players": [
443
- {
444
- "name": p.web_name,
445
- "full_name": f"{p.first_name} {p.second_name}",
446
- "position": p.position,
447
- "price": format_player_price(p.now_cost),
448
- "form": str(p.form),
449
- "points_per_game": str(p.points_per_game),
450
- "status": p.status,
451
- "news": p.news or None,
452
- }
453
- for p in players_sorted
454
- ],
455
- }
456
- return format_json_response(result)
457
- else:
458
- output = [f"**{team.name} ({team.short_name}) Squad:**\n"]
459
-
460
- current_position = None
461
- for p in players_sorted:
462
- if p.position != current_position:
463
- current_position = p.position
464
- output.append(f"\n**{current_position}:**")
465
-
466
- price = format_player_price(p.now_cost)
467
- status_ind = format_status_indicator(p.status, p.news)
468
-
469
- output.append(
470
- f"├─ {p.web_name:20s} | {price} | "
471
- f"Form: {p.form:4s} | PPG: {p.points_per_game:4s}{status_ind}"
472
- )
473
-
474
- result = "\n".join(output)
475
- truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
476
- return truncated
477
-
478
- except Exception as e:
479
- return handle_api_error(e)
480
-
481
-
482
250
  @mcp.tool(
483
251
  name="fpl_find_player",
484
252
  annotations={
@@ -684,48 +452,51 @@ async def fpl_compare_players(params: ComparePlayersInput) -> str:
684
452
  output.append("\nPlease use more specific names or full names.")
685
453
  return "\n".join(output)
686
454
 
687
- output = [f"**Player Comparison ({len(players_to_compare)} players)**\n"]
688
- output.append("=" * 80)
455
+ # Construct Markdown Table
456
+ headers = ["Metric"] + [f"**{p.web_name}**" for p in players_to_compare]
457
+ output = [
458
+ f"## Player Comparison ({len(players_to_compare)} players)",
459
+ "",
460
+ "| " + " | ".join(headers) + " |",
461
+ "| :--- | " + " | ".join([":---"] * len(players_to_compare)) + " |",
462
+ ]
689
463
 
690
- for player in players_to_compare:
691
- price = format_player_price(player.now_cost)
692
- status_ind = format_status_indicator(player.status, player.news)
693
- full_status = format_player_status(player.status)
694
-
695
- output.extend(
696
- [
697
- f"\n**{player.web_name}** ({player.first_name} {player.second_name})",
698
- f"├─ Team: {player.team_name} | Position: {player.position}",
699
- f"├─ Price: {price}",
700
- f"├─ Form: {player.form} | Points per Game: {player.points_per_game}",
701
- f"├─ Total Points: {getattr(player, 'total_points', 'N/A')}",
702
- f"├─ Status: {full_status}{status_ind}",
703
- ]
704
- )
464
+ def get_stat(p, attr, default="0"):
465
+ return str(getattr(p, attr, default))
466
+
467
+ metrics = [
468
+ ("Team", lambda p: p.team_name),
469
+ ("Position", lambda p: p.position),
470
+ ("Price", lambda p: format_player_price(p.now_cost)),
471
+ ("Form", lambda p: str(p.form)),
472
+ ("PPG", lambda p: str(p.points_per_game)),
473
+ ("Total Points", lambda p: get_stat(p, "total_points", "N/A")),
474
+ ("Selected By", lambda p: f"{get_stat(p, 'selected_by_percent', '0')}%"),
475
+ (
476
+ "Status",
477
+ lambda p: f"{format_player_status(p.status)} {format_status_indicator(p.status, p.news)}",
478
+ ),
479
+ ("Minutes", lambda p: get_stat(p, "minutes", "0")),
480
+ ("Goals", lambda p: get_stat(p, "goals_scored")),
481
+ ("xG", lambda p: get_stat(p, "expected_goals", "0.00")),
482
+ ("Assists", lambda p: get_stat(p, "assists")),
483
+ ("xA", lambda p: get_stat(p, "expected_assists", "0.00")),
484
+ ("Clean Sheets", lambda p: get_stat(p, "clean_sheets")),
485
+ ("BPS", lambda p: get_stat(p, "bps")),
486
+ ("Bonus", lambda p: get_stat(p, "bonus")),
487
+ ("Def. Contribution", lambda p: get_stat(p, "defensive_contribution")),
488
+ ("Yellow Cards", lambda p: get_stat(p, "yellow_cards")),
489
+ ("Red Cards", lambda p: get_stat(p, "red_cards")),
490
+ ]
705
491
 
706
- if player.news:
707
- output.append(f"├─ News: {player.news}")
708
-
709
- # Popularity stats
710
- if hasattr(player, "selected_by_percent"):
711
- output.append(f"├─ Selected by: {getattr(player, 'selected_by_percent', 'N/A')}%")
712
-
713
- if hasattr(player, "minutes"):
714
- output.append(f"├─ Minutes: {getattr(player, 'minutes', 'N/A')}")
715
-
716
- # Detailed Season Statistics
717
- output.extend(
718
- [
719
- f"├─ Goals: {getattr(player, 'goals_scored', 0)} | xG: {getattr(player, 'expected_goals', '0.00')}",
720
- f"├─ Assists: {getattr(player, 'assists', 0)} | xA: {getattr(player, 'expected_assists', '0.00')}",
721
- f"├─ BPS: {getattr(player, 'bps', 0)} | Bonus: {getattr(player, 'bonus', 0)}",
722
- f"├─ Clean Sheets: {getattr(player, 'clean_sheets', 0)}",
723
- f"├─ Defensive Contribution: {getattr(player, 'defensive_contribution', 0)}",
724
- f"├─ Yellow Cards: {getattr(player, 'yellow_cards', 0)} | Red Cards: {getattr(player, 'red_cards', 0)}",
725
- ]
726
- )
492
+ for label, getter in metrics:
493
+ row = [f"**{label}**"] + [getter(p) for p in players_to_compare]
494
+ output.append("| " + " | ".join(row) + " |")
727
495
 
728
- output.append("=" * 80)
496
+ # News row
497
+ if any(p.news for p in players_to_compare):
498
+ row = ["**News**"] + [(p.news if p.news else "") for p in players_to_compare]
499
+ output.append("| " + " | ".join(row) + " |")
729
500
 
730
501
  result = "\n".join(output)
731
502
  truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
@@ -781,6 +552,12 @@ async def fpl_get_top_performers(params: GetTopPlayersByMetricInput) -> str:
781
552
  # Aggregate stats from fixtures
782
553
  result = await _aggregate_player_stats_from_fixtures(client, params.num_gameweeks)
783
554
 
555
+ if not result:
556
+ msg = "No data available for the specified gameweek range."
557
+ if params.response_format == ResponseFormat.JSON:
558
+ return format_json_response({"error": msg})
559
+ return msg
560
+
784
561
  if "error" in result:
785
562
  return f"Error: {result['error']}\nGameweek range: {result.get('gameweek_range', 'Unknown')}"
786
563
 
@@ -827,3 +604,198 @@ async def fpl_get_top_performers(params: GetTopPlayersByMetricInput) -> str:
827
604
 
828
605
  except Exception as e:
829
606
  return handle_api_error(e)
607
+
608
+
609
+ @mcp.tool(
610
+ name="fpl_get_captain_recommendations",
611
+ annotations={
612
+ "title": "Get Captain Recommendations",
613
+ "readOnlyHint": True,
614
+ "destructiveHint": False,
615
+ "idempotentHint": True,
616
+ "openWorldHint": True,
617
+ },
618
+ )
619
+ async def fpl_get_captain_recommendations(params: GetCaptainRecommendationsInput) -> str:
620
+ """
621
+ Get captaincy recommendations for the upcoming gameweek.
622
+
623
+ Analyzes fixtures, form, and home/away advantage to recommend the best captain choices.
624
+ If team_id is provided, analyzes YOUR specific squad. Otherwise, provides
625
+ general recommendations from all players.
626
+
627
+ Args:
628
+ params (GetCaptainRecommendationsInput): Validated input parameters containing:
629
+ - team_id (int | None): Your team ID to analyze your specific squad
630
+ - gameweek (int | None): Gameweek to analyze (defaults to next)
631
+
632
+ Returns:
633
+ str: Top 3-5 captain recommendations with analysis
634
+
635
+ Examples:
636
+ - Analyze my team: team_id=123456
637
+ - General picks for GW15: gameweek=15
638
+ - General picks: (no args)
639
+
640
+ Error Handling:
641
+ - Returns error if gameweek invalid
642
+ - Returns helpful message if team ID not found
643
+ - Returns formatted error message if API fails
644
+ """
645
+ try:
646
+ client = await _create_client()
647
+ if not store.bootstrap_data:
648
+ return "Error: Player data not available. Please try again later."
649
+
650
+ # Determine gameweek
651
+ gw = params.gameweek
652
+ if not gw:
653
+ current = store.get_current_gameweek()
654
+ gw = (current.id + 1) if current else 1
655
+ if gw > 38:
656
+ gw = 38
657
+
658
+ candidates = []
659
+
660
+ # If team_id provided, fetch squad
661
+ if params.team_id:
662
+ try:
663
+ # Fetch picks for previous GW (or current if live) to guess squad
664
+ # For simplicity, we fetch picks for current event - 1 or current
665
+ fetch_gw = gw - 1 if gw > 1 else 1
666
+ picks_data = await client.get_manager_gameweek_picks(params.team_id, fetch_gw)
667
+ picks = picks_data.get("picks", [])
668
+
669
+ # Get element IDs
670
+ element_ids = [p["element"] for p in picks]
671
+ # Filter to playing players (bootstrap)
672
+ candidates = [p for p in store.bootstrap_data.elements if p.id in element_ids]
673
+ except Exception:
674
+ return f"Error: Could not fetch squad for team ID {params.team_id}. Ensure the ID is correct."
675
+ else:
676
+ # General recommendations: Focus on top 50 by form and price > 6.0 (likely captains)
677
+ candidates = [
678
+ p
679
+ for p in store.bootstrap_data.elements
680
+ if float(p.form) > 3.0 and p.now_cost > 60 and p.status == "a"
681
+ ]
682
+ if len(candidates) < 10:
683
+ candidates = sorted(
684
+ store.bootstrap_data.elements, key=lambda x: float(x.form), reverse=True
685
+ )[:50]
686
+
687
+ # Analyze candidates
688
+ scored_candidates = []
689
+
690
+ for p in candidates:
691
+ # Skip GKP, DEF unless exceptional? Usually cap MID/FWD
692
+ if p.element_type == 1: # Skip GKP
693
+ continue
694
+
695
+ # Fetch Fixture
696
+ player_fixture = None
697
+ if store.fixtures_data:
698
+ # Find fixture involving this player's team in target GW
699
+ fixtures = [
700
+ f
701
+ for f in store.fixtures_data
702
+ if f.event == gw and (f.team_h == p.team or f.team_a == p.team)
703
+ ]
704
+ if fixtures:
705
+ player_fixture = fixtures[0] # Assume 1 fixture
706
+
707
+ difficulty = 3 # Default average
708
+ is_home = False
709
+ opponent = "Unknown"
710
+
711
+ if player_fixture:
712
+ if player_fixture.team_h == p.team:
713
+ difficulty = player_fixture.team_h_difficulty
714
+ is_home = True
715
+ opp_team = next(
716
+ (t for t in store.bootstrap_data.teams if t.id == player_fixture.team_a),
717
+ None,
718
+ )
719
+ opponent = opp_team.short_name if opp_team else "UNK"
720
+ else:
721
+ difficulty = player_fixture.team_a_difficulty
722
+ is_home = False
723
+ opp_team = next(
724
+ (t for t in store.bootstrap_data.teams if t.id == player_fixture.team_h),
725
+ None,
726
+ )
727
+ opponent = opp_team.short_name if opp_team else "UNK"
728
+
729
+ # Score Calculation
730
+ form = float(p.form)
731
+ ppg = float(p.points_per_game)
732
+
733
+ score = (form * 1.5) + ((5 - difficulty) * 2.5) + (ppg * 0.5)
734
+ if is_home:
735
+ score += 1.0
736
+
737
+ if p.status != "a":
738
+ score = -1
739
+
740
+ scored_candidates.append(
741
+ {
742
+ "player": p,
743
+ "score": score,
744
+ "fixture": f"{opponent} ({'H' if is_home else 'A'})",
745
+ "difficulty": difficulty,
746
+ "is_home": is_home,
747
+ "form": form,
748
+ }
749
+ )
750
+
751
+ # Sort and take top 5
752
+ top_picks = sorted(scored_candidates, key=lambda x: x["score"], reverse=True)[:5]
753
+
754
+ if params.response_format == ResponseFormat.JSON:
755
+ result = {
756
+ "gameweek": gw,
757
+ "recommendations": [
758
+ {
759
+ "rank": i + 1,
760
+ "name": pick["player"].web_name,
761
+ "team": pick["player"].team_name,
762
+ "score": round(pick["score"], 1),
763
+ "fixture": pick["fixture"],
764
+ "difficulty": pick["difficulty"],
765
+ "form": pick["form"],
766
+ }
767
+ for i, pick in enumerate(top_picks)
768
+ ],
769
+ }
770
+ return format_json_response(result)
771
+
772
+ # Markdown
773
+ output = [
774
+ f"## 👑 Captain Recommendations (GW{gw})",
775
+ "Based on form, fixture difficulty, and home advantage.",
776
+ "",
777
+ ]
778
+
779
+ if params.team_id:
780
+ output.append(f"**Analysis for Team ID: {params.team_id}**\n")
781
+
782
+ for i, pick in enumerate(top_picks, 1):
783
+ p = pick["player"]
784
+ medal = "🥇" if i == 1 else "🥈" if i == 2 else "🥉" if i == 3 else f"{i}."
785
+
786
+ output.append(f"### {medal} {p.web_name} ({p.team_name})")
787
+ output.append(f"**Fixture:** {pick['fixture']} | **Diff:** {pick['difficulty']}/5")
788
+ output.append(f"**Form:** {p.form} | **PPG:** {p.points_per_game}")
789
+ output.append(
790
+ f"**Rationale:** {'Great home fixture' if pick['is_home'] and pick['difficulty'] <= 2 else 'In excellent form'} "
791
+ f"vs {pick['fixture'].split(' ')[0]}"
792
+ )
793
+ output.append("")
794
+
795
+ if not top_picks:
796
+ output.append("No suitable captain options found. Check your team ID or season status.")
797
+
798
+ return "\n".join(output)
799
+
800
+ except Exception as e:
801
+ return handle_api_error(e)