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.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: fpl-mcp-server
3
+ Version: 0.1.3
4
+ Summary: Fantasy Premier League MCP Server
5
+ Project-URL: Homepage, https://github.com/nguyenanhducs/fpl-mcp
6
+ Project-URL: Repository, https://github.com/nguyenanhducs/fpl-mcp
7
+ Project-URL: Issues, https://github.com/nguyenanhducs/fpl-mcp/issues
8
+ Project-URL: Documentation, https://github.com/nguyenanhducs/fpl-mcp#readme
9
+ Author: Anh Duc Nguyen
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: fantasy-premier-league,fpl,mcp,model-context-protocol
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Games/Entertainment :: Simulation
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: httpx>=0.28.0
20
+ Requires-Dist: mcp>=1.20.0
21
+ Requires-Dist: pydantic-settings>=2.12.0
22
+ Requires-Dist: pydantic>=2.12.0
23
+ Requires-Dist: python-dotenv>=1.0.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
27
+ Requires-Dist: pytest-mock>=3.10.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.14.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # FPL MCP Server 🏆⚽
33
+
34
+ A comprehensive **Model Context Protocol (MCP)** server for Fantasy Premier League analysis and strategy. This server provides AI assistants with powerful tools, resources, and prompts to help you dominate your FPL mini-leagues with data-driven insights.
35
+
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
37
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
38
+ [![MCP](https://img.shields.io/badge/MCP-compatible-green.svg)](https://modelcontextprotocol.io)
39
+
40
+ ## 🌟 Features
41
+
42
+ This MCP server provides comprehensive FPL analysis capabilities through:
43
+
44
+ - **26 Interactive Tools** - Search players, analyze fixtures, compare managers, track transfers, and more
45
+ - **16 Data Resources** - Efficient URI-based access to players, teams, gameweeks, and manager data
46
+ - **7 Strategy Prompts** - Structured templates for squad analysis, transfer planning, and chip strategy
47
+ - **Smart Caching** - 4-hour cache for bootstrap data to minimize API calls while keeping data fresh
48
+ - **Fuzzy Matching** - Find players even with spelling variations or nicknames
49
+ - **Live Transfer Trends** - Track the most transferred in/out players for current gameweek
50
+ - **Manager Insights** - Analyze squads, transfers, and chip usage (supports 2025/26 half-season system)
51
+ - **Fixture Analysis** - Assess team fixtures and plan transfers around favorable runs
52
+
53
+ ## 🚀 Quick Start
54
+
55
+ You have **two options** to run the FPL MCP server:
56
+
57
+ ### Option 1: Run with Docker (Recommended)
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "fpl": {
63
+ "command": "docker",
64
+ "args": ["run", "--rm", "-i", "yourusername/fpl-mcp:latest"]
65
+ },
66
+ "type": "stdio"
67
+ }
68
+ }
69
+ ```
70
+
71
+ ---
72
+
73
+ ### Option 2: Run with uv
74
+
75
+ 1. **Clone and install**:
76
+
77
+ ```bash
78
+ git clone https://github.com/nguyenanhducs/fpl-mcp.git
79
+ cd fpl-mcp
80
+ uv sync
81
+ ```
82
+
83
+ 2. **Configure MCP Servers**:
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "fpl": {
89
+ "command": "uv",
90
+ "args": [
91
+ "--directory",
92
+ "/absolute/path/to/fpl-mcp",
93
+ "run",
94
+ "python",
95
+ "-m",
96
+ "src.main"
97
+ ],
98
+ "type": "stdio"
99
+ }
100
+ }
101
+ }
102
+ ```
103
+
104
+ Replace `/absolute/path/to/fpl-mcp` with the actual path.
105
+
106
+ ## 📖 Usage & Documentation
107
+
108
+ Once configured, you can interact with the FPL MCP server through Claude Desktop using natural language.
109
+
110
+ For detailed guidance, see:
111
+
112
+ - **[Usage Examples](./docs/usage-examples.md)** - Natural language query examples for player analysis, fixtures, leagues, and strategy
113
+ - **[Tool Selection Guide](./docs/tool-selection-guide.md)** - Choose the right tool for your analysis task
114
+
115
+ ## ⚙️ Configuration
116
+
117
+ The server works out-of-the-box with sensible defaults, but you can customize cache durations, timeouts, and logging levels through environment variables.
118
+
119
+ For detailed configuration instructions for both Docker and uv deployments, see **[Configuration Guide](./docs/configuration.md)**.
120
+
121
+ ## Contributing
122
+
123
+ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
124
+
125
+ **Quick contribution checklist:**
126
+
127
+ - Fork the repository
128
+ - Create a feature branch
129
+ - Write tests for new features
130
+ - Ensure all tests pass
131
+ - Follow PEP 8 style guidelines
132
+ - Use conventional commit messages
133
+ - Submit a pull request
134
+
135
+ ## 📊 Data Sources
136
+
137
+ This server uses the official **Fantasy Premier League API**, see [here](./docs/fpl-api.md) for more details.
@@ -0,0 +1,33 @@
1
+ src/cache.py,sha256=SeJAmddaY9507Ac5YRnbBBXGOQw_OwpIefB-kn11lDI,4604
2
+ src/client.py,sha256=9c7jViZx-YavjDeNNeBt43cAqxVW_4NK08ztUbhYvZA,9737
3
+ src/config.py,sha256=hfjW-W0gdH0PxmC6gEg-o9SqraajJ6gNy1SIlIOG-F4,845
4
+ src/constants.py,sha256=kzcVmX__3miaHvs976H_zF2uBG9l-O3EzsLmwSsLJRE,4466
5
+ src/exceptions.py,sha256=Q8waMbF8Sr1s6lOoAB8-doX0v6EvqZopwQHGxNQ7m-w,2972
6
+ src/formatting.py,sha256=aLiJWM2hJw68gyGJ1Nc1nPAyfoSIqwyjPE8svr-7ufo,10236
7
+ src/main.py,sha256=C6wX96rm0-b1jSvU2BrTv47hw2FGktkwcqJ5nEM8t5U,977
8
+ src/models.py,sha256=0W6tZ6ZXxJTZrdLda3QGDQ-53XKeJ37GGkekR2w3E7Q,11725
9
+ src/rate_limiter.py,sha256=GLk3ZRFFvEZxkZAQd-pZ7UxQdrAAUVch3pxe_aMU-J8,3450
10
+ src/state.py,sha256=aZwZw9nuI3Ipf2MYVI_IXaLNGdbfUbst5uUZtyLLTLA,17412
11
+ src/utils.py,sha256=WhcWQIXpc1vIjU8hyrGDJyKJSlcbVoG938k_3UMDlCM,7340
12
+ src/validators.py,sha256=aU36TUNYWb26fvZH27Xnryrp8gve9DM2phvy7vEnAi8,6891
13
+ src/prompts/__init__.py,sha256=ArMCl0rgPRwWHgrsHau8Uf1zoPD_HbLniSzfzuEEADU,459
14
+ src/prompts/chips.py,sha256=zzv5bqr8HuUAkvXenonrTXVhwNYGMwH9OPSC-c-1Dtg,5524
15
+ src/prompts/league_analysis.py,sha256=23rNhCYkU8hSmd5BesXgNgHLFo_B8qgszmw909MPHkA,8095
16
+ src/prompts/player_analysis.py,sha256=SGyd0UYWMF0lgml9idfc853UHgXXBT_qLVLf-8PFePU,5242
17
+ src/prompts/squad_analysis.py,sha256=7ixTIrvTITvLIE-9ATH744ci_pObWgzx3p5yUqVHmEk,5204
18
+ src/prompts/team_analysis.py,sha256=lZZ2R1xlsclwy4UyiokMg41ziuCKAqxgN_CoT1mOvnY,4104
19
+ src/prompts/transfers.py,sha256=B99xjzJDTRRdwMluANjKxr5DPWB6eg69nZqJ5uyTosA,5448
20
+ src/resources/__init__.py,sha256=i7nlLVSLtiIrLtOnyoMiK3KTFGEnct4LXApB4b6URFM,303
21
+ src/resources/bootstrap.py,sha256=H6s1vubqNm9I3hcc6U5fdQbEPM_TJNweQvlhKVYCc9Y,6773
22
+ src/tools/__init__.py,sha256=mKHfS7-KsOcMOChZ-xfWTNpShJdKTi61ClnDHiToQQw,644
23
+ src/tools/fixtures.py,sha256=C2d06MX7sbVgX25oHixDorkQyJEbD0DvPdpsrytXMS4,6204
24
+ src/tools/gameweeks.py,sha256=qBsMjdQajiNvt6-DZm8YDdBOYi7ZQY46wST1MuPoK8I,15429
25
+ src/tools/leagues.py,sha256=XRF8h6Dt70wFtGPiU8UD6Rgpe2SAdIM9cgnDOh2geT8,23285
26
+ src/tools/players.py,sha256=Jd4FUzU_qvkc6UE8rMYxZ_EM_nBWnSgIUX4MBz8WoQA,30964
27
+ src/tools/teams.py,sha256=kqths5I7K_1rtsf4HyIzXpn9g8i7uMuCWbJ7YNy_tfQ,14753
28
+ src/tools/transfers.py,sha256=Hy8JXjtbzRjUrtX7kg6IaDZ0IqbstISmt2Ben2Di2XE,24591
29
+ fpl_mcp_server-0.1.3.dist-info/METADATA,sha256=21LXih5yO3c72CRgTX5sg7_LgnfZAycrG7TGmKxNwX8,4787
30
+ fpl_mcp_server-0.1.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
31
+ fpl_mcp_server-0.1.3.dist-info/entry_points.txt,sha256=SFOYS11Mz5hCZ2dw03hBGp-ZRefZCiYbD7BNozBNBOI,42
32
+ fpl_mcp_server-0.1.3.dist-info/licenses/LICENSE,sha256=HCDOcdX83voRU2Eip214yj6P_tEyjVjCsCW_sixZFPw,1071
33
+ fpl_mcp_server-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fpl-mcp = src.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anh Duc Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
src/cache.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ Cache management for FPL MCP Server.
3
+
4
+ Provides TTL-aware caching with automatic expiration and cache statistics.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ import logging
10
+ from typing import Any
11
+
12
+ logger = logging.getLogger("fpl_cache")
13
+
14
+
15
+ @dataclass
16
+ class CachedData[T]:
17
+ """Container for cached data with metadata."""
18
+
19
+ data: T
20
+ cached_at: datetime
21
+ ttl: int # Time to live in seconds
22
+
23
+ def is_expired(self) -> bool:
24
+ """Check if cached data has expired."""
25
+ age = (datetime.now(UTC) - self.cached_at).total_seconds()
26
+ return age >= self.ttl
27
+
28
+ def age_seconds(self) -> float:
29
+ """Get age of cached data in seconds."""
30
+ return (datetime.now(UTC) - self.cached_at).total_seconds()
31
+
32
+ def remaining_ttl(self) -> float:
33
+ """Get remaining TTL in seconds (can be negative if expired)."""
34
+ return self.ttl - self.age_seconds()
35
+
36
+
37
+ class CacheManager:
38
+ """
39
+ Manages cached data with TTL enforcement and statistics.
40
+
41
+ Features:
42
+ - Automatic expiration based on TTL
43
+ - Cache hit/miss statistics
44
+ - Support for custom TTL per cache entry
45
+ """
46
+
47
+ def __init__(self):
48
+ self._cache: dict[str, CachedData] = {}
49
+ self._stats = {"hits": 0, "misses": 0, "expirations": 0}
50
+
51
+ def get(self, key: str) -> Any | None:
52
+ """
53
+ Get data from cache if not expired.
54
+
55
+ Args:
56
+ key: Cache key
57
+
58
+ Returns:
59
+ Cached data if valid, None if expired or missing
60
+ """
61
+ if key not in self._cache:
62
+ self._stats["misses"] += 1
63
+ logger.debug(f"Cache miss: {key}")
64
+ return None
65
+
66
+ cached = self._cache[key]
67
+
68
+ if cached.is_expired():
69
+ self._stats["expirations"] += 1
70
+ logger.info(
71
+ f"Cache expired: {key} (age: {cached.age_seconds():.1f}s, ttl: {cached.ttl}s)"
72
+ )
73
+ del self._cache[key]
74
+ return None
75
+
76
+ self._stats["hits"] += 1
77
+ logger.debug(
78
+ f"Cache hit: {key} (age: {cached.age_seconds():.1f}s, "
79
+ f"remaining: {cached.remaining_ttl():.1f}s)"
80
+ )
81
+ return cached.data
82
+
83
+ def set(self, key: str, data: Any, ttl: int) -> None:
84
+ """
85
+ Store data in cache with TTL.
86
+
87
+ Args:
88
+ key: Cache key
89
+ data: Data to cache
90
+ ttl: Time to live in seconds
91
+ """
92
+ self._cache[key] = CachedData(data=data, cached_at=datetime.now(UTC), ttl=ttl)
93
+ logger.debug(f"Cache set: {key} (ttl: {ttl}s)")
94
+
95
+ def invalidate(self, key: str) -> bool:
96
+ """
97
+ Invalidate a specific cache entry.
98
+
99
+ Args:
100
+ key: Cache key to invalidate
101
+
102
+ Returns:
103
+ True if key was cached, False otherwise
104
+ """
105
+ if key in self._cache:
106
+ del self._cache[key]
107
+ logger.info(f"Cache invalidated: {key}")
108
+ return True
109
+ return False
110
+
111
+ def clear(self) -> None:
112
+ """Clear all cached data."""
113
+ count = len(self._cache)
114
+ self._cache.clear()
115
+ logger.info(f"Cache cleared: {count} entries removed")
116
+
117
+ def get_stats(self) -> dict[str, Any]:
118
+ """
119
+ Get cache statistics.
120
+
121
+ Returns:
122
+ Dictionary with hits, misses, expirations, hit_rate, and current size
123
+ """
124
+ total_requests = self._stats["hits"] + self._stats["misses"]
125
+ hit_rate = self._stats["hits"] / total_requests if total_requests > 0 else 0.0
126
+
127
+ return {
128
+ "hits": self._stats["hits"],
129
+ "misses": self._stats["misses"],
130
+ "expirations": self._stats["expirations"],
131
+ "hit_rate": hit_rate,
132
+ "current_size": len(self._cache),
133
+ "entries": {
134
+ key: {
135
+ "age_seconds": cached.age_seconds(),
136
+ "remaining_ttl": cached.remaining_ttl(),
137
+ "is_expired": cached.is_expired(),
138
+ }
139
+ for key, cached in self._cache.items()
140
+ },
141
+ }
142
+
143
+ def cleanup_expired(self) -> int:
144
+ """
145
+ Remove all expired entries from cache.
146
+
147
+ Returns:
148
+ Number of entries removed
149
+ """
150
+ expired_keys = [key for key, cached in self._cache.items() if cached.is_expired()]
151
+
152
+ for key in expired_keys:
153
+ del self._cache[key]
154
+
155
+ if expired_keys:
156
+ logger.info(f"Cleaned up {len(expired_keys)} expired cache entries")
157
+
158
+ return len(expired_keys)
159
+
160
+
161
+ # Global cache manager instance
162
+ cache_manager = CacheManager()
src/client.py ADDED
@@ -0,0 +1,273 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING, Any, Optional
3
+
4
+ import httpx
5
+
6
+ from .models import Player
7
+ from .rate_limiter import rate_limiter
8
+
9
+ if TYPE_CHECKING:
10
+ from .state import SessionStore
11
+
12
+ logger = logging.getLogger("fpl_client")
13
+
14
+
15
+ class FPLClient:
16
+ BASE_URL = "https://fantasy.premierleague.com/api/"
17
+
18
+ def __init__(self, store: Optional["SessionStore"] = None):
19
+ self.session = httpx.AsyncClient(headers={"User-Agent": "Mozilla/5.0"}, timeout=30.0)
20
+ self._store = store
21
+
22
+ async def _request(
23
+ self, method: str, endpoint: str, data: dict = None, params: dict = None
24
+ ) -> Any:
25
+ """Make HTTP request to FPL API (public endpoints only)."""
26
+ url = f"{self.BASE_URL}{endpoint}"
27
+ # Acquire rate limit token before making request
28
+ await rate_limiter.acquire()
29
+
30
+ if method == "GET":
31
+ response = await self.session.get(url, params=params)
32
+ else:
33
+ response = await self.session.post(url, json=data)
34
+
35
+ response.raise_for_status()
36
+ return response.json()
37
+
38
+ async def get_bootstrap_data(self) -> dict[str, Any]:
39
+ """Fetch fresh bootstrap data from API"""
40
+ return await self._request("GET", "bootstrap-static/")
41
+
42
+ async def get_fixtures(self) -> list[dict[str, Any]]:
43
+ """Fetch fixtures data from API"""
44
+ return await self._request("GET", "fixtures/")
45
+
46
+ async def get_element_summary(self, player_id: int) -> dict[str, Any]:
47
+ """
48
+ Fetch detailed player summary including fixtures, history, and past seasons.
49
+
50
+ Args:
51
+ player_id: The FPL player ID (element ID)
52
+
53
+ Returns:
54
+ Dictionary containing fixtures, history, and history_past
55
+ """
56
+ return await self._request("GET", f"element-summary/{player_id}/")
57
+
58
+ async def get_manager_entry(self, team_id: int) -> dict[str, Any]:
59
+ """
60
+ Fetch FPL manager/team entry information.
61
+
62
+ Args:
63
+ team_id: The FPL manager's team ID (entry ID)
64
+
65
+ Returns:
66
+ Dictionary containing manager details, leagues, and team information
67
+ """
68
+ return await self._request("GET", f"entry/{team_id}/")
69
+
70
+ async def get_league_standings(
71
+ self,
72
+ league_id: int,
73
+ page_standings: int = 1,
74
+ page_new_entries: int = 1,
75
+ phase: int = 1,
76
+ ) -> dict[str, Any]:
77
+ """
78
+ Fetch league standings for a classic league.
79
+
80
+ Args:
81
+ league_id: The league ID
82
+ page_standings: Page number for standings (default: 1)
83
+ page_new_entries: Page number for new entries (default: 1)
84
+ phase: Phase/season number (default: 1)
85
+
86
+ Returns:
87
+ Dictionary containing league info and standings with entries
88
+ """
89
+ params = {
90
+ "page_standings": page_standings,
91
+ "page_new_entries": page_new_entries,
92
+ "phase": phase,
93
+ }
94
+ return await self._request("GET", f"leagues-classic/{league_id}/standings/", params=params)
95
+
96
+ async def get_manager_gameweek_picks(self, team_id: int, gameweek: int) -> dict[str, Any]:
97
+ """
98
+ Fetch a manager's team picks for a specific gameweek.
99
+
100
+ Args:
101
+ team_id: The FPL manager's team ID (entry ID)
102
+ gameweek: The gameweek number (event ID)
103
+
104
+ Returns:
105
+ Dictionary containing picks, automatic subs, and entry history for the gameweek
106
+ """
107
+ return await self._request("GET", f"entry/{team_id}/event/{gameweek}/picks/")
108
+
109
+ async def get_manager_transfers(self, team_id: int) -> list[dict[str, Any]]:
110
+ """
111
+ Fetch a manager's complete transfer history.
112
+
113
+ Args:
114
+ team_id: The FPL manager's team ID (entry ID)
115
+
116
+ Returns:
117
+ List of transfer records, each containing transfer details and timing
118
+ """
119
+ return await self._request("GET", f"entry/{team_id}/transfers/")
120
+
121
+ async def get_manager_history(self, team_id: int) -> dict[str, Any]:
122
+ """
123
+ Fetch a manager's history including used chips.
124
+
125
+ Args:
126
+ team_id: The FPL manager's team ID (entry ID)
127
+
128
+ Returns:
129
+ Dictionary containing current, past, and chips data
130
+ """
131
+ return await self._request("GET", f"entry/{team_id}/history/")
132
+
133
+ async def get_fixture_stats(self, fixture_id: int) -> dict[str, Any]:
134
+ """
135
+ Fetch detailed player statistics for a specific fixture.
136
+
137
+ Args:
138
+ fixture_id: The fixture ID
139
+
140
+ Returns:
141
+ Dictionary containing 'a' (away) and 'h' (home) player stats lists,
142
+ each with detailed metrics including goals, assists, xG, xA, xGI, etc.
143
+ """
144
+ return await self._request("GET", f"fixture/{fixture_id}/stats/")
145
+
146
+ async def get_players(self) -> list[Player]:
147
+ """Get all players using in-memory bootstrap data"""
148
+ # Use in-memory data if available
149
+ if self._store and self._store.bootstrap_data:
150
+ data = self._store.bootstrap_data
151
+ teams = {t.id: t.name for t in data.teams}
152
+ types = {t.id: t.singular_name_short for t in data.element_types}
153
+
154
+ players = []
155
+ for element in data.elements:
156
+ # Convert ElementData to Player
157
+ player = Player(
158
+ id=element.id,
159
+ web_name=element.web_name,
160
+ first_name=element.first_name,
161
+ second_name=element.second_name,
162
+ team=element.team,
163
+ element_type=element.element_type,
164
+ now_cost=element.now_cost,
165
+ form=element.form,
166
+ points_per_game=element.points_per_game,
167
+ news=element.news,
168
+ )
169
+ player.team_name = teams.get(player.team, "Unknown")
170
+ player.position = types.get(player.element_type, "Unk")
171
+ players.append(player)
172
+ return players
173
+
174
+ # Fallback to API if in-memory data not available
175
+ logger.warning("Bootstrap data not loaded, fetching from API")
176
+ data = await self.get_bootstrap_data()
177
+ teams = {t["id"]: t["name"] for t in data["teams"]}
178
+ types = {t["id"]: t["singular_name_short"] for t in data["element_types"]}
179
+
180
+ players = []
181
+ for p in data["elements"]:
182
+ player = Player(**p)
183
+ player.team_name = teams.get(player.team, "Unknown")
184
+ player.position = types.get(player.element_type, "Unk")
185
+ players.append(player)
186
+ return players
187
+
188
+ async def get_top_players_by_position(self) -> dict[str, list[dict[str, Any]]]:
189
+ """
190
+ Get top players by position based on points per game.
191
+ Returns: {
192
+ 'GKP': [top 5 goalkeepers],
193
+ 'DEF': [top 20 defenders],
194
+ 'MID': [top 20 midfielders],
195
+ 'FWD': [top 20 forwards]
196
+ }
197
+ """
198
+ if not self._store or not self._store.bootstrap_data:
199
+ logger.warning("Bootstrap data not available for top players")
200
+ return {"GKP": [], "DEF": [], "MID": [], "FWD": []}
201
+
202
+ data = self._store.bootstrap_data
203
+ teams = {t.id: t.name for t in data.teams}
204
+ types = {t.id: t.singular_name_short for t in data.element_types}
205
+
206
+ # Group players by position
207
+ players_by_position = {"GKP": [], "DEF": [], "MID": [], "FWD": []}
208
+
209
+ for element in data.elements:
210
+ # Only include available players
211
+ # if element.status != 'a':
212
+ # continue
213
+
214
+ position = types.get(element.element_type, "UNK")
215
+ if position not in players_by_position:
216
+ continue
217
+
218
+ # Convert to float for sorting, handle 0.0 as string
219
+ try:
220
+ ppg = float(element.points_per_game) if element.points_per_game else 0.0
221
+ except ValueError:
222
+ ppg = 0.0
223
+
224
+ player_data = {
225
+ "id": element.id,
226
+ "name": element.web_name,
227
+ "full_name": f"{element.first_name} {element.second_name}",
228
+ "team": teams.get(element.team, "Unknown"),
229
+ "price": element.now_cost / 10,
230
+ "points_per_game": ppg,
231
+ "total_points": getattr(element, "total_points", 0),
232
+ "form": element.form,
233
+ "status": element.status,
234
+ "news": element.news if element.news else "",
235
+ }
236
+ players_by_position[position].append(player_data)
237
+
238
+ # Sort by points_per_game and take top N
239
+ result = {
240
+ "GKP": sorted(
241
+ players_by_position["GKP"],
242
+ key=lambda x: x["points_per_game"],
243
+ reverse=True,
244
+ )[:5],
245
+ "DEF": sorted(
246
+ players_by_position["DEF"],
247
+ key=lambda x: x["points_per_game"],
248
+ reverse=True,
249
+ )[:20],
250
+ "MID": sorted(
251
+ players_by_position["MID"],
252
+ key=lambda x: x["points_per_game"],
253
+ reverse=True,
254
+ )[:20],
255
+ "FWD": sorted(
256
+ players_by_position["FWD"],
257
+ key=lambda x: x["points_per_game"],
258
+ reverse=True,
259
+ )[:20],
260
+ }
261
+
262
+ return result
263
+
264
+ async def get_current_gameweek(self) -> int:
265
+ """Get the current gameweek number."""
266
+ data = await self.get_bootstrap_data()
267
+ for event in data["events"]:
268
+ if event["is_next"]:
269
+ return event["id"]
270
+ return 38
271
+
272
+ async def close(self):
273
+ await self.session.aclose()
src/config.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ Configuration management for FPL MCP Server using pydantic-settings.
3
+ """
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ """Application settings loaded from environment variables."""
10
+
11
+ # FPL API Configuration
12
+ fpl_base_url: str = "https://fantasy.premierleague.com"
13
+ fpl_api_timeout: int = 30
14
+
15
+ # Cache Configuration (in seconds)
16
+ bootstrap_cache_ttl: int = 14400 # 4 hours
17
+ fixtures_cache_ttl: int = 14400 # 4 hours
18
+ player_summary_cache_ttl: int = 300 # 5 minutes
19
+
20
+ # Logging Configuration
21
+ log_level: str = "INFO"
22
+
23
+ model_config = SettingsConfigDict(
24
+ env_prefix="FPL_MCP_",
25
+ env_file=".env",
26
+ env_file_encoding="utf-8",
27
+ case_sensitive=False,
28
+ extra="ignore",
29
+ )
30
+
31
+
32
+ # Global settings instance
33
+ settings = Settings()