speedrun-mcp 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Jeffries
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.
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: speedrun-mcp
3
+ Version: 0.1.0
4
+ Summary: Model Context Protocol server for the speedrun.com API — games, leaderboards, world records, players and personal bests.
5
+ Author: William Jeffries
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/williamcodes/speedrun-mcp
8
+ Project-URL: Issues, https://github.com/williamcodes/speedrun-mcp/issues
9
+ Project-URL: speedrun.com API, https://github.com/speedruncomorg/api
10
+ Keywords: mcp,model-context-protocol,speedrun,speedrun.com,llm,claude
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Topic :: Games/Entertainment
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: mcp>=1.2.0
18
+ Requires-Dist: httpx>=0.27
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
22
+ Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # speedrun-mcp
26
+
27
+ <!-- mcp-name: io.github.williamcodes/speedrun-mcp -->
28
+
29
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for
30
+ [speedrun.com](https://www.speedrun.com). It lets an AI assistant query games,
31
+ categories, leaderboards, world records, players and their personal bests —
32
+ e.g. *"What's the current Super Mario 64 16-star world record, and who holds it?"*
33
+
34
+ Built on speedrun.com's official, public [REST API](https://github.com/speedruncomorg/api).
35
+ **No account or API key required** (the read endpoints are open); results are
36
+ shaped into compact, model-friendly JSON (player ids resolved to names,
37
+ durations formatted, subcategory variables labeled).
38
+
39
+ ## Tools
40
+
41
+ | Tool | What it does |
42
+ | --- | --- |
43
+ | `search_games` | Fuzzy-search games by name → ids & abbreviations |
44
+ | `get_game` | A game's details plus its categories (and optionally levels) |
45
+ | `list_categories` | A game's categories (`Any%`, `120 Star`, …) with rules |
46
+ | `list_variables` | Subcategory/filter variables and their value ids |
47
+ | `list_platforms` / `list_regions` | Platform / region ids for the `platform`/`region` leaderboard filters |
48
+ | `get_leaderboard` | A ranked leaderboard (top N; filter by variable / platform / region / timing) |
49
+ | `get_world_record` | The current #1 run for a game/category, plus any runs tied for first |
50
+ | `search_users` | Find players by username (partial, fuzzy match) |
51
+ | `get_user_personal_bests` | A player's PBs across all games |
52
+ | `get_run` | Details of a single run |
53
+
54
+ A typical flow: `search_games` → `list_categories` (and `list_variables` for
55
+ subcategories) → `get_leaderboard` / `get_world_record`. Use `list_platforms` /
56
+ `list_regions` when you need an id for the `platform` / `region` filters.
57
+
58
+ ## Install & run
59
+
60
+ Requires Python 3.10+.
61
+
62
+ ```bash
63
+ # from PyPI (once published)
64
+ pipx install speedrun-mcp # or: uv tool install speedrun-mcp
65
+
66
+ # from source
67
+ git clone https://github.com/williamcodes/speedrun-mcp
68
+ cd speedrun-mcp
69
+ pip install -e .
70
+ ```
71
+
72
+ The server speaks MCP over stdio:
73
+
74
+ ```bash
75
+ speedrun-mcp # console script
76
+ python -m speedrun_mcp # equivalent
77
+ ```
78
+
79
+ ## Use with Claude Desktop / Claude Code
80
+
81
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "speedrun": {
87
+ "command": "speedrun-mcp"
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ If you installed from source into a virtualenv, point `command` at that
94
+ interpreter, e.g. `"command": "/path/to/.venv/bin/speedrun-mcp"`.
95
+
96
+ For Claude Code:
97
+
98
+ ```bash
99
+ claude mcp add speedrun -- speedrun-mcp
100
+ ```
101
+
102
+ ## Notes & limits
103
+
104
+ - **Read-only.** Submitting or moderating runs requires an authenticated
105
+ speedrun.com session and is intentionally out of scope.
106
+ - **Rate limit:** speedrun.com allows 100 requests/minute per IP and responds
107
+ with HTTP 420 when exceeded; the client surfaces a clear error if you hit it.
108
+ - Game and category arguments accept either an id (`o1y9wo6q`) or an
109
+ abbreviation (`sm64`). For precise subcategory leaderboards (e.g. `16 Star`),
110
+ discover the variable/value ids with `list_variables` and pass
111
+ `variables={variable_id: value_id}`.
112
+ - **Errors are explanatory.** Invalid ids/filters raise an error that includes
113
+ speedrun.com's own message — e.g. passing a `level` to a full-game category
114
+ returns *"The selected category is for full-game runs, but a level was selected."*
115
+
116
+ ### Output shape
117
+
118
+ - **Times** reflect the leaderboard's sort timing. When you pass `timing`
119
+ (`realtime` / `realtime_noloads` / `ingame`), the reported `time` /
120
+ `time_seconds` match that ranking, not the game's default timing.
121
+ - **`get_leaderboard`** returns `returned_runs` (the number of runs returned,
122
+ bounded by `top` and ties — not the full board size) and a `runs` list with
123
+ resolved player names, formatted times, and labeled subcategories.
124
+ - **`get_world_record`** returns `world_record` (the place-1 run, or `null` if
125
+ the board is empty) plus `tied` (a list of any other runs sharing first place).
126
+ - **`get_user_personal_bests`** returns `returned` (how many came back, capped by
127
+ `limit`) and `total_available` (the player's true PB count), plus the
128
+ `personal_bests` list with game/category names and resolved players.
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ pip install -e ".[dev]"
134
+ pytest -m "not network" # unit tests (offline)
135
+ pytest # include live-API tests
136
+ ruff check .
137
+ ```
138
+
139
+ ## License
140
+
141
+ MIT
@@ -0,0 +1,117 @@
1
+ # speedrun-mcp
2
+
3
+ <!-- mcp-name: io.github.williamcodes/speedrun-mcp -->
4
+
5
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for
6
+ [speedrun.com](https://www.speedrun.com). It lets an AI assistant query games,
7
+ categories, leaderboards, world records, players and their personal bests —
8
+ e.g. *"What's the current Super Mario 64 16-star world record, and who holds it?"*
9
+
10
+ Built on speedrun.com's official, public [REST API](https://github.com/speedruncomorg/api).
11
+ **No account or API key required** (the read endpoints are open); results are
12
+ shaped into compact, model-friendly JSON (player ids resolved to names,
13
+ durations formatted, subcategory variables labeled).
14
+
15
+ ## Tools
16
+
17
+ | Tool | What it does |
18
+ | --- | --- |
19
+ | `search_games` | Fuzzy-search games by name → ids & abbreviations |
20
+ | `get_game` | A game's details plus its categories (and optionally levels) |
21
+ | `list_categories` | A game's categories (`Any%`, `120 Star`, …) with rules |
22
+ | `list_variables` | Subcategory/filter variables and their value ids |
23
+ | `list_platforms` / `list_regions` | Platform / region ids for the `platform`/`region` leaderboard filters |
24
+ | `get_leaderboard` | A ranked leaderboard (top N; filter by variable / platform / region / timing) |
25
+ | `get_world_record` | The current #1 run for a game/category, plus any runs tied for first |
26
+ | `search_users` | Find players by username (partial, fuzzy match) |
27
+ | `get_user_personal_bests` | A player's PBs across all games |
28
+ | `get_run` | Details of a single run |
29
+
30
+ A typical flow: `search_games` → `list_categories` (and `list_variables` for
31
+ subcategories) → `get_leaderboard` / `get_world_record`. Use `list_platforms` /
32
+ `list_regions` when you need an id for the `platform` / `region` filters.
33
+
34
+ ## Install & run
35
+
36
+ Requires Python 3.10+.
37
+
38
+ ```bash
39
+ # from PyPI (once published)
40
+ pipx install speedrun-mcp # or: uv tool install speedrun-mcp
41
+
42
+ # from source
43
+ git clone https://github.com/williamcodes/speedrun-mcp
44
+ cd speedrun-mcp
45
+ pip install -e .
46
+ ```
47
+
48
+ The server speaks MCP over stdio:
49
+
50
+ ```bash
51
+ speedrun-mcp # console script
52
+ python -m speedrun_mcp # equivalent
53
+ ```
54
+
55
+ ## Use with Claude Desktop / Claude Code
56
+
57
+ Add to your MCP client config (e.g. `claude_desktop_config.json`):
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "speedrun": {
63
+ "command": "speedrun-mcp"
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ If you installed from source into a virtualenv, point `command` at that
70
+ interpreter, e.g. `"command": "/path/to/.venv/bin/speedrun-mcp"`.
71
+
72
+ For Claude Code:
73
+
74
+ ```bash
75
+ claude mcp add speedrun -- speedrun-mcp
76
+ ```
77
+
78
+ ## Notes & limits
79
+
80
+ - **Read-only.** Submitting or moderating runs requires an authenticated
81
+ speedrun.com session and is intentionally out of scope.
82
+ - **Rate limit:** speedrun.com allows 100 requests/minute per IP and responds
83
+ with HTTP 420 when exceeded; the client surfaces a clear error if you hit it.
84
+ - Game and category arguments accept either an id (`o1y9wo6q`) or an
85
+ abbreviation (`sm64`). For precise subcategory leaderboards (e.g. `16 Star`),
86
+ discover the variable/value ids with `list_variables` and pass
87
+ `variables={variable_id: value_id}`.
88
+ - **Errors are explanatory.** Invalid ids/filters raise an error that includes
89
+ speedrun.com's own message — e.g. passing a `level` to a full-game category
90
+ returns *"The selected category is for full-game runs, but a level was selected."*
91
+
92
+ ### Output shape
93
+
94
+ - **Times** reflect the leaderboard's sort timing. When you pass `timing`
95
+ (`realtime` / `realtime_noloads` / `ingame`), the reported `time` /
96
+ `time_seconds` match that ranking, not the game's default timing.
97
+ - **`get_leaderboard`** returns `returned_runs` (the number of runs returned,
98
+ bounded by `top` and ties — not the full board size) and a `runs` list with
99
+ resolved player names, formatted times, and labeled subcategories.
100
+ - **`get_world_record`** returns `world_record` (the place-1 run, or `null` if
101
+ the board is empty) plus `tied` (a list of any other runs sharing first place).
102
+ - **`get_user_personal_bests`** returns `returned` (how many came back, capped by
103
+ `limit`) and `total_available` (the player's true PB count), plus the
104
+ `personal_bests` list with game/category names and resolved players.
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ pip install -e ".[dev]"
110
+ pytest -m "not network" # unit tests (offline)
111
+ pytest # include live-API tests
112
+ ruff check .
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "speedrun-mcp"
7
+ version = "0.1.0"
8
+ description = "Model Context Protocol server for the speedrun.com API — games, leaderboards, world records, players and personal bests."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "William Jeffries" }]
13
+ keywords = ["mcp", "model-context-protocol", "speedrun", "speedrun.com", "llm", "claude"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Topic :: Games/Entertainment",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.2.0",
21
+ "httpx>=0.27",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/williamcodes/speedrun-mcp"
29
+ Issues = "https://github.com/williamcodes/speedrun-mcp/issues"
30
+ "speedrun.com API" = "https://github.com/speedruncomorg/api"
31
+
32
+ [project.scripts]
33
+ speedrun-mcp = "speedrun_mcp.server:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ markers = ["network: tests that hit the live speedrun.com API"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """speedrun-mcp: a Model Context Protocol server for the speedrun.com API."""
2
+
3
+ from .server import mcp
4
+
5
+ __all__ = ["mcp"]
@@ -0,0 +1,4 @@
1
+ from .server import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,230 @@
1
+ """Thin async client for the speedrun.com REST API (v1).
2
+
3
+ The public API requires no authentication for the read endpoints used here.
4
+ Docs: https://github.com/speedruncomorg/api/tree/master/version1
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ API_BASE = "https://www.speedrun.com/api/v1"
15
+
16
+ try:
17
+ _VERSION = version("speedrun-mcp")
18
+ except PackageNotFoundError: # running from a source checkout without install
19
+ _VERSION = "0.0.0+dev"
20
+
21
+ USER_AGENT = f"speedrun-mcp/{_VERSION} (+https://github.com/williamcodes/speedrun-mcp)"
22
+
23
+ # speedrun.com allows 100 requests/min/IP and answers 420 when exceeded.
24
+ RATE_LIMIT_STATUS = 420
25
+
26
+
27
+ class SpeedrunError(RuntimeError):
28
+ """Raised when the speedrun.com API returns an error we can explain."""
29
+
30
+
31
+ class RateLimitError(SpeedrunError):
32
+ """Raised when the API rejects us for exceeding 100 requests/minute."""
33
+
34
+
35
+ class NotFoundError(SpeedrunError):
36
+ """Raised when the API returns HTTP 404 for a resource (bad id/filters)."""
37
+
38
+
39
+ class SpeedrunClient:
40
+ """Minimal async wrapper around the speedrun.com API.
41
+
42
+ One client owns one ``httpx.AsyncClient``; use it as an async context
43
+ manager or remember to ``await close()``.
44
+ """
45
+
46
+ def __init__(self, *, timeout: float = 20.0) -> None:
47
+ self._http = httpx.AsyncClient(
48
+ base_url=API_BASE,
49
+ timeout=timeout,
50
+ headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
51
+ follow_redirects=True, # abbreviations 30x-redirect to ID-based URLs
52
+ )
53
+
54
+ async def __aenter__(self) -> "SpeedrunClient":
55
+ return self
56
+
57
+ async def __aexit__(self, *_exc: object) -> None:
58
+ await self.close()
59
+
60
+ async def close(self) -> None:
61
+ await self._http.aclose()
62
+
63
+ async def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
64
+ """GET a path and return the full parsed JSON body (incl. pagination)."""
65
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
66
+ try:
67
+ resp = await self._http.get(path, params=clean)
68
+ except httpx.HTTPError as exc: # network/DNS/timeout
69
+ raise SpeedrunError(f"Network error talking to speedrun.com: {exc}") from exc
70
+
71
+ if resp.status_code == RATE_LIMIT_STATUS:
72
+ raise RateLimitError(
73
+ "speedrun.com rate limit hit (100 requests/minute). Wait a minute and retry."
74
+ )
75
+
76
+ if resp.status_code == 404:
77
+ detail = self._error_message(resp)
78
+ msg = f"Not found: {path} (check the id/abbreviation and any filters)."
79
+ if detail:
80
+ msg = f"{msg} speedrun.com says: {detail}"
81
+ raise NotFoundError(msg)
82
+
83
+ if resp.status_code >= 400:
84
+ detail = self._error_message(resp)
85
+ msg = f"speedrun.com returned HTTP {resp.status_code} for {path}."
86
+ if detail:
87
+ msg = f"{msg} speedrun.com says: {detail}"
88
+ raise SpeedrunError(msg)
89
+
90
+ try:
91
+ return resp.json()
92
+ except ValueError as exc: # non-JSON / empty success body
93
+ raise SpeedrunError(
94
+ f"speedrun.com returned an unparseable response for {path}: {exc}"
95
+ ) from exc
96
+
97
+ async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
98
+ """GET a path and return the parsed ``data`` payload.
99
+
100
+ speedrun.com wraps successful responses in ``{"data": ...}``; we unwrap
101
+ it so callers never have to. Pagination metadata is dropped on purpose —
102
+ for single-page tools that cap their own result counts. Use
103
+ :meth:`_get_paginated` for collections that may exceed one page.
104
+ """
105
+ body = await self._request(path, params)
106
+ if not isinstance(body, dict):
107
+ return body
108
+ return body.get("data", body)
109
+
110
+ async def _get_paginated(self, path: str, params: dict[str, Any] | None = None) -> list[dict]:
111
+ """Fetch and concatenate ALL pages of a collection endpoint.
112
+
113
+ Some collections (e.g. ``/platforms``, ~235 items) exceed the 200/page
114
+ cap, so a single request silently truncates. This walks every page by
115
+ following the ``pagination.links`` ``next`` marker / incrementing offset.
116
+ """
117
+ merged = dict(params or {})
118
+ page_size = int(merged.get("max") or 200)
119
+ merged["max"] = page_size
120
+ collected: list[dict] = []
121
+ offset = 0
122
+ while True:
123
+ merged["offset"] = offset
124
+ body = await self._request(path, merged)
125
+ data = body.get("data", []) if isinstance(body, dict) else (body or [])
126
+ collected.extend(data)
127
+ pagination = body.get("pagination", {}) if isinstance(body, dict) else {}
128
+ has_next = any(link.get("rel") == "next" for link in pagination.get("links", []))
129
+ if not has_next or len(data) < page_size: # last (or short/empty) page
130
+ break
131
+ offset += page_size
132
+ return collected
133
+
134
+ @staticmethod
135
+ def _error_message(resp: httpx.Response) -> str | None:
136
+ """Best-effort extraction of the ``message`` field from an error body.
137
+
138
+ speedrun.com error bodies look like
139
+ ``{"status":400,"message":"...","links":[...]}``. A non-JSON or empty
140
+ body must not raise here — we just return ``None`` so the caller can
141
+ fall back to a generic message.
142
+ """
143
+ try:
144
+ body = resp.json()
145
+ except ValueError:
146
+ return None
147
+ if isinstance(body, dict):
148
+ message = body.get("message")
149
+ if isinstance(message, str) and message.strip():
150
+ return message.strip()
151
+ return None
152
+
153
+ # -- games ----------------------------------------------------------------
154
+
155
+ async def search_games(self, name: str, *, maximum: int = 10) -> list[dict]:
156
+ return await self._get("/games", {"name": name, "max": maximum})
157
+
158
+ async def get_game(self, game: str, *, embed: str | None = None) -> dict:
159
+ return await self._get(f"/games/{game}", {"embed": embed})
160
+
161
+ async def get_categories(self, game: str) -> list[dict]:
162
+ return await self._get(f"/games/{game}/categories")
163
+
164
+ async def get_levels(self, game: str) -> list[dict]:
165
+ return await self._get(f"/games/{game}/levels")
166
+
167
+ async def get_game_variables(self, game: str) -> list[dict]:
168
+ return await self._get(f"/games/{game}/variables")
169
+
170
+ async def get_category_variables(self, category: str) -> list[dict]:
171
+ return await self._get(f"/categories/{category}/variables")
172
+
173
+ # -- leaderboards ---------------------------------------------------------
174
+
175
+ async def get_leaderboard(
176
+ self,
177
+ game: str,
178
+ category: str,
179
+ *,
180
+ level: str | None = None,
181
+ top: int | None = None,
182
+ variables: dict[str, str] | None = None,
183
+ platform: str | None = None,
184
+ region: str | None = None,
185
+ timing: str | None = None,
186
+ emulators: bool | None = None,
187
+ date: str | None = None,
188
+ embed: str | None = None,
189
+ ) -> dict:
190
+ if level:
191
+ path = f"/leaderboards/{game}/level/{level}/{category}"
192
+ else:
193
+ path = f"/leaderboards/{game}/category/{category}"
194
+ params: dict[str, Any] = {
195
+ "top": top,
196
+ "platform": platform,
197
+ "region": region,
198
+ "timing": timing,
199
+ "emulators": emulators,
200
+ "date": date,
201
+ "embed": embed,
202
+ }
203
+ for var_id, value_id in (variables or {}).items():
204
+ params[f"var-{var_id}"] = value_id
205
+ return await self._get(path, params)
206
+
207
+ # -- users / runs ---------------------------------------------------------
208
+
209
+ async def search_users(self, name: str, *, maximum: int = 10) -> list[dict]:
210
+ # 'name' does fuzzy/partial matching; 'lookup' is exact-only.
211
+ return await self._get("/users", {"name": name, "max": maximum})
212
+
213
+ async def get_user(self, user: str) -> dict:
214
+ return await self._get(f"/users/{user}")
215
+
216
+ async def get_user_personal_bests(
217
+ self, user: str, *, embed: str | None = None
218
+ ) -> list[dict]:
219
+ return await self._get(f"/users/{user}/personal-bests", {"embed": embed})
220
+
221
+ async def get_run(self, run_id: str, *, embed: str | None = None) -> dict:
222
+ return await self._get(f"/runs/{run_id}", {"embed": embed})
223
+
224
+ # -- platforms / regions --------------------------------------------------
225
+
226
+ async def get_platforms(self) -> list[dict]:
227
+ return await self._get_paginated("/platforms")
228
+
229
+ async def get_regions(self) -> list[dict]:
230
+ return await self._get_paginated("/regions")