fpl-mcp-server 0.1.5__py3-none-any.whl → 0.1.7__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
@@ -15,51 +15,8 @@ from ..utils import (
15
15
  format_status_indicator,
16
16
  handle_api_error,
17
17
  )
18
-
19
- # Import shared mcp instance
20
18
  from . import mcp
21
19
 
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
20
 
64
21
  class FindPlayerInput(BaseModel):
65
22
  """Input for finding a player with fuzzy matching."""
@@ -126,9 +83,19 @@ class GetTopPlayersByMetricInput(BaseModel):
126
83
  )
127
84
 
128
85
 
129
- # =============================================================================
130
- # Helper Functions
131
- # =============================================================================
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
+ )
132
99
 
133
100
 
134
101
  async def _create_client():
@@ -280,216 +247,6 @@ async def _aggregate_player_stats_from_fixtures(client: FPLClient, num_gameweeks
280
247
  }
281
248
 
282
249
 
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
250
  @mcp.tool(
494
251
  name="fpl_find_player",
495
252
  annotations={
@@ -695,48 +452,51 @@ async def fpl_compare_players(params: ComparePlayersInput) -> str:
695
452
  output.append("\nPlease use more specific names or full names.")
696
453
  return "\n".join(output)
697
454
 
698
- output = [f"**Player Comparison ({len(players_to_compare)} players)**\n"]
699
- 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
+ ]
700
463
 
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
- )
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
+ ]
716
491
 
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
- )
492
+ for label, getter in metrics:
493
+ row = [f"**{label}**"] + [getter(p) for p in players_to_compare]
494
+ output.append("| " + " | ".join(row) + " |")
738
495
 
739
- 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) + " |")
740
500
 
741
501
  result = "\n".join(output)
742
502
  truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
@@ -792,6 +552,12 @@ async def fpl_get_top_performers(params: GetTopPlayersByMetricInput) -> str:
792
552
  # Aggregate stats from fixtures
793
553
  result = await _aggregate_player_stats_from_fixtures(client, params.num_gameweeks)
794
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
+
795
561
  if "error" in result:
796
562
  return f"Error: {result['error']}\nGameweek range: {result.get('gameweek_range', 'Unknown')}"
797
563
 
@@ -838,3 +604,198 @@ async def fpl_get_top_performers(params: GetTopPlayersByMetricInput) -> str:
838
604
 
839
605
  except Exception as e:
840
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)