rivalsdata-api 1.0.0__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.
- rivalsdata/__init__.py +22 -0
- rivalsdata/client.py +282 -0
- rivalsdata/exceptions.py +13 -0
- rivalsdata/mcp_server.py +444 -0
- rivalsdata/models.py +172 -0
- rivalsdata/resources.py +248 -0
- rivalsdata_api-1.0.0.dist-info/METADATA +189 -0
- rivalsdata_api-1.0.0.dist-info/RECORD +11 -0
- rivalsdata_api-1.0.0.dist-info/WHEEL +4 -0
- rivalsdata_api-1.0.0.dist-info/entry_points.txt +2 -0
- rivalsdata_api-1.0.0.dist-info/licenses/LICENSE +21 -0
rivalsdata/mcp_server.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Model Context Protocol server for public RivalsData data.
|
|
2
|
+
|
|
3
|
+
Install the optional MCP dependency with ``pip install 'rivalsdata-api[mcp]'``.
|
|
4
|
+
The server is read-only and supports stdio (Claude Desktop/local clients) and
|
|
5
|
+
Streamable HTTP (ChatGPT and other remote MCP clients).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from html import escape
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
except ImportError as exc: # pragma: no cover - depends on optional extra
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"The MCP server requires the optional dependency. Install it with "
|
|
20
|
+
"`pip install 'rivalsdata-api[mcp]'`."
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
from .client import RivalsDataClient
|
|
24
|
+
from mcp_ui_server import create_ui_resource
|
|
25
|
+
from mcp_ui_server.core import UIResource
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
mcp = FastMCP(
|
|
29
|
+
"RivalsData",
|
|
30
|
+
instructions=(
|
|
31
|
+
"Read public Marvel Rivals player profiles, match history, leaderboards, "
|
|
32
|
+
"hero statistics, team-ups, insights, factions, and match details from "
|
|
33
|
+
"RivalsData. These results come from an unofficial, undocumented API "
|
|
34
|
+
"and can change. Use a numeric player UID or exact in-game name; player "
|
|
35
|
+
"match history may be private. All tools are read-only."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _plain(value: Any) -> Any:
|
|
41
|
+
"""Convert package mapping models into JSON-serializable MCP results."""
|
|
42
|
+
if isinstance(value, Mapping):
|
|
43
|
+
return {str(key): _plain(item) for key, item in value.items()}
|
|
44
|
+
if isinstance(value, (list, tuple)):
|
|
45
|
+
return [_plain(item) for item in value]
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _call(method: Any, *args: Any, **kwargs: Any) -> Any:
|
|
50
|
+
with RivalsDataClient() as client:
|
|
51
|
+
return _plain(method(client, *args, **kwargs))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def search_players(name: str) -> dict[str, Any]:
|
|
56
|
+
"""Find a public player by in-game name and return their numeric UID."""
|
|
57
|
+
return _call(lambda client, query: client.resolve_player(query), name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def get_player(uid_or_name: str) -> dict[str, Any]:
|
|
62
|
+
"""Get a public player overview by numeric UID or in-game name."""
|
|
63
|
+
return _call(lambda client, value: client.get_player(value).raw, uid_or_name)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def get_current_match(uid_or_name: str) -> Any:
|
|
68
|
+
"""Get a player's current live match, or null if they are not in a match.
|
|
69
|
+
|
|
70
|
+
Fetches a fresh profile first, so this checks current in-game status rather
|
|
71
|
+
than returning match history or the player's last completed match.
|
|
72
|
+
"""
|
|
73
|
+
def fetch(client: RivalsDataClient, value: str) -> Any:
|
|
74
|
+
player = client.get_player(value)
|
|
75
|
+
return player.live_game.fetch()
|
|
76
|
+
return _call(fetch, uid_or_name)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@mcp.tool()
|
|
80
|
+
def show_player_dashboard(uid_or_name: str) -> list[UIResource]:
|
|
81
|
+
"""Show an interactive player card with their current match status.
|
|
82
|
+
|
|
83
|
+
Fetches a fresh profile and current live-match data. The dashboard is
|
|
84
|
+
rendered by MCP-UI capable hosts; all values are escaped before HTML output.
|
|
85
|
+
"""
|
|
86
|
+
with RivalsDataClient() as client:
|
|
87
|
+
player = client.get_player(uid_or_name)
|
|
88
|
+
live = _plain(player.live_game.fetch())
|
|
89
|
+
hero_rows = _plain(player.heroes.fetch())
|
|
90
|
+
history = _plain(player.matches.fetch())
|
|
91
|
+
|
|
92
|
+
player_name = str(player.get("name", uid_or_name))
|
|
93
|
+
name = escape(player_name)
|
|
94
|
+
uid = escape(str(player.get("uid", "")))
|
|
95
|
+
level = escape(str(player.get("level", "—")))
|
|
96
|
+
xp = escape(str(player.get("xp", "—")))
|
|
97
|
+
rank_data = player.get("rank_game_season", {})
|
|
98
|
+
rank_text = "Rank data unavailable"
|
|
99
|
+
competitive_summary = ""
|
|
100
|
+
if isinstance(rank_data, Mapping):
|
|
101
|
+
rank_rows = [row for row in rank_data.values() if isinstance(row, Mapping)]
|
|
102
|
+
if rank_rows:
|
|
103
|
+
current = max(rank_rows, key=lambda row: int(row.get("rank_game_id", 0) or 0))
|
|
104
|
+
rank_text = escape(str(current.get("rank", current.get("rank_score", "Rank available"))))
|
|
105
|
+
battles = int(current.get("battle_count", 0) or 0)
|
|
106
|
+
wins = int(current.get("win_count", 0) or 0)
|
|
107
|
+
rate = round(wins * 100 / battles) if battles else None
|
|
108
|
+
score = current.get("rank_score")
|
|
109
|
+
competitive_summary = (
|
|
110
|
+
f'<div class="competitive-line">'
|
|
111
|
+
f'<span>{battles:,} competitive games</span>'
|
|
112
|
+
+ (f'<span>{rate}% win rate</span>' if rate is not None else "")
|
|
113
|
+
+ (f'<span>{escape(str(score))} RP</span>' if score is not None else "")
|
|
114
|
+
+ '</div>'
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
chart_rows = []
|
|
118
|
+
if isinstance(hero_rows, list):
|
|
119
|
+
ranked_heroes = sorted(
|
|
120
|
+
(row for row in hero_rows if isinstance(row, Mapping)),
|
|
121
|
+
key=lambda row: int(row.get("games", 0) or 0),
|
|
122
|
+
reverse=True,
|
|
123
|
+
)[:6]
|
|
124
|
+
for row in ranked_heroes:
|
|
125
|
+
wins = int(row.get("wins", 0) or 0)
|
|
126
|
+
losses = int(row.get("losses", 0) or 0)
|
|
127
|
+
games = int(row.get("games", wins + losses) or 0)
|
|
128
|
+
total = wins + losses
|
|
129
|
+
win_rate = round(wins * 100 / total) if total else 0
|
|
130
|
+
hero = escape(str(row.get("hero_id", "Unknown hero")))
|
|
131
|
+
chart_rows.append(
|
|
132
|
+
f'<div class="hero-row"><div class="hero-label"><span>{hero}</span>'
|
|
133
|
+
f'<b>{win_rate}%</b></div><div class="track"><i style="width:{win_rate}%"></i>'
|
|
134
|
+
f'</div><small>{games} games</small></div>'
|
|
135
|
+
)
|
|
136
|
+
hero_chart = (
|
|
137
|
+
'<section class="section"><div class="section-head"><div><h2>Hero form</h2>'
|
|
138
|
+
'<p class="sub">Win rate across the most played heroes</p></div>'
|
|
139
|
+
'<span class="section-tag">SEASON</span></div>'
|
|
140
|
+
+ ("".join(chart_rows) if chart_rows else
|
|
141
|
+
'<p class="empty">No hero win/loss data is available for this player.</p>')
|
|
142
|
+
+ '</section>'
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
match_rows = history.get("matches", []) if isinstance(history, Mapping) else []
|
|
146
|
+
if not isinstance(match_rows, list):
|
|
147
|
+
match_rows = []
|
|
148
|
+
recent = [row for row in match_rows[:10] if isinstance(row, Mapping)]
|
|
149
|
+
recent_wins = sum(1 for row in recent if row.get("is_win") is True)
|
|
150
|
+
recent_kills = sum(int(row.get("kills", 0) or 0) for row in recent)
|
|
151
|
+
recent_deaths = sum(int(row.get("deaths", 0) or 0) for row in recent)
|
|
152
|
+
recent_assists = sum(int(row.get("assists", 0) or 0) for row in recent)
|
|
153
|
+
recent_kda = (recent_kills + recent_assists) / max(recent_deaths, 1) if recent else 0
|
|
154
|
+
form_marks = []
|
|
155
|
+
for row in recent:
|
|
156
|
+
did_win = row.get("is_win") is True
|
|
157
|
+
outcome = "W" if did_win else "L"
|
|
158
|
+
label = "Win" if did_win else "Loss"
|
|
159
|
+
kills = escape(str(row.get("kills", 0)))
|
|
160
|
+
deaths = escape(str(row.get("deaths", 0)))
|
|
161
|
+
assists = escape(str(row.get("assists", 0)))
|
|
162
|
+
form_marks.append(
|
|
163
|
+
f'<span class="form-mark {"win" if did_win else "loss"}" '
|
|
164
|
+
f'title="{label} · {kills}/{deaths}/{assists}" aria-label="{label}, '
|
|
165
|
+
f'{kills} kills, {deaths} deaths, {assists} assists">{outcome}</span>'
|
|
166
|
+
)
|
|
167
|
+
recent_section = (
|
|
168
|
+
'<section class="section"><div class="section-head"><div><h2>Recent run</h2>'
|
|
169
|
+
'<p class="sub">Latest visible matches</p></div>'
|
|
170
|
+
+ (f'<span class="section-tag">{recent_wins}W · {len(recent) - recent_wins}L</span>' if recent else '')
|
|
171
|
+
+ '</div>'
|
|
172
|
+
+ (f'<div class="form-strip">{"".join(form_marks)}</div>'
|
|
173
|
+
f'<div class="form-foot"><span>Last {len(recent)} games</span>'
|
|
174
|
+
f'<span>{recent_kills} / {recent_deaths} / {recent_assists} K/D/A</span>'
|
|
175
|
+
f'<span>{recent_kda:.2f} KDA</span></div>' if recent else
|
|
176
|
+
'<p class="empty">No public match history is available for this player.</p>')
|
|
177
|
+
+ '</section>'
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if isinstance(live, Mapping):
|
|
181
|
+
slots = live.get("players", {})
|
|
182
|
+
teams: dict[str, list[str]] = {}
|
|
183
|
+
if isinstance(slots, Mapping):
|
|
184
|
+
for row in slots.values():
|
|
185
|
+
if isinstance(row, Mapping):
|
|
186
|
+
player_name = escape(str(row.get("name", "Unknown player")))
|
|
187
|
+
team_id = str(row.get("side", row.get("team_id", "Unknown side")))
|
|
188
|
+
team = escape(team_id)
|
|
189
|
+
rank = escape(str(row.get("rank", "—")))
|
|
190
|
+
wins = escape(str(row.get("wins", "—")))
|
|
191
|
+
losses = escape(str(row.get("losses", "—")))
|
|
192
|
+
top_heroes = row.get("top_heroes", [])
|
|
193
|
+
hero_label = ""
|
|
194
|
+
if isinstance(top_heroes, list) and top_heroes:
|
|
195
|
+
hero_label = escape(", ".join(str(hero) for hero in top_heroes[:2]))
|
|
196
|
+
detail = f"Rank {rank} · {wins}W/{losses}L"
|
|
197
|
+
if hero_label:
|
|
198
|
+
detail += f" · {hero_label}"
|
|
199
|
+
teams.setdefault(team_id, []).append(
|
|
200
|
+
f'<li><span>{player_name}</span><small>{detail}</small></li>'
|
|
201
|
+
)
|
|
202
|
+
side_keys = sorted(teams)
|
|
203
|
+
team_sections = []
|
|
204
|
+
for index, team_id in enumerate(side_keys):
|
|
205
|
+
avg_rank = live.get("team_avg_rank", {})
|
|
206
|
+
avg = avg_rank.get(team_id) if isinstance(avg_rank, Mapping) else None
|
|
207
|
+
avg_markup = f'<span class="avg-rank">Avg rank {escape(str(avg))}</span>' if avg is not None else ""
|
|
208
|
+
team_sections.append(
|
|
209
|
+
f'<div class="team team-{index % 2}"><div class="team-head">'
|
|
210
|
+
f'<b>Side {escape(team_id)}</b>{avg_markup}</div>'
|
|
211
|
+
f'<ul>{"".join(teams[team_id])}</ul></div>'
|
|
212
|
+
)
|
|
213
|
+
match_section = (
|
|
214
|
+
'<section class="match-section"><div class="match-head"><div>'
|
|
215
|
+
'<span class="live-dot"></span><span class="live-label">LIVE MATCH</span>'
|
|
216
|
+
'<h2>On the battlefield</h2></div><span class="live-pulse">ACTIVE</span></div>'
|
|
217
|
+
+ (f'<div class="teams">{"".join(team_sections)}</div>' if team_sections
|
|
218
|
+
else '<p class="empty">Match detected; player roster is not available.</p>')
|
|
219
|
+
+ "</section>"
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
match_section = (
|
|
223
|
+
'<section class="match-section idle"><div class="match-head"><div>'
|
|
224
|
+
'<span class="idle-dot"></span><span class="live-label">MATCH STATUS</span>'
|
|
225
|
+
'<h2>Between matches</h2></div></div>'
|
|
226
|
+
'<p class="empty">No active match is linked to this profile right now.</p></section>'
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
html = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
230
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
231
|
+
<style>
|
|
232
|
+
*{{box-sizing:border-box}}:root{{color-scheme:dark}}body{{margin:0;padding:18px;
|
|
233
|
+
background:#0d0f12;color:#f0f1f3;font:14px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}}
|
|
234
|
+
main{{max-width:850px;margin:auto}}h1,h2,p{{margin-top:0}}h1{{font-size:26px;line-height:1.12;
|
|
235
|
+
letter-spacing:-.025em;margin:3px 0 5px}}h2{{font-size:17px;letter-spacing:-.01em;margin:0}}
|
|
236
|
+
.sub,.empty{{color:#a9adb6;font-size:12px;margin:5px 0 0}}.masthead{{display:flex;
|
|
237
|
+
justify-content:space-between;align-items:center;padding:3px 1px 16px}}
|
|
238
|
+
.identity{{display:flex;align-items:center;gap:13px}}.monogram{{width:42px;height:42px;
|
|
239
|
+
display:grid;place-items:center;background:#25282e;border:1px solid #393d45;border-radius:12px;
|
|
240
|
+
font-size:14px;font-weight:750;color:#f2f3f5}}.uid{{color:#959aa4;font:11px/1.4 ui-monospace,monospace}}
|
|
241
|
+
.state,.section-tag,.live-pulse{{font-size:10px;letter-spacing:.09em;font-weight:750;
|
|
242
|
+
border:1px solid #40434b;border-radius:999px;padding:5px 9px;color:#c6c9cf}}
|
|
243
|
+
.state.active,.live-pulse{{border-color:#6f3937;color:#ff9b8e;background:#281b1a}}
|
|
244
|
+
.overview{{display:grid;grid-template-columns:1.2fr 1fr;gap:12px;margin-bottom:12px}}
|
|
245
|
+
.panel,.match-section{{background:#17191e;border:1px solid #2b2e35;border-radius:13px;padding:17px}}
|
|
246
|
+
.rank-panel{{background:#202126}}.rank-caption,.section-tag{{color:#aeb2bb}}
|
|
247
|
+
.rank-line{{display:flex;align-items:baseline;gap:10px;margin-top:5px}}
|
|
248
|
+
.rank-line strong{{font-size:25px;letter-spacing:-.03em}}.rank-line span{{color:#ff9b8e;font-size:12px}}
|
|
249
|
+
.competitive-line{{display:flex;gap:13px;flex-wrap:wrap;margin-top:13px;padding-top:12px;
|
|
250
|
+
border-top:1px solid #373940;color:#babdc5;font-size:11px;font-variant-numeric:tabular-nums}}
|
|
251
|
+
.level-line{{display:flex;gap:20px;align-items:center;height:100%}}.level-value{{font-size:28px;
|
|
252
|
+
font-weight:720;letter-spacing:-.03em;font-variant-numeric:tabular-nums}}
|
|
253
|
+
.level-copy{{display:grid;gap:1px;color:#a9adb6;font-size:11px}}.level-copy b{{color:#edeef0;
|
|
254
|
+
font-size:13px;font-variant-numeric:tabular-nums}}
|
|
255
|
+
.match-section{{margin-bottom:12px;padding:0;overflow:hidden}}.match-head{{display:flex;
|
|
256
|
+
justify-content:space-between;align-items:center;padding:15px 17px;border-bottom:1px solid #30323a}}
|
|
257
|
+
.match-head h2{{margin-top:3px}}.live-label{{color:#ff9b8e;font-size:10px;font-weight:750;
|
|
258
|
+
letter-spacing:.1em}}.live-dot,.idle-dot{{display:inline-block;width:7px;height:7px;
|
|
259
|
+
border-radius:50%;background:#f47869;margin-right:7px;vertical-align:1px}}
|
|
260
|
+
.idle-dot{{background:#797e88}}.teams{{display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:13px}}
|
|
261
|
+
.team{{padding:12px;border-radius:9px;background:#1d2026}}.team-0{{border:1px solid #34515b}}
|
|
262
|
+
.team-1{{border:1px solid #60403c}}.team-head{{display:flex;justify-content:space-between;
|
|
263
|
+
align-items:center;font-size:12px}}.team-0 .team-head b{{color:#91ceda}}.team-1 .team-head b{{color:#f0a08f}}
|
|
264
|
+
.avg-rank{{color:#a9adb6;font-size:10px}}ul{{list-style:none;padding:0;margin:9px 0 0}}
|
|
265
|
+
li{{display:grid;gap:2px;padding:8px 0;border-top:1px solid #34363d;min-width:0}}
|
|
266
|
+
li span{{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:12px}}
|
|
267
|
+
li small{{font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
268
|
+
.data-grid{{display:grid;grid-template-columns:1.05fr .95fr;gap:12px}}
|
|
269
|
+
.section{{background:#17191e;border:1px solid #2b2e35;border-radius:13px;padding:17px;min-width:0}}
|
|
270
|
+
.section-head{{display:flex;justify-content:space-between;align-items:start;gap:10px;margin-bottom:12px}}
|
|
271
|
+
.section-head h2{{font-size:16px}}.section-tag{{font-size:9px;padding:4px 7px;white-space:nowrap}}
|
|
272
|
+
.hero-row{{margin-top:12px}}.hero-label{{display:flex;justify-content:space-between;gap:8px;
|
|
273
|
+
margin-bottom:5px;font-size:11px}}.hero-label span{{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
274
|
+
.hero-label b{{font-variant-numeric:tabular-nums}}.track{{height:7px;background:#30333a;
|
|
275
|
+
border-radius:2px;overflow:hidden}}.track i{{display:block;height:100%;background:#a8d2a2}}
|
|
276
|
+
.hero-row small{{display:block;margin-top:3px;color:#9398a2;font-size:10px}}
|
|
277
|
+
.form-strip{{display:flex;gap:5px;margin:16px 0 12px;flex-wrap:wrap}}
|
|
278
|
+
.form-mark{{width:25px;height:25px;display:grid;place-items:center;border-radius:5px;
|
|
279
|
+
font-size:10px;font-weight:750}}.form-mark.win{{background:#20362d;color:#9bd4ac}}
|
|
280
|
+
.form-mark.loss{{background:#3a2627;color:#f1a0a0}}.form-foot{{display:flex;gap:8px 14px;
|
|
281
|
+
justify-content:space-between;flex-wrap:wrap;color:#a9adb6;font-size:10px;font-variant-numeric:tabular-nums}}
|
|
282
|
+
.empty{{padding:8px 0}}.source-note{{margin:11px 2px 0;color:#777d87;font-size:10px}}
|
|
283
|
+
@media(max-width:560px){{body{{padding:12px}}.overview,.data-grid,.teams{{grid-template-columns:1fr}}
|
|
284
|
+
.panel,.section{{padding:14px}}.teams{{padding:10px}}.masthead{{align-items:flex-start}}}}
|
|
285
|
+
</style></head><body><main>
|
|
286
|
+
<header class="masthead"><div class="identity"><span class="monogram">{escape(player_name[:2].upper())}</span>
|
|
287
|
+
<div><h1>{name}</h1><div class="uid">PLAYER ID {uid}</div></div></div>
|
|
288
|
+
<span class="state {"active" if live else ""}">{"IN MATCH" if live else "PROFILE"}</span></header>
|
|
289
|
+
<div class="overview"><section class="panel rank-panel"><div class="rank-caption">COMPETITIVE RANK</div>
|
|
290
|
+
<div class="rank-line"><strong>{rank_text}</strong></div>{competitive_summary}</section>
|
|
291
|
+
<section class="panel"><div class="level-line"><div class="level-value">{level}</div>
|
|
292
|
+
<div class="level-copy"><span>PLAYER LEVEL</span><b>{xp} XP</b><span>Lifetime experience</span></div></div></section></div>
|
|
293
|
+
{match_section}
|
|
294
|
+
<div class="data-grid">{hero_chart}{recent_section}</div>
|
|
295
|
+
<p class="source-note">RivalsData public profile · data shown as returned by the source</p>
|
|
296
|
+
</main></body></html>"""
|
|
297
|
+
resource = create_ui_resource({
|
|
298
|
+
"uri": "ui://rivalsdata/player-dashboard",
|
|
299
|
+
"content": {"type": "rawHtml", "htmlString": html},
|
|
300
|
+
"encoding": "text",
|
|
301
|
+
"uiMetadata": {"preferred-frame-size": [850, 980]},
|
|
302
|
+
})
|
|
303
|
+
return [resource]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@mcp.tool()
|
|
307
|
+
def get_player_heroes(uid_or_name: str, season: int | None = None) -> Any:
|
|
308
|
+
"""Get a player's hero summary rows, optionally for a season ID."""
|
|
309
|
+
def fetch(client: RivalsDataClient, value: str, season: int | None) -> Any:
|
|
310
|
+
player = client.get_player(value)
|
|
311
|
+
return player.heroes.fetch(season=season)
|
|
312
|
+
return _call(fetch, uid_or_name, season)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@mcp.tool()
|
|
316
|
+
def get_player_matches(
|
|
317
|
+
uid_or_name: str,
|
|
318
|
+
season: int | None = None,
|
|
319
|
+
cursor: str | None = None,
|
|
320
|
+
mode: str | None = None,
|
|
321
|
+
hero: str | None = None,
|
|
322
|
+
teammate: str | None = None,
|
|
323
|
+
) -> Any:
|
|
324
|
+
"""Get visible, cached player match history with optional filters."""
|
|
325
|
+
def fetch(client: RivalsDataClient, value: str, **filters: Any) -> Any:
|
|
326
|
+
player = client.get_player(value)
|
|
327
|
+
return player.matches.fetch(**filters)
|
|
328
|
+
return _call(fetch, uid_or_name, season=season, cursor=cursor, mode=mode,
|
|
329
|
+
hero=hero, teammate=teammate)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@mcp.tool()
|
|
333
|
+
def get_player_stats(
|
|
334
|
+
uid_or_name: str, category: str = "heroes", season: int | None = None
|
|
335
|
+
) -> Any:
|
|
336
|
+
"""Get player stats; category must be heroes, maps, or bans."""
|
|
337
|
+
methods = {"heroes": "heroes", "maps": "maps", "bans": "bans"}
|
|
338
|
+
if category not in methods:
|
|
339
|
+
raise ValueError("category must be one of: heroes, maps, bans")
|
|
340
|
+
def fetch(client: RivalsDataClient, value: str, category: str,
|
|
341
|
+
season: int | None) -> Any:
|
|
342
|
+
return getattr(client.get_player(value).stats, methods[category])(season=season)
|
|
343
|
+
return _call(fetch, uid_or_name, category, season)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@mcp.tool()
|
|
347
|
+
def get_player_teammates(
|
|
348
|
+
uid_or_name: str, season: int | None = None, mode: str | None = None
|
|
349
|
+
) -> Any:
|
|
350
|
+
"""Get a player's public teammate statistics."""
|
|
351
|
+
def fetch(client: RivalsDataClient, value: str, **filters: Any) -> Any:
|
|
352
|
+
return client.get_player(value).teammates.fetch(**filters)
|
|
353
|
+
return _call(fetch, uid_or_name, season=season, mode=mode)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@mcp.tool()
|
|
357
|
+
def get_leaderboard(
|
|
358
|
+
limit: int = 100, skip: int = 0, season: int | None = None,
|
|
359
|
+
platform: int | None = None,
|
|
360
|
+
) -> Any:
|
|
361
|
+
"""Get the global ranked-player leaderboard (platform 1 is PC)."""
|
|
362
|
+
return _call(lambda client, **kw: client.leaderboards.fetch(**kw),
|
|
363
|
+
limit=limit, skip=skip, season=season, platform=platform)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@mcp.tool()
|
|
367
|
+
def get_hero_tier_list(platform: int = 1, rank: str = "grandmaster_plus") -> Any:
|
|
368
|
+
"""Get hero tier-list statistics for a platform and rank range."""
|
|
369
|
+
return _call(lambda client, **kw: client.heroes.tier_list(**kw),
|
|
370
|
+
platform=platform, rank=rank)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@mcp.tool()
|
|
374
|
+
def get_hero_stats(hero_id: str) -> Any:
|
|
375
|
+
"""Get aggregate statistics for a hero ID."""
|
|
376
|
+
return _call(lambda client, value: client.heroes.get(value), hero_id)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@mcp.tool()
|
|
380
|
+
def get_hero_meta(hero_id: str, days: int = 90) -> Any:
|
|
381
|
+
"""Get hero trends for 30, 90, or 180 days."""
|
|
382
|
+
if days not in (30, 90, 180):
|
|
383
|
+
raise ValueError("days must be 30, 90, or 180")
|
|
384
|
+
return _call(lambda client, value, period: client.heroes.meta(value, range=period),
|
|
385
|
+
hero_id, days)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@mcp.tool()
|
|
389
|
+
def get_team_ups(platform: int = 1, rank: str = "grandmaster_plus") -> Any:
|
|
390
|
+
"""Get team-up usage and win-rate statistics."""
|
|
391
|
+
return _call(lambda client, **kw: client.team_ups.fetch(**kw),
|
|
392
|
+
platform=platform, rank=rank)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@mcp.tool()
|
|
396
|
+
def get_public_insight(kind: str = "xp", cursor: str | None = None,
|
|
397
|
+
mode: str = "all", platform: int = 1) -> Any:
|
|
398
|
+
"""Get public XP, Top 500, comm-ban, leaver, or punishment insights."""
|
|
399
|
+
def fetch(client: RivalsDataClient, kind: str, cursor: str | None,
|
|
400
|
+
mode: str, platform: int) -> Any:
|
|
401
|
+
if kind == "xp":
|
|
402
|
+
return client.insights.xp(cursor=cursor)
|
|
403
|
+
if kind == "top_500":
|
|
404
|
+
return client.insights.top_500(platform=platform)
|
|
405
|
+
if kind == "commbans":
|
|
406
|
+
return client.insights.commbans(mode=mode)
|
|
407
|
+
if kind == "leavers":
|
|
408
|
+
return client.insights.leavers(mode=mode)
|
|
409
|
+
if kind == "punishments":
|
|
410
|
+
return client.insights.punishments(kind=mode, cursor=cursor)
|
|
411
|
+
raise ValueError("kind must be xp, top_500, commbans, leavers, or punishments")
|
|
412
|
+
return _call(fetch, kind, cursor, mode, platform)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@mcp.tool()
|
|
416
|
+
def get_match(match_id: str) -> Any:
|
|
417
|
+
"""Get the public details and player statistics for a match ID."""
|
|
418
|
+
return _call(lambda client, value: client.matches.get(value), match_id)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@mcp.tool()
|
|
422
|
+
def get_faction(faction_id: str) -> Any:
|
|
423
|
+
"""Get public faction details and member information."""
|
|
424
|
+
return _call(lambda client, value: client.factions.get(value), faction_id)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main() -> None:
|
|
428
|
+
"""Start the MCP server over stdio or Streamable HTTP."""
|
|
429
|
+
parser = argparse.ArgumentParser(description="RivalsData MCP server")
|
|
430
|
+
parser.add_argument("--transport", choices=("stdio", "streamable-http"),
|
|
431
|
+
default="stdio")
|
|
432
|
+
parser.add_argument("--host", default="127.0.0.1",
|
|
433
|
+
help="HTTP bind host (default: 127.0.0.1)")
|
|
434
|
+
parser.add_argument("--port", type=int, default=8000,
|
|
435
|
+
help="HTTP port (default: 8000)")
|
|
436
|
+
args = parser.parse_args()
|
|
437
|
+
if args.transport == "stdio":
|
|
438
|
+
mcp.run(transport="stdio")
|
|
439
|
+
else:
|
|
440
|
+
mcp.run(transport="streamable-http", host=args.host, port=args.port)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
if __name__ == "__main__":
|
|
444
|
+
main()
|
rivalsdata/models.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Attribute-accessible response models for the undocumented RivalsData API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _wrap(value: Any) -> Any:
|
|
10
|
+
if isinstance(value, dict):
|
|
11
|
+
return DataModel(value)
|
|
12
|
+
if isinstance(value, list):
|
|
13
|
+
return [_wrap(item) for item in value]
|
|
14
|
+
return value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DataModel(Mapping[str, Any]):
|
|
18
|
+
"""A tolerant mapping that exposes JSON keys as Python attributes.
|
|
19
|
+
|
|
20
|
+
The API is undocumented, so the complete original payload remains
|
|
21
|
+
available through :attr:`raw` and unknown upstream keys are preserved.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__slots__ = ("_data",)
|
|
25
|
+
|
|
26
|
+
def __init__(self, data: Mapping[str, Any] | None = None, **values: Any) -> None:
|
|
27
|
+
self._data = dict(data or {})
|
|
28
|
+
self._data.update(values)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def raw(self) -> dict[str, Any]:
|
|
32
|
+
"""Return a shallow copy of the original JSON object."""
|
|
33
|
+
return dict(self._data)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def win_rate(self) -> int | None:
|
|
37
|
+
"""Return an integer percentage when this object exposes win/loss data."""
|
|
38
|
+
for key in ("win_rate", "winrate", "winRate"):
|
|
39
|
+
if key in self._data:
|
|
40
|
+
try:
|
|
41
|
+
value = float(self._data[key])
|
|
42
|
+
return round(value * 100) if 0 <= value <= 1 else int(value)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return None
|
|
45
|
+
wins = self._data.get("wins", self._data.get("win_count"))
|
|
46
|
+
losses = self._data.get("losses")
|
|
47
|
+
if losses is None and "battle_count" in self._data:
|
|
48
|
+
try:
|
|
49
|
+
losses = int(self._data["battle_count"]) - int(wins)
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
losses = None
|
|
52
|
+
try:
|
|
53
|
+
wins_count, losses_count = int(wins), int(losses)
|
|
54
|
+
total = wins_count + losses_count
|
|
55
|
+
return round(wins_count * 100 / total) if total else None
|
|
56
|
+
except (TypeError, ValueError):
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def __getitem__(self, key: str) -> Any:
|
|
60
|
+
return _wrap(self._data[key])
|
|
61
|
+
|
|
62
|
+
def __iter__(self) -> Iterator[str]:
|
|
63
|
+
return iter(self._data)
|
|
64
|
+
|
|
65
|
+
def __len__(self) -> int:
|
|
66
|
+
return len(self._data)
|
|
67
|
+
|
|
68
|
+
def __getattr__(self, name: str) -> Any:
|
|
69
|
+
if name == "winrate":
|
|
70
|
+
return self.win_rate
|
|
71
|
+
try:
|
|
72
|
+
return _wrap(self._data[name])
|
|
73
|
+
except KeyError as exc:
|
|
74
|
+
raise AttributeError(name) from exc
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
keys = ", ".join(self._data)
|
|
78
|
+
return f"{type(self).__name__}({keys})"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class StatRecord(DataModel):
|
|
82
|
+
"""A stats row with a normalized integer ``win_rate`` convenience."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Player(DataModel):
|
|
86
|
+
"""Public player profile with lazy access to profile subresources."""
|
|
87
|
+
|
|
88
|
+
__slots__ = ("_client",)
|
|
89
|
+
|
|
90
|
+
def __init__(self, data: Mapping[str, Any], client: Any) -> None:
|
|
91
|
+
super().__init__(data)
|
|
92
|
+
object.__setattr__(self, "_client", client)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def stats(self) -> Any:
|
|
96
|
+
from .resources import PlayerStats
|
|
97
|
+
|
|
98
|
+
return PlayerStats(self._client, int(self._data["uid"]))
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def heroes(self) -> Any:
|
|
102
|
+
from .resources import PlayerHeroes
|
|
103
|
+
|
|
104
|
+
return PlayerHeroes(self._client, int(self._data["uid"]))
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def matches(self) -> Any:
|
|
108
|
+
from .resources import PlayerMatches
|
|
109
|
+
|
|
110
|
+
return PlayerMatches(self._client, int(self._data["uid"]))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def live_game(self) -> Any:
|
|
114
|
+
"""The player's current live-game resource, if they are in a match."""
|
|
115
|
+
from .resources import PlayerLiveGame
|
|
116
|
+
|
|
117
|
+
return PlayerLiveGame(self._client, self._data)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def teammates(self) -> Any:
|
|
121
|
+
from .resources import PlayerTeammates
|
|
122
|
+
|
|
123
|
+
return PlayerTeammates(self._client, int(self._data["uid"]))
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def crosshairs(self) -> Any:
|
|
127
|
+
from .resources import PlayerCrosshairs
|
|
128
|
+
|
|
129
|
+
return PlayerCrosshairs(self._client, int(self._data["uid"]))
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def proficiency(self) -> Any:
|
|
133
|
+
from .resources import PlayerProficiency
|
|
134
|
+
|
|
135
|
+
return PlayerProficiency(self._client, int(self._data["uid"]))
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def punishments(self) -> Any:
|
|
139
|
+
from .resources import PlayerPunishments
|
|
140
|
+
|
|
141
|
+
return PlayerPunishments(self._client, int(self._data["uid"]))
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def name_history(self) -> Any:
|
|
145
|
+
from .resources import PlayerNameHistory
|
|
146
|
+
|
|
147
|
+
return PlayerNameHistory(self._client, int(self._data["uid"]))
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def win_rate(self) -> int | None:
|
|
151
|
+
"""Overall competitive win rate when the profile includes wins/losses."""
|
|
152
|
+
direct = StatRecord(self._data).win_rate
|
|
153
|
+
if direct is not None:
|
|
154
|
+
return direct
|
|
155
|
+
for key in ("rank_game_season", "leaderboard"):
|
|
156
|
+
nested = self._data.get(key)
|
|
157
|
+
if isinstance(nested, dict):
|
|
158
|
+
competitive = [
|
|
159
|
+
value for account_id, value in nested.items()
|
|
160
|
+
if str(account_id).startswith("1001") and isinstance(value, dict)
|
|
161
|
+
]
|
|
162
|
+
competitive.sort(
|
|
163
|
+
key=lambda row: int(row.get("rank_game_id", 0)), reverse=True
|
|
164
|
+
)
|
|
165
|
+
for row in competitive:
|
|
166
|
+
rate = StatRecord(row).win_rate
|
|
167
|
+
if rate is not None:
|
|
168
|
+
return rate
|
|
169
|
+
rate = StatRecord(nested).win_rate
|
|
170
|
+
if rate is not None:
|
|
171
|
+
return rate
|
|
172
|
+
return None
|