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
|
@@ -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
|
+
[](https://opensource.org/licenses/MIT)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[](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,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()
|