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/gameweeks.py CHANGED
@@ -6,7 +6,6 @@ from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
7
  from ..client import FPLClient
8
8
  from ..constants import CHARACTER_LIMIT
9
- from ..formatting import format_gameweek_details
10
9
  from ..state import store
11
10
  from ..utils import (
12
11
  ResponseFormat,
@@ -14,14 +13,8 @@ from ..utils import (
14
13
  format_json_response,
15
14
  handle_api_error,
16
15
  )
17
-
18
- # Import shared mcp instance
19
16
  from . import mcp
20
17
 
21
- # =============================================================================
22
- # Pydantic Input Models
23
- # =============================================================================
24
-
25
18
 
26
19
  class GetCurrentGameweekInput(BaseModel):
27
20
  """Input model for getting current gameweek."""
@@ -34,34 +27,6 @@ class GetCurrentGameweekInput(BaseModel):
34
27
  )
35
28
 
36
29
 
37
- class GetGameweekInfoInput(BaseModel):
38
- """Input model for getting specific gameweek information."""
39
-
40
- model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
41
-
42
- gameweek_number: int = Field(..., description="Gameweek number (1-38)", ge=1, le=38)
43
- response_format: ResponseFormat = Field(
44
- default=ResponseFormat.MARKDOWN,
45
- description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
46
- )
47
-
48
-
49
- class ListAllGameweeksInput(BaseModel):
50
- """Input model for listing all gameweeks."""
51
-
52
- model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
53
-
54
- response_format: ResponseFormat = Field(
55
- default=ResponseFormat.MARKDOWN,
56
- description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
57
- )
58
-
59
-
60
- # =============================================================================
61
- # Helper Functions
62
- # =============================================================================
63
-
64
-
65
30
  async def _create_client():
66
31
  """Create an unauthenticated FPL client for public API access and ensure data is loaded."""
67
32
  client = FPLClient(store=store)
@@ -70,11 +35,6 @@ async def _create_client():
70
35
  return client
71
36
 
72
37
 
