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/utils.py ADDED
@@ -0,0 +1,226 @@
1
+ """Shared utility functions for FPL MCP Server tools."""
2
+
3
+ from enum import Enum
4
+ import json
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+
10
+ class ResponseFormat(str, Enum):
11
+ """Output format for tool responses."""
12
+
13
+ MARKDOWN = "markdown"
14
+ JSON = "json"
15
+
16
+
17
+ def handle_api_error(e: Exception) -> str:
18
+ """
19
+ Consistent error formatting across all tools with actionable guidance.
20
+
21
+ Args:
22
+ e: The exception that was raised
23
+
24
+ Returns:
25
+ User-friendly error message with guidance on next steps
26
+ """
27
+ if isinstance(e, httpx.HTTPStatusError):
28
+ if e.response.status_code == 404:
29
+ return (
30
+ "Error: Resource not found. The ID or name you provided doesn't match any existing resources.\n"
31
+ "Next steps:\n"
32
+ "• For players: Use fpl_search_players or fpl_find_player to find the correct name\n"
33
+ "• For teams: Use fpl_list_all_teams to see all available team names\n"
34
+ "• For leagues: Verify the league ID from the FPL website URL (e.g., /leagues/12345/standings/)\n"
35
+ "• For managers: Check the exact name in league standings using fpl_get_league_standings"
36
+ )
37
+ elif e.response.status_code == 403:
38
+ return (
39
+ "Error: Access denied. You don't have permission to access this resource.\n"
40
+ "This may be because:\n"
41
+ "• The resource is private (some leagues are private)\n"
42
+ "• The manager has restricted access to their team\n"
43
+ "Try accessing public leagues or teams instead."
44
+ )
45
+ elif e.response.status_code == 429:
46
+ return (
47
+ "Error: Rate limit exceeded. The FPL API has temporarily blocked too many requests.\n"
48
+ "Next steps:\n"
49
+ "• Wait 60 seconds before making more requests\n"
50
+ "• Reduce the number of consecutive API calls\n"
51
+ "• Use fpl:// resources instead of tools when possible (they're cached)"
52
+ )
53
+ elif e.response.status_code == 500:
54
+ return (
55
+ "Error: FPL API server error. The Fantasy Premier League servers are experiencing issues.\n"
56
+ "Next steps:\n"
57
+ "• Try again in a few minutes\n"
58
+ "• Check if the FPL website is working: https://fantasy.premierleague.com\n"
59
+ "• This is usually temporary during high traffic periods"
60
+ )
61
+ return (
62
+ f"Error: API request failed with status {e.response.status_code}.\n"
63
+ "Try the following:\n"
64
+ "• Verify your input parameters are correct\n"
65
+ "• Check the FPL API status\n"
66
+ "• Try again in a few moments"
67
+ )
68
+ elif isinstance(e, httpx.TimeoutException):
69
+ return (
70
+ "Error: Request timed out. The FPL API is taking too long to respond.\n"
71
+ "Next steps:\n"
72
+ "• Try again - the API may be under heavy load\n"
73
+ "• Use more specific filters to reduce response size\n"
74
+ "• Check your internet connection"
75
+ )
76
+ elif isinstance(e, httpx.ConnectError):
77
+ return (
78
+ "Error: Cannot connect to FPL API. Network connection failed.\n"
79
+ "Next steps:\n"
80
+ "• Check your internet connection\n"
81
+ "• Verify you can access https://fantasy.premierleague.com\n"
82
+ "• Try again in a moment"
83
+ )
84
+ return f"Error: {type(e).__name__}: {str(e)}"
85
+
86
+
87
+ def check_and_truncate(
88
+ content: str, character_limit: int, additional_message: str | None = None
89
+ ) -> tuple[str, bool]:
90
+ """
91
+ Check if content exceeds character limit and truncate if needed.
92
+
93
+ Args:
94
+ content: The content to check
95
+ character_limit: Maximum allowed characters
96
+ additional_message: Optional message to append if truncated
97
+
98
+ Returns:
99
+ Tuple of (possibly truncated content, was_truncated boolean)
100
+ """
101
+ if len(content) <= character_limit:
102
+ return content, False
103
+
104
+ # Truncate to ~80% of limit to leave room for truncation message
105
+ truncate_to = int(character_limit * 0.8)
106
+ truncated = content[:truncate_to]
107
+
108
+ # Add truncation notice
109
+ truncation_msg = f"\n\n[Response truncated - exceeded {character_limit:,} character limit. "
110
+ if additional_message:
111
+ truncation_msg += additional_message + "]"
112
+ else:
113
+ truncation_msg += "Use more specific filters to reduce results.]"
114
+
115
+ return truncated + truncation_msg, True
116
+
117
+
118
+ def create_pagination_metadata(total: int, count: int, limit: int, offset: int) -> dict[str, Any]:
119
+ """
120
+ Create standardized pagination metadata.
121
+
122
+ Args:
123
+ total: Total number of items available
124
+ count: Number of items in current response
125
+ limit: Maximum items requested
126
+ offset: Current offset
127
+
128
+ Returns:
129
+ Dictionary with pagination metadata
130
+ """
131
+ has_more = total > offset + count
132
+ next_offset = offset + count if has_more else None
133
+
134
+ return {
135
+ "total": total,
136
+ "count": count,
137
+ "limit": limit,
138
+ "offset": offset,
139
+ "has_more": has_more,
140
+ "next_offset": next_offset,
141
+ }
142
+
143
+
144
+ def format_player_price(price_in_tenths: int) -> str:
145
+ """Format player price from tenths to pounds."""
146
+ return f"£{price_in_tenths / 10:.1f}m"
147
+
148
+
149
+ def format_status_indicator(status: str, news: str | None = None) -> str:
150
+ """
151
+ Format player status with indicators.
152
+
153
+ Args:
154
+ status: Player status code ('a', 'i', 'd', 'u', 's', 'n')
155
+ news: Optional news text
156
+
157
+ Returns:
158
+ Formatted status string with indicators
159
+ """
160
+ indicator = ""
161
+ if status != "a":
162
+ status_map = {
163
+ "i": "Injured",
164
+ "d": "Doubtful",
165
+ "u": "Unavailable",
166
+ "s": "Suspended",
167
+ "n": "Not in squad",
168
+ }
169
+ indicator = f" [{status_map.get(status, status)}]"
170
+
171
+ if news:
172
+ indicator += " ⚠️"
173
+
174
+ return indicator
175
+
176
+
177
+ def format_player_status(status: str) -> str:
178
+ """
179
+ Format player status code to full readable name.
180
+
181
+ Args:
182
+ status: Player status code ('a', 'i', 'd', 'u', 's', 'n')
183
+
184
+ Returns:
185
+ Full status name
186
+ """
187
+ status_map = {
188
+ "a": "Available",
189
+ "i": "Injured",
190
+ "d": "Doubtful",
191
+ "u": "Unavailable",
192
+ "s": "Suspended",
193
+ "n": "Not in squad",
194
+ }
195
+ return status_map.get(status, status)
196
+
197
+
198
+ def format_json_response(data: Any, indent: int = 2) -> str:
199
+ """
200
+ Format data as JSON string with consistent formatting.
201
+
202
+ Args:
203
+ data: Data to format as JSON
204
+ indent: Number of spaces for indentation
205
+
206
+ Returns:
207
+ JSON-formatted string
208
+ """
209
+ return json.dumps(data, indent=indent, ensure_ascii=False)
210
+
211
+
212
+ def pluralize(count: int, singular: str, plural: str | None = None) -> str:
213
+ """
214
+ Pluralize a word based on count.
215
+
216
+ Args:
217
+ count: Number of items
218
+ singular: Singular form of word
219
+ plural: Optional plural form (defaults to singular + 's')
220
+
221
+ Returns:
222
+ Formatted string with count and properly pluralized word
223
+ """
224
+ if count == 1:
225
+ return f"{count} {singular}"
226
+ return f"{count} {plural or singular + 's'}"
src/validators.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ Input validation and sanitization for FPL MCP Server.
3
+
4
+ Provides validators to prevent injection attacks and ensure data integrity.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from typing import Any
10
+
11
+ from .exceptions import ValidationError
12
+
13
+ logger = logging.getLogger("fpl_validators")
14
+
15
+ # Constants for validation
16
+ MAX_STRING_LENGTH = 1000
17
+ MAX_NAME_LENGTH = 200
18
+ MIN_ID = 1
19
+ MAX_ID = 999999
20
+ MAX_GAMEWEEK = 38
21
+ MIN_GAMEWEEK = 1
22
+ MAX_PAGE_SIZE = 100
23
+ MIN_PAGE_SIZE = 1
24
+
25
+
26
+ def sanitize_string(
27
+ value: str, field_name: str = "input", max_length: int = MAX_STRING_LENGTH
28
+ ) -> str:
29
+ """
30
+ Sanitize string input to prevent injection attacks.
31
+
32
+ Args:
33
+ value: String to sanitize
34
+ field_name: Name of the field for error messages
35
+ max_length: Maximum allowed length
36
+
37
+ Returns:
38
+ Sanitized string
39
+
40
+ Raises:
41
+ ValidationError: If input is invalid
42
+ """
43
+ if not isinstance(value, str):
44
+ raise ValidationError(f"{field_name} must be a string, got {type(value).__name__}")
45
+
46
+ # Remove null bytes and control characters
47
+ sanitized = value.replace("\0", "").strip()
48
+
49
+ # Check length
50
+ if len(sanitized) > max_length:
51
+ raise ValidationError(
52
+ f"{field_name} exceeds maximum length of {max_length} characters (got {len(sanitized)})"
53
+ )
54
+
55
+ if not sanitized:
56
+ raise ValidationError(f"{field_name} cannot be empty after sanitization")
57
+
58
+ # Log suspicious patterns
59
+ if re.search(r"[<>]|script|javascript|onclick|onerror", sanitized, re.IGNORECASE):
60
+ logger.warning(f"Suspicious pattern detected in {field_name}: {sanitized[:50]}")
61
+
62
+ return sanitized
63
+
64
+
65
+ def validate_player_name(name: str) -> str:
66
+ """
67
+ Validate and sanitize player name.
68
+
69
+ Args:
70
+ name: Player name to validate
71
+
72
+ Returns:
73
+ Sanitized player name
74
+
75
+ Raises:
76
+ ValidationError: If name is invalid
77
+ """
78
+ sanitized = sanitize_string(name, "player_name", MAX_NAME_LENGTH)
79
+
80
+ # Player names should only contain letters, spaces, hyphens, apostrophes, and dots
81
+ if not re.match(r"^[A-Za-z\s\-'.]+$", sanitized):
82
+ raise ValidationError(
83
+ "Player name contains invalid characters. "
84
+ "Only letters, spaces, hyphens, apostrophes, and dots are allowed"
85
+ )
86
+
87
+ return sanitized
88
+
89
+
90
+ def validate_team_name(name: str) -> str:
91
+ """
92
+ Validate and sanitize team name.
93
+
94
+ Args:
95
+ name: Team name to validate
96
+
97
+ Returns:
98
+ Sanitized team name
99
+
100
+ Raises:
101
+ ValidationError: If name is invalid
102
+ """
103
+ sanitized = sanitize_string(name, "team_name", MAX_NAME_LENGTH)
104
+
105
+ # Team names can contain letters, spaces, numbers, and common punctuation
106
+ if not re.match(r"^[A-Za-z0-9\s\-'&.]+$", sanitized):
107
+ raise ValidationError(
108
+ "Team name contains invalid characters. "
109
+ "Only letters, numbers, spaces, hyphens, apostrophes, ampersands, and dots are allowed"
110
+ )
111
+
112
+ return sanitized
113
+
114
+
115
+ def validate_positive_int(
116
+ value: Any,
117
+ field_name: str = "value",
118
+ min_value: int = MIN_ID,
119
+ max_value: int = MAX_ID,
120
+ ) -> int:
121
+ """
122
+ Validate that value is a positive integer within range.
123
+
124
+ Args:
125
+ value: Value to validate
126
+ field_name: Name of the field for error messages
127
+ min_value: Minimum allowed value
128
+ max_value: Maximum allowed value
129
+
130
+ Returns:
131
+ Validated integer
132
+
133
+ Raises:
134
+ ValidationError: If value is invalid
135
+ """
136
+ if not isinstance(value, int):
137
+ try:
138
+ value = int(value)
139
+ except (ValueError, TypeError) as e:
140
+ raise ValidationError(
141
+ f"{field_name} must be an integer, got {type(value).__name__}"
142
+ ) from e
143
+
144
+ if value < min_value:
145
+ raise ValidationError(f"{field_name} must be at least {min_value}, got {value}")
146
+
147
+ if value > max_value:
148
+ raise ValidationError(f"{field_name} must be at most {max_value}, got {value}")
149
+
150
+ return value
151
+
152
+
153
+ def validate_player_id(player_id: Any) -> int:
154
+ """
155
+ Validate player ID.
156
+
157
+ Args:
158
+ player_id: Player ID to validate
159
+
160
+ Returns:
161
+ Validated player ID
162
+
163
+ Raises:
164
+ ValidationError: If ID is invalid
165
+ """
166
+ return validate_positive_int(player_id, "player_id", MIN_ID, MAX_ID)
167
+
168
+
169
+ def validate_team_id(team_id: Any) -> int:
170
+ """
171
+ Validate team ID.
172
+
173
+ Args:
174
+ team_id: Team ID to validate
175
+
176
+ Returns:
177
+ Validated team ID
178
+
179
+ Raises:
180
+ ValidationError: If ID is invalid
181
+ """
182
+ # FPL has 20 teams
183
+ return validate_positive_int(team_id, "team_id", MIN_ID, 20)
184
+
185
+
186
+ def validate_gameweek(gameweek: Any) -> int:
187
+ """
188
+ Validate gameweek number.
189
+
190
+ Args:
191
+ gameweek: Gameweek number to validate
192
+
193
+ Returns:
194
+ Validated gameweek
195
+
196
+ Raises:
197
+ ValidationError: If gameweek is invalid
198
+ """
199
+ return validate_positive_int(gameweek, "gameweek", MIN_GAMEWEEK, MAX_GAMEWEEK)
200
+
201
+
202
+ def validate_manager_id(manager_id: Any) -> int:
203
+ """
204
+ Validate manager ID.
205
+
206
+ Args:
207
+ manager_id: Manager ID to validate
208
+
209
+ Returns:
210
+ Validated manager ID
211
+
212
+ Raises:
213
+ ValidationError: If ID is invalid
214
+ """
215
+ return validate_positive_int(manager_id, "manager_id", MIN_ID, MAX_ID * 10)
216
+
217
+
218
+ def validate_league_id(league_id: Any) -> int:
219
+ """
220
+ Validate league ID.
221
+
222
+ Args:
223
+ league_id: League ID to validate
224
+
225
+ Returns:
226
+ Validated league ID
227
+
228
+ Raises:
229
+ ValidationError: If ID is invalid
230
+ """
231
+ return validate_positive_int(league_id, "league_id", MIN_ID, MAX_ID * 10)
232
+
233
+
234
+ def validate_page_number(page: Any) -> int:
235
+ """
236
+ Validate page number for pagination.
237
+
238
+ Args:
239
+ page: Page number to validate
240
+
241
+ Returns:
242
+ Validated page number
243
+
244
+ Raises:
245
+ ValidationError: If page is invalid
246
+ """
247
+ return validate_positive_int(page, "page", 1, 10000)
248
+
249
+
250
+ def validate_page_size(page_size: Any) -> int:
251
+ """
252
+ Validate page size for pagination.
253
+
254
+ Args:
255
+ page_size: Page size to validate
256
+
257
+ Returns:
258
+ Validated page size
259
+
260
+ Raises:
261
+ ValidationError: If page size is invalid
262
+ """
263
+ return validate_positive_int(page_size, "page_size", MIN_PAGE_SIZE, MAX_PAGE_SIZE)
264
+
265
+
266
+ def validate_boolean(value: Any, field_name: str = "value") -> bool:
267
+ """
268
+ Validate and convert to boolean.
269
+
270
+ Args:
271
+ value: Value to validate
272
+ field_name: Name of the field for error messages
273
+
274
+ Returns:
275
+ Boolean value
276
+
277
+ Raises:
278
+ ValidationError: If value cannot be converted to boolean
279
+ """
280
+ if isinstance(value, bool):
281
+ return value
282
+
283
+ if isinstance(value, str):
284
+ lower = value.lower()
285
+ if lower in ("true", "1", "yes"):
286
+ return True
287
+ if lower in ("false", "0", "no"):
288
+ return False
289
+
290
+ raise ValidationError(f"{field_name} must be a boolean value, got {value}")