fpl-mcp-server 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/constants.py ADDED
@@ -0,0 +1,118 @@
1
+ """
2
+ Constants and enums for the FPL MCP Server.
3
+ """
4
+
5
+ from enum import Enum
6
+ from http import HTTPStatus
7
+
8
+ # =============================================================================
9
+ # HTTP Status Codes
10
+ # =============================================================================
11
+ SUCCESS_STATUS = HTTPStatus.OK
12
+ NOT_FOUND_STATUS = HTTPStatus.NOT_FOUND
13
+ SERVER_ERROR_STATUS = HTTPStatus.INTERNAL_SERVER_ERROR
14
+ RATE_LIMIT_STATUS = HTTPStatus.TOO_MANY_REQUESTS
15
+
16
+ # =============================================================================
17
+ # Fuzzy Matching Constants
18
+ # =============================================================================
19
+ FUZZY_MATCH_THRESHOLD = 0.6 # Minimum similarity for fuzzy matches
20
+ SUBSTRING_MATCH_PENALTY = 0.9 # Score multiplier for substring matches
21
+ FUZZY_MATCH_PENALTY = 0.8 # Score multiplier for fuzzy matches
22
+ PERFECT_MATCH_SCORE = 1.0 # Score for exact matches
23
+
24
+ # =============================================================================
25
+ # Cache TTL (Time To Live) in Seconds
26
+ # =============================================================================
27
+ DEFAULT_BOOTSTRAP_TTL = 14400 # 4 hours
28
+ DEFAULT_FIXTURES_TTL = 14400 # 4 hours
29
+ DEFAULT_PLAYER_SUMMARY_TTL = 300 # 5 minutes
30
+
31
+ # =============================================================================
32
+ # Rate Limiting
33
+ # =============================================================================
34
+ MAX_AUTH_ATTEMPTS = 5 # Maximum login attempts
35
+ AUTH_WINDOW_SECONDS = 300 # Time window for rate limiting (5 minutes)
36
+
37
+ # =============================================================================
38
+ # API Timeouts
39
+ # =============================================================================
40
+ DEFAULT_HTTP_TIMEOUT = 30 # Default timeout for HTTP requests
41
+ BROWSER_TIMEOUT = 15 # Browser automation timeout
42
+
43
+
44
+ # =============================================================================
45
+ # Difficulty Ratings
46
+ # =============================================================================
47
+ class FixtureDifficulty(Enum):
48
+ """FPL fixture difficulty ratings"""
49
+
50
+ VERY_EASY = 1
51
+ EASY = 2
52
+ MODERATE = 3
53
+ HARD = 4
54
+ VERY_HARD = 5
55
+
56
+
57
+ # Difficulty emoji mapping
58
+ DIFFICULTY_EMOJI = {
59
+ FixtureDifficulty.VERY_EASY: "🟢",
60
+ FixtureDifficulty.EASY: "🟢",
61
+ FixtureDifficulty.MODERATE: "🟡",
62
+ FixtureDifficulty.HARD: "🟠",
63
+ FixtureDifficulty.VERY_HARD: "🔴",
64
+ 1: "🟢",
65
+ 2: "🟢",
66
+ 3: "🟡",
67
+ 4: "🟠",
68
+ 5: "🔴",
69
+ }
70
+
71
+
72
+ # =============================================================================
73
+ # Player Positions
74
+ # =============================================================================
75
+ class PlayerPosition(Enum):
76
+ """FPL player positions"""
77
+
78
+ GOALKEEPER = "GKP"
79
+ DEFENDER = "DEF"
80
+ MIDFIELDER = "MID"
81
+ FORWARD = "FWD"
82
+
83
+
84
+ # =============================================================================
85
+ # Top Players Count
86
+ # =============================================================================
87
+ TOP_GOALKEEPERS_COUNT = 3
88
+ TOP_OUTFIELD_PLAYERS_COUNT = 10
89
+
90
+ # =============================================================================
91
+ # Pagination
92
+ # =============================================================================
93
+ DEFAULT_PAGE_SIZE = 50 # FPL API default league standings page size
94
+ DEFAULT_PAGINATION_LIMIT = 20 # MCP recommended default limit for tool responses
95
+ MAX_PAGINATION_LIMIT = 100 # MCP recommended maximum limit
96
+
97
+ # =============================================================================
98
+ # MCP Response Configuration
99
+ # =============================================================================
100
+ CHARACTER_LIMIT = 25000 # Maximum response size in characters (MCP best practice)
101
+
102
+ # =============================================================================
103
+ # FPL URLs
104
+ # =============================================================================
105
+ FPL_BASE_URL = "https://fantasy.premierleague.com"
106
+ FPL_API_BASE = f"{FPL_BASE_URL}/api"
107
+
108
+ # =============================================================================
109
+ # Logging
110
+ # =============================================================================
111
+ DEFAULT_LOG_LEVEL = "INFO"
112
+ WEB_SERVER_LOG_LEVEL = "critical" # Quiet web server logs
113
+
114
+ # =============================================================================
115
+ # Server Configuration
116
+ # =============================================================================
117
+ DEFAULT_WEB_SERVER_HOST = "0.0.0.0"
118
+ DEFAULT_WEB_SERVER_PORT = 8000
src/exceptions.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Custom exceptions for the FPL MCP Server.
3
+ """
4
+
5
+
6
+ class FPLMCPError(Exception):
7
+ """Base exception for FPL MCP Server."""
8
+
9
+ pass
10
+
11
+
12
+ class AuthenticationError(FPLMCPError):
13
+ """Raised when authentication fails or is required."""
14
+
15
+ pass
16
+
17
+
18
+ class DataFetchError(FPLMCPError):
19
+ """Raised when data cannot be fetched from the FPL API."""
20
+
21
+ pass
22
+
23
+
24
+ class CacheError(FPLMCPError):
25
+ """Raised when cache operations fail."""
26
+
27
+ pass
28
+
29
+
30
+ class PlayerNotFoundError(FPLMCPError):
31
+ """Raised when a player cannot be found."""
32
+
33
+ def __init__(self, player_name: str, suggestions: list[str] = None):
34
+ self.player_name = player_name
35
+ self.suggestions = suggestions or []
36
+
37
+ message = f"No player found matching '{player_name}'."
38
+ if suggestions:
39
+ message += f" Did you mean: {', '.join(suggestions[:3])}?"
40
+
41
+ super().__init__(message)
42
+
43
+
44
+ class TeamNotFoundError(FPLMCPError):
45
+ """Raised when a team cannot be found."""
46
+
47
+ def __init__(self, team_name: str, suggestions: list[str] = None):
48
+ self.team_name = team_name
49
+ self.suggestions = suggestions or []
50
+
51
+ message = f"No team found matching '{team_name}'."
52
+ if suggestions:
53
+ message += f" Did you mean: {', '.join(suggestions[:3])}?"
54
+
55
+ super().__init__(message)
56
+
57
+
58
+ class LeagueNotFoundError(FPLMCPError):
59
+ """Raised when a league cannot be found."""
60
+
61
+ def __init__(self, league_name: str):
62
+ self.league_name = league_name
63
+ message = f"League '{league_name}' not found in your leagues."
64
+ super().__init__(message)
65
+
66
+
67
+ class ManagerNotFoundError(FPLMCPError):
68
+ """Raised when a manager cannot be found in a league."""
69
+
70
+ def __init__(self, manager_name: str, league_name: str):
71
+ self.manager_name = manager_name
72
+ self.league_name = league_name
73
+ message = f"Manager '{manager_name}' not found in league '{league_name}'."
74
+ super().__init__(message)
75
+
76
+
77
+ class GameweekNotFoundError(FPLMCPError):
78
+ """Raised when a gameweek cannot be found."""
79
+
80
+ def __init__(self, gameweek: int):
81
+ self.gameweek = gameweek
82
+ message = f"Gameweek {gameweek} not found or not valid."
83
+ super().__init__(message)
84
+
85
+
86
+ class TransferError(FPLMCPError):
87
+ """Raised when a transfer operation fails."""
88
+
89
+ def __init__(self, message: str, details: dict = None):
90
+ self.details = details or {}
91
+ super().__init__(message)
92
+
93
+
94
+ class RateLimitError(FPLMCPError):
95
+ """Raised when rate limit is exceeded."""
96
+
97
+ def __init__(self, retry_after: int = None):
98
+ self.retry_after = retry_after
99
+ message = "Rate limit exceeded."
100
+ if retry_after:
101
+ message += f" Please try again in {retry_after} seconds."
102
+ super().__init__(message)
103
+
104
+
105
+ class ValidationError(FPLMCPError):
106
+ """Raised when input validation fails."""
107
+
108
+ pass
109
+
110
+
111
+ class ScraperError(FPLMCPError):
112
+ """Raised when web scraping operations fail."""
113
+
114
+ pass
src/formatting.py ADDED
@@ -0,0 +1,299 @@
1
+ """
2
+ Shared formatting utilities for FPL MCP tools and resources.
3
+ Centralizes Markdown generation logic to ensure consistency and reduce duplication.
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from .utils import format_player_price
9
+
10
+
11
+ def format_player_details(
12
+ player: Any,
13
+ history: list[dict[str, Any]] | None = None,
14
+ fixtures: list[dict[str, Any]] | None = None,
15
+ ) -> str:
16
+ """
17
+ Format comprehensive player information including fixtures and history.
18
+
19
+ Args:
20
+ player: ElementData object or similar having player attributes
21
+ history: Optional list of past gameweek performance dictionaries
22
+ fixtures: Optional list of upcoming fixture dictionaries
23
+
24
+ Returns:
25
+ Formatted markdown string
26
+ """
27
+ # Handle potentially missing attributes
28
+ team_name = getattr(player, "team_name", "Unknown")
29
+ position = getattr(player, "position", "Unknown")
30
+
31
+ price_val = getattr(player, "now_cost", 0)
32
+ price = format_player_price(price_val)
33
+
34
+ output = [
35
+ f"**{player.web_name}** ({player.first_name} {player.second_name})",
36
+ f"Team: {team_name} | Position: {position} | Price: {price}",
37
+ "",
38
+ ]
39
+
40
+ # Upcoming Fixtures
41
+ if fixtures:
42
+ output.append(f"**Upcoming Fixtures ({len(fixtures)}):**")
43
+ for fixture in fixtures[:5]:
44
+ opponent_name = (
45
+ fixture.get("team_h_short")
46
+ if not fixture["is_home"]
47
+ else fixture.get("team_a_short", "Unknown")
48
+ )
49
+ home_away = "H" if fixture["is_home"] else "A"
50
+ difficulty = "●" * fixture["difficulty"]
51
+
52
+ output.append(
53
+ f"├─ GW{fixture['event']}: vs {opponent_name} ({home_away}) | "
54
+ f"Difficulty: {difficulty} ({fixture['difficulty']}/5)"
55
+ )
56
+ output.append("")
57
+
58
+ # Recent Gameweek History
59
+ if history:
60
+ recent_history = history[-5:]
61
+ output.append(f"**Recent Performance (Last {len(recent_history)} GWs):**")
62
+
63
+ for gw in recent_history:
64
+ opponent_name = gw.get("opponent_team_short", "Unknown")
65
+ home_away = "H" if gw["was_home"] else "A"
66
+ xg = gw.get("expected_goals", "0.00")
67
+ xa = gw.get("expected_assists", "0.00")
68
+
69
+ output.append(
70
+ f"├─ GW{gw['round']}: {gw['total_points']}pts vs {opponent_name} ({home_away}) | "
71
+ f"{gw['minutes']}min | xG: {xg} G:{gw['goals_scored']} xA: {xa} A:{gw['assists']} "
72
+ f"CS:{gw['clean_sheets']} | Bonus: {gw['bonus']}"
73
+ )
74
+
75
+ total_points = sum(gw["total_points"] for gw in recent_history)
76
+ avg_points = total_points / len(recent_history) if recent_history else 0
77
+ total_minutes = sum(gw["minutes"] for gw in recent_history)
78
+ avg_minutes = total_minutes / len(recent_history) if recent_history else 0
79
+
80
+ output.extend(
81
+ [
82
+ "",
83
+ "**Recent Averages:**",
84
+ f"├─ Points per game: {avg_points:.1f}",
85
+ f"├─ Minutes per game: {avg_minutes:.0f}",
86
+ "",
87
+ ]
88
+ )
89
+
90
+ # Performance stats
91
+ output.extend(
92
+ [
93
+ "**Performance:**",
94
+ f"├─ Form: {player.form}",
95
+ f"├─ Points per Game: {player.points_per_game}",
96
+ f"├─ Total Points: {getattr(player, 'total_points', 'N/A')}",
97
+ f"├─ Minutes: {getattr(player, 'minutes', 'N/A')}",
98
+ "",
99
+ ]
100
+ )
101
+
102
+ # Popularity
103
+ if hasattr(player, "selected_by_percent"):
104
+ output.extend(
105
+ [
106
+ "**Popularity:**",
107
+ f"├─ Selected by: {getattr(player, 'selected_by_percent', 'N/A')}%",
108
+ f"├─ Transfers in (GW): {getattr(player, 'transfers_in_event', 'N/A')}",
109
+ f"├─ Transfers out (GW): {getattr(player, 'transfers_out_event', 'N/A')}",
110
+ "",
111
+ ]
112
+ )
113
+
114
+ # Season stats
115
+ if hasattr(player, "goals_scored"):
116
+ output.extend(
117
+ [
118
+ "**Season Stats:**",
119
+ f"├─ Goals: {getattr(player, 'goals_scored', 0)}",
120
+ f"├─ Assists: {getattr(player, 'assists', 0)}",
121
+ f"├─ Clean Sheets: {getattr(player, 'clean_sheets', 0)}",
122
+ f"├─ Bonus Points: {getattr(player, 'bonus', 0)}",
123
+ ]
124
+ )
125
+
126
+ return "\n".join(output)
127
+
128
+
129
+ def format_team_details(team: dict[str, Any]) -> str:
130
+ """Format team detailed information and strength ratings."""
131
+ name = team.get("name", "Unknown")
132
+ short_name = team.get("short_name", "UNK")
133
+
134
+ output = [f"**{name} ({short_name})**", ""]
135
+
136
+ if team.get("strength"):
137
+ output.append(f"Overall Strength: {team['strength']}")
138
+
139
+ if team.get("strength_overall_home") or team.get("strength_overall_away"):
140
+ output.extend(
141
+ [
142
+ "",
143
+ "**Overall Strength:**",
144
+ f"Home: {team.get('strength_overall_home', 'N/A')}",
145
+ f"Away: {team.get('strength_overall_away', 'N/A')}",
146
+ ]
147
+ )
148
+
149
+ if team.get("strength_attack_home") or team.get("strength_attack_away"):
150
+ output.extend(
151
+ [
152
+ "",
153
+ "**Attack Strength:**",
154
+ f"Home: {team.get('strength_attack_home', 'N/A')}",
155
+ f"Away: {team.get('strength_attack_away', 'N/A')}",
156
+ ]
157
+ )
158
+
159
+ if team.get("strength_defence_home") or team.get("strength_defence_away"):
160
+ output.extend(
161
+ [
162
+ "",
163
+ "**Defence Strength:**",
164
+ f"Home: {team.get('strength_defence_home', 'N/A')}",
165
+ f"Away: {team.get('strength_defence_away', 'N/A')}",
166
+ ]
167
+ )
168
+
169
+ return "\n".join(output)
170
+
171
+
172
+ def format_gameweek_details(event: Any) -> str:
173
+ """Format detailed gameweek information."""
174
+ # Handle object or dict access if needed, assuming event is an object from bootstrap data
175
+ is_current = getattr(event, "is_current", False)
176
+ is_previous = getattr(event, "is_previous", False)
177
+ is_next = getattr(event, "is_next", False)
178
+
179
+ status_label = "Upcoming"
180
+ if is_current:
181
+ status_label = "Current"
182
+ elif is_previous:
183
+ status_label = "Previous"
184
+ elif is_next:
185
+ status_label = "Next"
186
+
187
+ output = [
188
+ f"**{event.name}**",
189
+ f"Deadline: {event.deadline_time}",
190
+ f"Status: {status_label}",
191
+ f"Finished: {event.finished}",
192
+ f"Released: {getattr(event, 'released', 'N/A')}",
193
+ "",
194
+ ]
195
+
196
+ if event.finished:
197
+ output.extend(
198
+ [
199
+ "**Statistics:**",
200
+ f"Average Score: {event.average_entry_score}",
201
+ f"Highest Score: {event.highest_score}",
202
+ "",
203
+ ]
204
+ )
205
+
206
+ # Note: top_element_info logic usually requires a separate name lookup
207
+ # so we'll leave that to the caller to append if they have the name
208
+
209
+ return "\n".join(output)
210
+
211
+
212
+ def format_manager_squad(
213
+ team_name: str,
214
+ player_name: str,
215
+ team_id: int,
216
+ gameweek: int,
217
+ entry_history: dict[str, Any],
218
+ picks: list[dict[str, Any]],
219
+ players_info: dict[int, Any],
220
+ active_chip: str | None = None,
221
+ auto_subs: list[dict[str, Any]] = None,
222
+ ) -> str:
223
+ """Format a manager's squad for a specific gameweek."""
224
+
225
+ output = [
226
+ f"**{team_name}** - {player_name}" if player_name else f"**{team_name}**",
227
+ f"Team ID: {team_id}",
228
+ f"Gameweek {gameweek}",
229
+ f"Points: {entry_history.get('points', 0)} | Total: {entry_history.get('total_points', 0)}",
230
+ f"Overall Rank: {entry_history.get('overall_rank', 'N/A'):,}",
231
+ f"Team Value: £{entry_history.get('value', 0) / 10:.1f}m | Bank: £{entry_history.get('bank', 0) / 10:.1f}m",
232
+ f"Transfers: {entry_history.get('event_transfers', 0)} (Cost: {entry_history.get('event_transfers_cost', 0)}pts)",
233
+ f"Points on Bench: {entry_history.get('points_on_bench', 0)}",
234
+ "",
235
+ ]
236
+
237
+ if active_chip:
238
+ output.append(f"**Active Chip:** {active_chip}")
239
+ output.append("")
240
+
241
+ starting_xi = [p for p in picks if p["position"] <= 11]
242
+ bench = [p for p in picks if p["position"] > 11]
243
+
244
+ output.append("**Starting XI:**")
245
+ for pick in starting_xi:
246
+ player = players_info.get(pick["element"], {})
247
+ role = " (C)" if pick["is_captain"] else " (VC)" if pick["is_vice_captain"] else ""
248
+ multiplier = f" x{pick['multiplier']}" if pick["multiplier"] > 1 else ""
249
+
250
+ output.append(
251
+ f"{pick['position']:2d}. {player.get('web_name', 'Unknown'):15s} "
252
+ f"({player.get('team', 'UNK'):3s} {player.get('position', 'UNK')}) | "
253
+ f"£{player.get('price', 0):.1f}m{role}{multiplier}"
254
+ )
255
+
256
+ output.append("\n**Bench:**")
257
+ for pick in bench:
258
+ player = players_info.get(pick["element"], {})
259
+ output.append(
260
+ f"{pick['position']:2d}. {player.get('web_name', 'Unknown'):15s} "
261
+ f"({player.get('team', 'UNK'):3s} {player.get('position', 'UNK')}) | "
262
+ f"£{player.get('price', 0):.1f}m"
263
+ )
264
+
265
+ # or we handle it if we pass the names.
266
+ # For simplicity, let's assume the caller handles auto_subs appending
267
+ # or we handle it if we pass the names.
268
+
269
+ return "\n".join(output)
270
+
271
+
272
+ def format_difficulty_indicator(difficulty: int) -> str:
273
+ """
274
+ Format fixture difficulty as visual indicator.
275
+
276
+ Args:
277
+ difficulty: Difficulty rating (1-5)
278
+
279
+ Returns:
280
+ Visual difficulty indicator
281
+ """
282
+ return "●" * difficulty + "○" * (5 - difficulty)
283
+
284
+
285
+ def format_markdown_table_row(items: list[str], widths: list[int] | None = None) -> str:
286
+ """
287
+ Format a markdown table row with proper spacing.
288
+
289
+ Args:
290
+ items: List of cell contents
291
+ widths: Optional list of column widths for padding
292
+
293
+ Returns:
294
+ Formatted markdown table row
295
+ """
296
+ if widths:
297
+ padded = [str(item).ljust(width) for item, width in zip(items, widths, strict=True)]
298
+ return "| " + " | ".join(padded) + " |"
299
+ return "| " + " | ".join(str(item) for item in items) + " |"
src/main.py ADDED
@@ -0,0 +1,41 @@
1
+ """FPL MCP Server - Main Entry Point"""
2
+
3
+ import sys
4
+ import traceback
5
+
6
+ sys.stderr.write("Starting FPL MCP Server...\n")
7
+ sys.stderr.flush()
8
+
9
+ try:
10
+ from .tools import mcp
11
+
12
+ sys.stderr.write("Imports successful. Initializing MCP server...\n")
13
+ sys.stderr.flush()
14
+ except Exception as e:
15
+ sys.stderr.write(f"CRITICAL IMPORT ERROR: {e}\n")
16
+ traceback.print_exc(file=sys.stderr)
17
+ sys.stderr.flush()
18
+ sys.exit(1)
19
+
20
+
21
+ def main():
22
+ """Run the MCP server on stdio transport."""
23
+ try:
24
+ sys.stderr.write("Starting MCP Server (Stdio)...\n")
25
+ sys.stderr.flush()
26
+
27
+ # Run MCP server - blocks waiting for MCP protocol messages
28
+ mcp.run(transport="stdio")
29
+
30
+ sys.stderr.write("MCP Server stopped.\n")
31
+ sys.stderr.flush()
32
+
33
+ except Exception as e:
34
+ sys.stderr.write(f"RUNTIME ERROR: {e}\n")
35
+ traceback.print_exc(file=sys.stderr)
36
+ sys.stderr.flush()
37
+ sys.exit(1)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()