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,167 @@
1
+ """
2
+ FPL MCP Prompts - Transfer Recommendations.
3
+
4
+ Prompts guide the LLM in analyzing transfer strategies based on
5
+ available free transfers and squad needs.
6
+ """
7
+
8
+ from ..tools import mcp
9
+
10
+
11
+ @mcp.prompt()
12
+ def recommend_transfers(team_id: int, free_transfers: int = 1) -> str:
13
+ """
14
+ Identify targets using xGI delta, fixture swings, and price urgency.
15
+
16
+ Args:
17
+ team_id: Manager's FPL team ID
18
+ free_transfers: Available free transfers
19
+ """
20
+ return f"""Analyze squad {team_id} and recommend ELITE-LEVEL transfer strategy.
21
+
22
+ **FRAMEWORK: Prioritize xGI underperformers (sell) → xGI overperformers with good fixtures (buy) → Price rise urgency.**
23
+
24
+ ---
25
+
26
+ ## 🔍 **STEP 1: Identify Transfer-Out Candidates**
27
+
28
+ For each squad player, calculate **SELL PRIORITY SCORE**:
29
+
30
+ ### **Automatic Triggers (+100 pts each)**
31
+ - ❌ Injured / Suspended / Flagged as doubtful
32
+ - ❌ DNP last 2 games (did not play)
33
+
34
+ ### **Regression Risk (+30-50 pts)**
35
+ - **xGI Delta (last 5 GW)**: Actual G+A MINUS xG+xA
36
+ - **+3 or higher**: +50 pts (*Massively overperforming → sell before regression*)
37
+ - **+2 to +2.9**: +30 pts (*Moderately overperforming*)
38
+
39
+ ### **Fixture Deterioration (+20-40 pts)**
40
+ - **Next 4 GW Avg FDR**:
41
+ - **>4.0**: +40 pts (*Nightmare run*)
42
+ - **3.5-4.0**: +20 pts (*Tough fixtures*)
43
+
44
+ ### **Minutes Risk (+25 pts)**
45
+ - **Last 5 GW minutes** <60% of possible → +25 pts (*Rotation risk*)
46
+
47
+ ### **Price Drop Urgency (+15 pts)**
48
+ - **Net transfers out >5% of ownership** in last 3 days → +15 pts (*Price drop imminent*)
49
+
50
+ ---
51
+
52
+ ## 🎯 **STEP 2: Rank Transfer-Out Targets**
53
+
54
+ Sort squad players by **SELL PRIORITY SCORE** (descending). Present top 5:
55
+
56
+ | Player | Sell Priority | Reason Breakdown |
57
+ |--------|---------------|------------------|
58
+ | [Name] | 130 | ❌ Injured + 🔴 FDR 4.2 next 4 GW |
59
+ | [Name] | 80 | 🔴 xGI Delta +3.5 (overperforming) + Tough fixtures |
60
+ | ... | ... | ... |
61
+
62
+ **Urgency Tiers:**
63
+ - 🚨 **URGENT (100+ pts)**: Transfer out THIS gameweek (injured/suspended)
64
+ - ⚠️ **HIGH (50-99 pts)**: Transfer out within 2 GW (regression risk + fixtures)
65
+ - 🟡 **MEDIUM (30-49 pts)**: Consider if spare FT available
66
+ - 🟢 **LOW (<30 pts)**: Monitor, no action needed
67
+
68
+ ---
69
+
70
+ ## 💰 **STEP 3: Identify Transfer-In Targets**
71
+
72
+ Search for players matching:
73
+
74
+ ### **Positive Regression Candidates (Priority #1)**
75
+ Using `fpl_get_top_performers(num_gameweeks=5)`:
76
+ - Filter for **xGI Delta <-2.0** (underperforming their xG+xA by 2+ goal involvements)
77
+ → *These are "unlucky" players due for points explosion*
78
+ - Exclude if: Injured, rotation risk (minutes <60%), or FDR >3.5 next 4 GW
79
+
80
+ ### **Fixture Swing Beneficiaries (Priority #2)**
81
+ - Players in teams with **FDR swing** (rolling avg drops >1.0 starting next GW)
82
+ - OR players with **DGW in next 4 GW** 🔥
83
+
84
+ ### **Price Rise Opportunities (Priority #3)**
85
+ - Players with **net transfers in >100K last 3 days** → Price rise imminent
86
+ → *Buy before 0.1m increase locks you out*
87
+
88
+ ### **Budget Constraints**
89
+ - Max price: `[Current player's selling price + £X.Xm ITB]`
90
+
91
+ ---
92
+
93
+ ## 📊 **STEP 4: Transfer Strategy by Free Transfers**
94
+
95
+ ### **{free_transfers} Free Transfer(s) Available:**
96
+
97
+ {
98
+ '''
99
+ 🔴 **0 Free Transfers** — Only take a -4 hit if:
100
+ - Player is injured/suspended (guaranteed 0 pts)
101
+ - Replacement has DGW (expected +8 pts minimum)
102
+ - Replacement expected to outscore by 6+ pts (break even + profit)
103
+ - **Otherwise**: Bank the GW, take 2 FT next week
104
+ '''
105
+ if free_transfers == 0
106
+ else ""
107
+ }
108
+
109
+ {
110
+ '''
111
+ 🟡 **1 Free Transfer** — Decision tree:
112
+ - **If 🚨 URGENT issue exists** (injured player): Use FT to fix
113
+ - **If no urgent issue**: Bank FT → Next week you'll have 2 FT (more flexibility)
114
+ - **Exception**: DGW in next 2 GW → Use FT to bring in DGW player now
115
+ '''
116
+ if free_transfers == 1
117
+ else ""
118
+ }
119
+
120
+ {
121
+ '''
122
+ 🟢 **2 Free Transfers** — Optimal flexibility:
123
+ - Address top 2 **SELL PRIORITY** players (unless both are LOW tier)
124
+ - Don't waste FTs on sideways moves (similar xGI/90, no fixture improvement)
125
+ - Remember: FTs don't bank beyond 2 → USE THEM or LOSE THEM
126
+ '''
127
+ if free_transfers >= 2
128
+ else ""
129
+ }
130
+
131
+ ---
132
+
133
+ ## 🎯 **STEP 5: Recommended Transfers**
134
+
135
+ ### **Transfer Out:**
136
+ 1. **[Player Name]** (Sell Priority: [Score])
137
+ *Reason*: [injury / xGI overperformance / fixtures]
138
+ *Urgency*: [URGENT / HIGH / MEDIUM]
139
+
140
+ ### **Transfer In:**
141
+ 1. **[Player Name]** (£X.Xm)
142
+ *Why*: xGI Delta -2.8 (underperforming) + FDR 2.1 next 4 GW + Rising (150K transfers in)
143
+ *Expected Impact*: [X.X xGI/90 vs current player's Y.Y]
144
+
145
+ ### **Points Hit Economics:**
146
+ - If recommending -4 hit:
147
+ → *"[New player] expected to outscore [old player] by 6+ pts based on xGI/90 + fixtures"*
148
+
149
+ ---
150
+
151
+ ## 🔧 **Tool Calls**
152
+
153
+ 1. `fpl_get_manager_squad(team_id={team_id})` → Current squad with transfer context
154
+ 2. `fpl_get_top_performers(num_gameweeks=5)` → Find high xGI players for replacements
155
+ 3. For each transfer candidate:
156
+ - `fpl://player/{{{{name}}}}/summary` → xG, xA, fixtures, status
157
+ 4. `fpl://bootstrap/players` → Price, ownership, transfer trends
158
+
159
+ ---
160
+
161
+ ## ⚠️ **Critical Rules**
162
+
163
+ 1. **Prioritize xGI Delta** over form/PPG → Regression is alpha
164
+ 2. **Never chase last week's points** → Use xGI to predict NEXT week's points
165
+ 3. **Account for price rise windows** → Buying before rise = free 0.1m
166
+ 4. **DGW overrides everything** → Double fixtures = double xGI opportunity
167
+ """
src/rate_limiter.py ADDED
@@ -0,0 +1,101 @@
1
+ """
2
+ Rate limiting for FPL MCP Server.
3
+
4
+ Implements token bucket algorithm to prevent API rate limit violations.
5
+ """
6
+
7
+ import asyncio
8
+ from collections import deque
9
+ from datetime import UTC, datetime
10
+ import logging
11
+
12
+ logger = logging.getLogger("fpl_rate_limiter")
13
+
14
+
15
+ class RateLimiter:
16
+ """
17
+ Token bucket rate limiter to prevent API abuse.
18
+
19
+ Features:
20
+ - Token bucket algorithm for smooth rate limiting
21
+ - Configurable requests per window
22
+ - Automatic token replenishment
23
+ - Blocking when limit exceeded with wait time calculation
24
+ """
25
+
26
+ def __init__(self, max_requests: int = 100, window_seconds: int = 60):
27
+ """
28
+ Initialize rate limiter.
29
+
30
+ Args:
31
+ max_requests: Maximum number of requests allowed in the time window
32
+ window_seconds: Time window in seconds
33
+ """
34
+ self.max_requests = max_requests
35
+ self.window_seconds = window_seconds
36
+ self.requests: deque[float] = deque()
37
+ self._lock = asyncio.Lock()
38
+
39
+ async def acquire(self) -> None:
40
+ """
41
+ Acquire permission to make a request.
42
+
43
+ Blocks if rate limit would be exceeded, waiting until a token is available.
44
+ """
45
+ while True:
46
+ async with self._lock:
47
+ now = datetime.now(UTC).timestamp()
48
+
49
+ # Remove requests outside the current window
50
+ while self.requests and self.requests[0] < now - self.window_seconds:
51
+ self.requests.popleft()
52
+
53
+ # Check if we have capacity
54
+ if len(self.requests) < self.max_requests:
55
+ # Add current request timestamp and return
56
+ self.requests.append(now)
57
+ logger.debug(
58
+ f"Rate limit check passed: {len(self.requests)}/{self.max_requests} "
59
+ f"requests in window"
60
+ )
61
+ return
62
+
63
+ # Calculate wait time
64
+ oldest_request = self.requests[0]
65
+ wait_time = self.window_seconds - (now - oldest_request) + 0.01 # Small buffer
66
+
67
+ # Release lock while waiting (lock released when exiting context manager)
68
+ if wait_time > 0:
69
+ logger.warning(f"Rate limit reached. Waiting {wait_time:.2f}s before retry...")
70
+ await asyncio.sleep(wait_time)
71
+ # Loop will retry acquisition
72
+
73
+ def get_stats(self) -> dict[str, any]:
74
+ """
75
+ Get rate limiter statistics.
76
+
77
+ Returns:
78
+ Dictionary with current usage and limits
79
+ """
80
+ now = datetime.now(UTC).timestamp()
81
+
82
+ # Count requests in current window
83
+ active_requests = sum(1 for req in self.requests if req >= now - self.window_seconds)
84
+
85
+ return {
86
+ "max_requests": self.max_requests,
87
+ "window_seconds": self.window_seconds,
88
+ "active_requests": active_requests,
89
+ "available_tokens": max(0, self.max_requests - active_requests),
90
+ "utilization": active_requests / self.max_requests if self.max_requests > 0 else 0,
91
+ }
92
+
93
+ def reset(self) -> None:
94
+ """Reset rate limiter (clear all request history)."""
95
+ self.requests.clear()
96
+ logger.info("Rate limiter reset")
97
+
98
+
99
+ # Global rate limiter instance
100
+ # Conservative default: 100 requests per minute to prevent API bans
101
+ rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
@@ -0,0 +1,13 @@
1
+ """FPL MCP Resources - Topic-based modules."""
2
+ # ruff: noqa: E402
3
+
4
+ # Import shared MCP instance from tools
5
+ from ..tools import mcp
6
+
7
+ # Import all resource modules (this registers resources with mcp) # noqa: E402
8
+ from . import (
9
+ bootstrap, # noqa: F401
10
+ )
11
+
12
+ # Re-export mcp instance
13
+ __all__ = ["mcp"]
@@ -0,0 +1,183 @@
1
+ """
2
+ FPL MCP Resources - Bootstrap Data (Static).
3
+
4
+ Bootstrap resources expose static FPL data that rarely changes during a season.
5
+ """
6
+
7
+ from datetime import UTC, datetime
8
+
9
+ from ..client import FPLClient
10
+ from ..state import store
11
+ from ..tools import mcp
12
+
13
+
14
+ def _create_client():
15
+ """Create an unauthenticated FPL client for public API access."""
16
+ return FPLClient(store=store)
17
+
18
+
19
+ @mcp.resource("fpl://bootstrap/players")
20
+ async def get_all_players_resource() -> str:
21
+ """Get all FPL players with basic stats and prices."""
22
+ client = _create_client()
23
+ await store.ensure_bootstrap_data(client)
24
+
25
+ if not store.bootstrap_data or not store.bootstrap_data.elements:
26
+ return "Error: Player data not available."
27
+
28
+ try:
29
+ players = store.bootstrap_data.elements
30
+
31
+ output = [f"**All FPL Players ({len(players)} total)**\n"]
32
+
33
+ # Group by position
34
+ positions = {"GKP": [], "DEF": [], "MID": [], "FWD": []}
35
+ for p in players:
36
+ if p.position in positions:
37
+ positions[p.position].append(p)
38
+
39
+ for pos, players_list in positions.items():
40
+ output.append(f"\n**{pos} ({len(players_list)} players):**")
41
+ # Sort by price descending, show top 10
42
+ sorted_players = sorted(players_list, key=lambda x: x.now_cost, reverse=True)[:10]
43
+ for p in sorted_players:
44
+ price = p.now_cost / 10
45
+ news_indicator = " ⚠️" if p.news else ""
46
+ output.append(
47
+ f"├─ {p.web_name:15s} ({p.team_name:15s}) | £{price:4.1f}m | "
48
+ f"Form: {p.form:4s} | PPG: {p.points_per_game:4s}{news_indicator}"
49
+ )
50
+ if len(players_list) > 10:
51
+ output.append(f"└─ ... and {len(players_list) - 10} more")
52
+
53
+ return "\n".join(output)
54
+ except Exception as e:
55
+ return f"Error: {str(e)}"
56
+
57
+
58
+ @mcp.resource("fpl://bootstrap/teams")
59
+ async def get_all_teams_resource() -> str:
60
+ """Get all Premier League teams with strength ratings."""
61
+ client = _create_client()
62
+ await store.ensure_bootstrap_data(client)
63
+
64
+ teams = store.get_all_teams()
65
+ if not teams:
66
+ return "Error: Team data not available."
67
+
68
+ output = ["**Premier League Teams:**\n"]
69
+
70
+ teams_sorted = sorted(teams, key=lambda t: t["name"])
71
+
72
+ for team in teams_sorted:
73
+ strength_info = ""
74
+ if team.get("strength_overall_home") and team.get("strength_overall_away"):
75
+ avg_strength = (team["strength_overall_home"] + team["strength_overall_away"]) / 2
76
+ strength_info = f" | Strength: {avg_strength:.0f}"
77
+
78
+ output.append(f"{team['name']:20s} ({team['short_name']}){strength_info}")
79
+
80
+ return "\n".join(output)
81
+
82
+
83
+ @mcp.resource("fpl://bootstrap/gameweeks")
84
+ async def get_all_gameweeks_resource() -> str:
85
+ """Get all gameweeks with their status for the season."""
86
+ client = _create_client()
87
+ await store.ensure_bootstrap_data(client)
88
+
89
+ if not store.bootstrap_data or not store.bootstrap_data.events:
90
+ return "Error: Gameweek data not available."
91
+
92
+ try:
93
+ output = ["**All Gameweeks:**\n"]
94
+
95
+ for event in store.bootstrap_data.events:
96
+ status = []
97
+ if event.is_current:
98
+ status.append("CURRENT")
99
+ if event.is_previous:
100
+ status.append("PREVIOUS")
101
+ if event.is_next:
102
+ status.append("NEXT")
103
+ if event.finished:
104
+ status.append("FINISHED")
105
+
106
+ status_str = f" [{', '.join(status)}]" if status else ""
107
+ avg_score = f" | Avg: {event.average_entry_score}" if event.average_entry_score else ""
108
+
109
+ output.append(
110
+ f"GW{event.id}: {event.name}{status_str} | "
111
+ f"Deadline: {event.deadline_time[:10]}{avg_score}"
112
+ )
113
+
114
+ return "\n".join(output)
115
+ except Exception as e:
116
+ return f"Error: {str(e)}"
117
+
118
+
119
+ @mcp.resource("fpl://current-gameweek")
120
+ async def get_current_gameweek_resource() -> str:
121
+ """Get the current or upcoming gameweek information."""
122
+ client = _create_client()
123
+ await store.ensure_bootstrap_data(client)
124
+
125
+ if not store.bootstrap_data or not store.bootstrap_data.events:
126
+ return "Error: Gameweek data not available."
127
+
128
+ try:
129
+ now = datetime.now(UTC)
130
+
131
+ # First check for current gameweek (in progress or upcoming)
132
+ for event in store.bootstrap_data.events:
133
+ if event.is_current:
134
+ deadline = datetime.fromisoformat(event.deadline_time.replace("Z", "+00:00"))
135
+ if now < deadline:
136
+ # Deadline hasn't passed - gameweek upcoming
137
+ return (
138
+ f"**Current Gameweek: {event.name}**\n"
139
+ f"Deadline: {event.deadline_time}\n"
140
+ f"Status: Active - deadline not yet passed\n"
141
+ f"Finished: {event.finished}\n"
142
+ f"Average Score: {event.average_entry_score or 'N/A'}\n"
143
+ f"Highest Score: {event.highest_score or 'N/A'}"
144
+ )
145
+ else:
146
+ # Deadline passed - check if finished or in progress
147
+ if event.finished:
148
+ status = "Status: Finished"
149
+ else:
150
+ status = "Status: In progress - deadline has passed"
151
+
152
+ return (
153
+ f"**Current Gameweek: {event.name}**\n"
154
+ f"Deadline: {event.deadline_time} (passed)\n"
155
+ f"{status}\n"
156
+ f"Average Score: {event.average_entry_score or 'N/A'}\n"
157
+ f"Highest Score: {event.highest_score or 'N/A'}"
158
+ )
159
+
160
+ # If no current, check for next gameweek
161
+ for event in store.bootstrap_data.events:
162
+ if event.is_next:
163
+ return (
164
+ f"**Upcoming Gameweek: {event.name}**\n"
165
+ f"Deadline: {event.deadline_time}\n"
166
+ f"Status: Next gameweek\n"
167
+ f"Released: {event.released}\n"
168
+ f"Can Enter: {event.can_enter}"
169
+ )
170
+
171
+ # Fallback: find first unfinished gameweek
172
+ for event in store.bootstrap_data.events:
173
+ if not event.finished:
174
+ return (
175
+ f"**Upcoming Gameweek: {event.name}**\n"
176
+ f"Deadline: {event.deadline_time}\n"
177
+ f"Status: Upcoming\n"
178
+ f"Released: {event.released}"
179
+ )
180
+
181
+ return "Error: No active or upcoming gameweek found."
182
+ except Exception as e:
183
+ return f"Error: {str(e)}"