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.
- fpl_mcp_server-0.1.3.dist-info/METADATA +137 -0
- fpl_mcp_server-0.1.3.dist-info/RECORD +33 -0
- fpl_mcp_server-0.1.3.dist-info/WHEEL +4 -0
- fpl_mcp_server-0.1.3.dist-info/entry_points.txt +2 -0
- fpl_mcp_server-0.1.3.dist-info/licenses/LICENSE +21 -0
- src/cache.py +162 -0
- src/client.py +273 -0
- src/config.py +33 -0
- src/constants.py +118 -0
- src/exceptions.py +114 -0
- src/formatting.py +299 -0
- src/main.py +41 -0
- src/models.py +526 -0
- src/prompts/__init__.py +18 -0
- src/prompts/chips.py +127 -0
- src/prompts/league_analysis.py +250 -0
- src/prompts/player_analysis.py +141 -0
- src/prompts/squad_analysis.py +136 -0
- src/prompts/team_analysis.py +121 -0
- src/prompts/transfers.py +167 -0
- src/rate_limiter.py +101 -0
- src/resources/__init__.py +13 -0
- src/resources/bootstrap.py +183 -0
- src/state.py +443 -0
- src/tools/__init__.py +25 -0
- src/tools/fixtures.py +162 -0
- src/tools/gameweeks.py +392 -0
- src/tools/leagues.py +590 -0
- src/tools/players.py +840 -0
- src/tools/teams.py +397 -0
- src/tools/transfers.py +629 -0
- src/utils.py +226 -0
- src/validators.py +290 -0
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)
|