73
- # =============================================================================
74
- # MCP Tools
75
- # =============================================================================
76
-
77
-
78
38
  @mcp.tool(
79
39
  name="fpl_get_current_gameweek",
80
40
  annotations={
@@ -232,161 +192,3 @@ async def fpl_get_current_gameweek(params: GetCurrentGameweekInput) -> str:
232
192
 
233
193
  except Exception as e:
234
194
  return handle_api_error(e)
235
-
236
-
237
- @mcp.tool(
238
- name="fpl_get_gameweek_info",
239
- annotations={
240
- "title": "Get FPL Gameweek Information",
241
- "readOnlyHint": True,
242
- "destructiveHint": False,
243
- "idempotentHint": True,
244
- "openWorldHint": True,
245
- },
246
- )
247
- async def fpl_get_gameweek_info(params: GetGameweekInfoInput) -> str:
248
- """
249
- Get detailed information about a specific Fantasy Premier League gameweek.
250
-
251
- Returns comprehensive gameweek data including deadline, scores, top players, most
252
- captained players, and statistics. Useful for analyzing past performance or planning
253
- for future gameweeks.
254
-
255
- Args:
256
- params (GetGameweekInfoInput): Validated input parameters containing:
257
- - gameweek_number (int): Gameweek number between 1-38
258
- - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
259
-
260
- Returns:
261
- str: Detailed gameweek information with statistics and top performers
262
-
263
- Examples:
264
- - Check GW1 results: gameweek_number=1
265
- - Plan for GW10: gameweek_number=10
266
- - Get as JSON: gameweek_number=5, response_format="json"
267
-
268
- Error Handling:
269
- - Returns error if gameweek number invalid (must be 1-38)
270
- - Returns error if gameweek not found
271
- - Returns formatted error message if data unavailable
272
- """
273
- try:
274
- await _create_client()
275
- if not store.bootstrap_data or not store.bootstrap_data.events:
276
- return "Error: Gameweek data not available. Please try again later."
277
-
278
- gameweek = next(
279
- (e for e in store.bootstrap_data.events if e.id == params.gameweek_number),
280
- None,
281
- )
282
- if not gameweek:
283
- return f"Error: Gameweek {params.gameweek_number} not found. Please use a number between 1-38."
284
-
285
- if params.response_format == ResponseFormat.JSON:
286
- return format_json_response(gameweek.model_dump())
287
- else:
288
- result = format_gameweek_details(gameweek)
289
-
290
- # Add top element info manually since it differs from basic details
291
- if gameweek.top_element_info:
292
- top_player = store.get_player_by_id(gameweek.top_element_info.id)
293
- top_player_name = (
294
- top_player.web_name if top_player else f"ID {gameweek.top_element_info.id}"
295
- )
296
- result += (
297
- f"\n**Top Player:** {top_player_name} ({gameweek.top_element_info.points} pts)"
298
- )
299
-
300
- truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
301
- return truncated
302
-
303
- except Exception as e:
304
- return handle_api_error(e)
305
-
306
-
307
- @mcp.tool(
308
- name="fpl_list_all_gameweeks",
309
- annotations={
310
- "title": "List All FPL Gameweeks",
311
- "readOnlyHint": True,
312
- "destructiveHint": False,
313
- "idempotentHint": True,
314
- "openWorldHint": True,
315
- },
316
- )
317
- async def fpl_list_all_gameweeks(params: ListAllGameweeksInput) -> str:
318
- """
319
- List all Fantasy Premier League gameweeks with their status.
320
-
321
- Returns all 38 gameweeks showing which are finished, current, or upcoming. Includes
322
- deadlines and average scores for finished gameweeks. Useful for getting a season
323
- overview and planning long-term strategy.
324
-
325
- Args:
326
- params (ListAllGameweeksInput): Validated input parameters containing:
327
- - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
328
-
329
- Returns:
330
- str: Complete list of all gameweeks with status and scores
331
-
332
- Examples:
333
- - View all gameweeks: response_format="markdown"
334
- - Get season overview: response_format="json"
335
-
336
- Error Handling:
337
- - Returns error if gameweek data unavailable
338
- - Returns formatted error message if API fails
339
- """
340
- try:
341
- await _create_client()
342
- if not store.bootstrap_data or not store.bootstrap_data.events:
343
- return "Error: Gameweek data not available. Please try again later."
344
-
345
- if params.response_format == ResponseFormat.JSON:
346
- result = {
347
- "total_gameweeks": len(store.bootstrap_data.events),
348
- "gameweeks": [
349
- {
350
- "id": event.id,
351
- "name": event.name,
352
- "deadline_time": event.deadline_time,
353
- "is_current": event.is_current,
354
- "is_previous": event.is_previous,
355
- "is_next": event.is_next,
356
- "finished": event.finished,
357
- "average_entry_score": event.average_entry_score,
358
- }
359
- for event in store.bootstrap_data.events
360
- ],
361
- }
362
- return format_json_response(result)
363
- else:
364
- output = ["**All Gameweeks:**\n"]
365
-
366
- for event in store.bootstrap_data.events:
367
- status = []
368
- if event.is_current:
369
- status.append("CURRENT")
370
- if event.is_previous:
371
- status.append("PREVIOUS")
372
- if event.is_next:
373
- status.append("NEXT")
374
- if event.finished:
375
- status.append("FINISHED")
376
-
377
- status_str = f" [{', '.join(status)}]" if status else ""
378
- avg_score = (
379
- f" | Avg: {event.average_entry_score}" if event.average_entry_score else ""
380
- )
381
-
382
- output.append(
383
- f"GW{event.id}: {event.name}{status_str} | "
384
- f"Deadline: {event.deadline_time[:10]}{avg_score}"
385
- )
386
-
387
- result = "\n".join(output)
388
- truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
389
- return truncated
390
-
391
- except Exception as e:
392
- return handle_api_error(e)
src/tools/leagues.py CHANGED
@@ -12,14 +12,8 @@ from ..utils import (
12
12
  format_json_response,
13
13
  handle_api_error,
14
14
  )
15
-
16
- # Import shared mcp instance
17
15
  from . import mcp
18
16
 
19
- # =============================================================================
20
- # Pydantic Input Models
21
- # =============================================================================
22
-
23
17
 
24
18
  class GetLeagueStandingsInput(BaseModel):
25
19
  """Input model for getting league standings."""
@@ -99,9 +93,45 @@ class GetManagerSquadInput(BaseModel):
99
93
  )
100
94
 
101
95
 
102
- # =============================================================================
103
- # Helper Functions
104
- # =============================================================================
96
+ class GetManagerByTeamIdInput(BaseModel):
97
+ """Input model for getting manager profile by team ID."""
98
+
99
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
100
+
101
+ team_id: int = Field(
102
+ ...,
103
+ description="Manager's team ID (entry ID)",
104
+ ge=1,
105
+ )
106
+ gameweek: int | None = Field(
107
+ default=None,
108
+ description="Gameweek number (1-38). If not provided, uses current gameweek",
109
+ ge=1,
110
+ le=38,
111
+ )
112
+ response_format: ResponseFormat = Field(
113
+ default=ResponseFormat.MARKDOWN,
114
+ description="Output format: 'markdown' for human-readable or 'json' for machine-readable",
115
+ )
116
+
117
+
118
+ class AnalyzeRivalInput(BaseModel):
119
+ """Input model for analyzing a rival manager."""
120
+
121
+ model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
122
+
123
+ my_team_id: int = Field(..., description="Your team ID", ge=1)
124
+ rival_team_id: int = Field(..., description="Rival's team ID", ge=1)
125
+ gameweek: int | None = Field(
126
+ default=None,
127
+ description="Gameweek to analyze (defaults to current)",
128
+ ge=1,
129
+ le=38,
130
+ )
131
+ response_format: ResponseFormat = Field(
132
+ default=ResponseFormat.MARKDOWN,
133
+ description="Output format: 'markdown' or 'json'",
134
+ )
105
135
 
106
136
 
107
137
  async def _create_client():
@@ -112,11 +142,6 @@ async def _create_client():
112
142
  return client
113
143
 
114
144
 
115
- # =============================================================================
116
- # MCP Tools
117
- # =============================================================================
118
-
119
-
120
145
  @mcp.tool(
121
146
  name="fpl_get_league_standings",
122
147
  annotations={
@@ -588,3 +613,329 @@ async def fpl_get_manager_squad(params: GetManagerSquadInput) -> str:
588
613
 
589
614
  except Exception as e:
590
615
  return handle_api_error(e)
616
+
617
+
618
+ @mcp.tool(
619
+ name="fpl_get_manager_by_team_id",
620
+ annotations={
621
+ "title": "Get Manager Profile by Team ID",
622
+ "readOnlyHint": True,
623
+ "destructiveHint": False,
624
+ "idempotentHint": True,
625
+ "openWorldHint": True,
626
+ },
627
+ )
628
+ async def fpl_get_manager_by_team_id(params: GetManagerByTeamIdInput) -> str:
629
+ """
630
+ Get manager profile and squad information using team ID directly.
631
+
632
+ This tool provides the same functionality as fpl_get_manager_squad but with
633
+ a name that better reflects its purpose - getting manager information without
634
+ requiring league context. Shows the 15 players picked, captain/vice-captain
635
+ choices, formation, points scored, transfers made, and automatic substitutions.
636
+
637
+ Args:
638
+ params (GetManagerByTeamIdInput): Validated input parameters containing:
639
+ - team_id (int): Manager's team ID (entry ID)
640
+ - gameweek (int | None): Gameweek number (1-38), defaults to current GW
641
+ - response_format (ResponseFormat): 'markdown' or 'json' (default: markdown)
642
+
643
+ Returns:
644
+ str: Complete manager profile with squad, statistics, and team info
645
+
646
+ Examples:
647
+ - View current squad: team_id=123456
648
+ - View specific gameweek: team_id=123456, gameweek=20
649
+ - Get as JSON: team_id=123456, response_format="json"
650
+
651
+ Error Handling:
652
+ - Returns error if team ID not found (404)
653
+ - Returns error if gameweek not started yet
654
+ - Returns formatted error message if API fails
655
+ """
656
+ try:
657
+ client = await _create_client()
658
+
659
+ # Fetch manager entry to get team name and player name
660
+ try:
661
+ entry_data = await client.get_manager_entry(params.team_id)
662
+ except Exception:
663
+ return (
664
+ f"Manager with team ID {params.team_id} not found. Verify the team ID is correct."
665
+ )
666
+
667
+ team_name = entry_data.get("name", "Unknown Team")
668
+ player_name = f"{entry_data.get('player_first_name', '')} {entry_data.get('player_last_name', '')}".strip()
669
+
670
+ # Determine which gameweek to use
671
+ gameweek = params.gameweek
672
+ if gameweek is None:
673
+ current_gw = store.get_current_gameweek()
674
+ if not current_gw:
675
+ return (
676
+ "Error: Could not determine current gameweek. Please specify a gameweek number."
677
+ )
678
+ gameweek = current_gw.id
679
+
680
+ # Fetch gameweek picks from API
681
+ picks_data = await client.get_manager_gameweek_picks(params.team_id, gameweek)
682
+
683
+ picks = picks_data.get("picks", [])
684
+ entry_history = picks_data.get("entry_history", {})
685
+ auto_subs = picks_data.get("automatic_subs", [])
686
+
687
+ if not picks:
688
+ return f"No team data found for team ID {params.team_id} in gameweek {gameweek}. Gameweek {gameweek} may not have started yet. Please choose an earlier gameweek or wait until GW{gameweek} begins."
689
+
690
+ # Rehydrate player names
691
+ element_ids = [pick["element"] for pick in picks]
692
+ players_info = store.rehydrate_player_names(element_ids)
693
+
694
+ if params.response_format == ResponseFormat.JSON:
695
+ starting_xi = [p for p in picks if p["position"] <= 11]
696
+ bench = [p for p in picks if p["position"] > 11]
697
+
698
+ result = {
699
+ "team_id": params.team_id,
700
+ "team_name": team_name,
701
+ "player_name": player_name,
702
+ "gameweek": gameweek,
703
+ "stats": {
704
+ "points": entry_history.get("points", 0),
705
+ "total_points": entry_history.get("total_points", 0),
706
+ "overall_rank": entry_history.get("overall_rank"),
707
+ "team_value": entry_history.get("value", 0) / 10,
708
+ "bank": entry_history.get("bank", 0) / 10,
709
+ "transfers": entry_history.get("event_transfers", 0),
710
+ "transfer_cost": entry_history.get("event_transfers_cost", 0),
711
+ "points_on_bench": entry_history.get("points_on_bench", 0),
712
+ },
713
+ "active_chip": picks_data.get("active_chip"),
714
+ "starting_xi": [
715
+ {
716
+ "position": pick["position"],
717
+ "player_name": players_info.get(pick["element"], {}).get(
718
+ "web_name", "Unknown"
719
+ ),
720
+ "team": players_info.get(pick["element"], {}).get("team", "UNK"),
721
+ "player_position": players_info.get(pick["element"], {}).get(
722
+ "position", "UNK"
723
+ ),
724
+ "price": players_info.get(pick["element"], {}).get("price", 0),
725
+ "is_captain": pick["is_captain"],
726
+ "is_vice_captain": pick["is_vice_captain"],
727
+ "multiplier": pick["multiplier"],
728
+ }
729
+ for pick in starting_xi
730
+ ],
731
+ "bench": [
732
+ {
733
+ "position": pick["position"],
734
+ "player_name": players_info.get(pick["element"], {}).get(
735
+ "web_name", "Unknown"
736
+ ),
737
+ "team": players_info.get(pick["element"], {}).get("team", "UNK"),
738
+ "player_position": players_info.get(pick["element"], {}).get(
739
+ "position", "UNK"
740
+ ),
741
+ "price": players_info.get(pick["element"], {}).get("price", 0),
742
+ }
743
+ for pick in bench
744
+ ],
745
+ "automatic_subs": [
746
+ {
747
+ "player_out": store.get_player_name(sub["element_out"]),
748
+ "player_in": store.get_player_name(sub["element_in"]),
749
+ }
750
+ for sub in auto_subs
751
+ ],
752
+ }
753
+ return format_json_response(result)
754
+ else:
755
+ result = format_manager_squad(
756
+ team_name=team_name,
757
+ player_name=player_name,
758
+ team_id=params.team_id,
759
+ gameweek=gameweek,
760
+ entry_history=entry_history,
761
+ picks=picks,
762
+ players_info=players_info,
763
+ active_chip=picks_data.get("active_chip"),
764
+ )
765
+
766
+ if auto_subs:
767
+ result += "\n\n**Automatic Substitutions:**"
768
+ for sub in auto_subs:
769
+ player_out = store.get_player_name(sub["element_out"])
770
+ player_in = store.get_player_name(sub["element_in"])
771
+ result += f"\n├─ {player_out} → {player_in}"
772
+
773
+ truncated, _ = check_and_truncate(result, CHARACTER_LIMIT)
774
+ return truncated
775
+
776
+ except Exception as e:
777
+ return handle_api_error(e)
778
+
779
+
780
+ @mcp.tool(
781
+ name="fpl_analyze_rival",
782
+ annotations={
783
+ "title": "Analyze FPL Rival",
784
+ "readOnlyHint": True,
785
+ "destructiveHint": False,
786
+ "idempotentHint": True,
787
+ "openWorldHint": True,
788
+ },
789
+ )
790
+ async def fpl_analyze_rival(params: AnalyzeRivalInput) -> str:
791
+ """
792
+ Compare your team against a specific rival manager.
793
+
794
+ Provides a comprehensive head-to-head analysis including:
795
+ - Points and Rank comparison
796
+ - Chip usage history
797
+ - Captaincy comparison
798
+ - Key Differentials (players they own that you don't)
799
+
800
+ Args:
801
+ params (AnalyzeRivalInput): Validated input parameters containing:
802
+ - my_team_id (int): Your team ID
803
+ - rival_team_id (int): Rival's team ID
804
+ - gameweek (int | None): Gameweek number (defaults to current)
805
+
806
+ Returns:
807
+ str: Detailed rival analysis and threat assessment
808
+
809
+ Examples:
810
+ - Compare me vs rival: my_team_id=123, rival_team_id=456
811
+ - Analyze past GW: my_team_id=123, rival_team_id=456, gameweek=10
812
+
813
+ Error Handling:
814
+ - Returns error if either team ID invalid
815
+ - Returns formatted error message if API fails
816
+ """
817
+ try:
818
+ client = await _create_client()
819
+
820
+ # Determine gameweek
821
+ gameweek = params.gameweek
822
+ if not gameweek:
823
+ current = store.get_current_gameweek()
824
+ if not current:
825
+ return "Error: Could not determine current gameweek."
826
+ gameweek = current.id
827
+
828
+ # Fetch data for both managers
829
+ try:
830
+ my_entry = await client.get_manager_entry(params.my_team_id)
831
+ rival_entry = await client.get_manager_entry(params.rival_team_id)
832
+ except Exception:
833
+ return "Error: Could not retrieve manager details. Check Team IDs."
834
+
835
+ my_picks_data = await client.get_manager_gameweek_picks(params.my_team_id, gameweek)
836
+ rival_picks_data = await client.get_manager_gameweek_picks(params.rival_team_id, gameweek)
837
+
838
+ my_name = f"{my_entry.get('player_first_name')} {my_entry.get('player_last_name')}"
839
+ rival_name = f"{rival_entry.get('player_first_name')} {rival_entry.get('player_last_name')}"
840
+
841
+ my_team_name = my_entry.get("name")
842
+ rival_team_name = rival_entry.get("name")
843
+
844
+ # Extract picks (Starting XI + Bench)
845
+ my_picks = my_picks_data.get("picks", [])
846
+ rival_picks = rival_picks_data.get("picks", [])
847
+
848
+ # Helper to get active players (Starting XI - first 11)
849
+ # Note: Position 1-11 are starters, 12-15 bench
850
+ my_starters = {p["element"] for p in my_picks if p["position"] <= 11}
851
+ rival_starters = {p["element"] for p in rival_picks if p["position"] <= 11}
852
+
853
+ # Differentials (My unique vs Rival unique)
854
+ my_unique = my_starters - rival_starters
855
+ rival_unique = rival_starters - my_starters
856
+ common = my_starters & rival_starters
857
+
858
+ # Captains
859
+ my_cap = next((p for p in my_picks if p["is_captain"]), None)
860
+ rival_cap = next((p for p in rival_picks if p["is_captain"]), None)
861
+
862
+ my_cap_name = store.get_player_name(my_cap["element"]) if my_cap else "None"
863
+ rival_cap_name = store.get_player_name(rival_cap["element"]) if rival_cap else "None"
864
+
865
+ # Stats
866
+ my_history = my_picks_data.get("entry_history", {})
867
+ rival_history = rival_picks_data.get("entry_history", {})
868
+
869
+ if params.response_format == ResponseFormat.JSON:
870
+ result = {
871
+ "gameweek": gameweek,
872
+ "managers": {
873
+ "me": {
874
+ "name": my_name,
875
+ "team": my_team_name,
876
+ "points": my_history.get("points"),
877
+ "rank": my_history.get("overall_rank"),
878
+ },
879
+ "rival": {
880
+ "name": rival_name,
881
+ "team": rival_team_name,
882
+ "points": rival_history.get("points"),
883
+ "rank": rival_history.get("overall_rank"),
884
+ },
885
+ },
886
+ "comparison": {
887
+ "common_players_count": len(common),
888
+ "differentials_count": len(rival_unique),
889
+ "points_diff": my_history.get("points", 0) - rival_history.get("points", 0),
890
+ },
891
+ "captains": {"me": my_cap_name, "rival": rival_cap_name},
892
+ "rival_differentials": [store.get_player_name(pid) for pid in rival_unique],
893
+ }
894
+ return format_json_response(result)
895
+
896
+ # Markdown Output
897
+ output = [
898
+ f"## Rival Analysis: {my_name} vs {rival_name}",
899
+ f"**Gameweek {gameweek}**",
900
+ "",
901
+ "### 🏆 Performance & Rank",
902
+ f"| Metric | 👤 You ({my_team_name}) | 🆚 Rival ({rival_team_name}) | Diff |",
903
+ "| :--- | :--- | :--- | :--- |",
904
+ f"| **GW Points** | {my_history.get('points', 0)} | {rival_history.get('points', 0)} | {my_history.get('points', 0) - rival_history.get('points', 0):+d} |",
905
+ f"| **Total Pts** | {my_history.get('total_points', 0)} | {rival_history.get('total_points', 0)} | {my_history.get('total_points', 0) - rival_history.get('total_points', 0):+d} |",
906
+ f"| **Rank** | {my_history.get('overall_rank', 0):,} | {rival_history.get('overall_rank', 0):,} | --- |",
907
+ f"| **Captain** | {my_cap_name} | {rival_cap_name} | {'✅ Same' if my_cap['element'] == rival_cap['element'] else '⚠️ Diff'} |",
908
+ f"| **Chip** | {my_picks_data.get('active_chip') or 'None'} | {rival_picks_data.get('active_chip') or 'None'} | --- |",
909
+ "",
910
+ "### ⚠️ Threat Assessment (Differentials)",
911
+ "Players in their starting XI that you DO NOT have:",
912
+ "",
913
+ ]
914
+
915
+ if rival_unique:
916
+ for pid in rival_unique:
917
+ p_name = store.get_player_name(pid)
918
+ # Get live points if possible
919
+ p_data = next((p for p in store.bootstrap_data.elements if p.id == pid), None)
920
+ points = p_data.event_points if p_data else "?"
921
+ output.append(f"- **{p_name}** ({points} pts)")
922
+ else:
923
+ output.append("No starting XI differentials! You have a full template match.")
924
+
925
+ output.append("")
926
+ output.append("### 🛡️ Your Advantages")
927
+ output.append("Players you have that they don't:")
928
+
929
+ if my_unique:
930
+ for pid in my_unique:
931
+ p_name = store.get_player_name(pid)
932
+ p_data = next((p for p in store.bootstrap_data.elements if p.id == pid), None)
933
+ points = p_data.event_points if p_data else "?"
934
+ output.append(f"- **{p_name}** ({points} pts)")
935
+ else:
936
+ output.append("No unique players.")
937
+
938
+ return "\n".join(output)
939
+
940
+ except Exception as e:
941
+ return handle_api_error(e)