mcp-steam 0.1.2__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,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-steam
3
+ Version: 0.1.2
4
+ Summary: MCP server for Steam gaming library, achievements, stats, and store search
5
+ Keywords: mcp,steam,gaming,model-context-protocol
6
+ Author: Matthew O'Brien
7
+ Author-email: Matthew O'Brien <obrien.mlotwis@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Games/Entertainment
14
+ Requires-Dist: mcp>=1.27.0,<2
15
+ Requires-Dist: httpx>=0.28.0
16
+ Requires-Python: >=3.14
17
+ Project-URL: Repository, https://github.com/obrien-matthew/mcp-steam
18
+ Project-URL: Issues, https://github.com/obrien-matthew/mcp-steam/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mcp-steam
22
+
23
+ MCP server for Steam, focused on gaming library management, achievements, stats, and store discovery. 12 granular tools designed for use with Claude and other LLM agents.
24
+
25
+ ## Prerequisites
26
+
27
+ - Python 3.14+
28
+ - [uv](https://docs.astral.sh/uv/)
29
+ - A [Steam Web API Key](https://steamcommunity.com/dev/apikey)
30
+ - Your Steam ID (numeric, up to 17 digits)
31
+
32
+ ## Setup
33
+
34
+ ### 1. Get Your Steam API Key
35
+
36
+ 1. Go to the [Steam Web API Key page](https://steamcommunity.com/dev/apikey)
37
+ 2. Sign in with your Steam account
38
+ 3. Register a domain name (any name works for personal use)
39
+ 4. Note your API key
40
+
41
+ ### 2. Find Your Steam ID
42
+
43
+ Your Steam ID is the numeric identifier in your profile URL. If your profile URL is `https://steamcommunity.com/profiles/76561198012345678`, your Steam ID is `76561198012345678`.
44
+
45
+ If you use a custom URL (e.g., `/id/username`), use a [Steam ID finder](https://www.steamidfinder.com/) to look up the numeric ID.
46
+
47
+ ### 3. Install
48
+
49
+ ```bash
50
+ cd mcp-steam
51
+ uv sync
52
+ ```
53
+
54
+ ### 4. Configure Environment Variables
55
+
56
+ Set these before running the server:
57
+
58
+ ```bash
59
+ export STEAM_API_KEY="your_api_key"
60
+ export STEAM_ID="your_steam_id"
61
+ ```
62
+
63
+ ### 5. Test the Connection
64
+
65
+ ```bash
66
+ uv run mcp-steam
67
+ ```
68
+
69
+ The server verifies your API key and Steam ID on startup by fetching your player summary.
70
+
71
+ ## Claude Desktop / Claude Code Configuration
72
+
73
+ Add to your MCP server config. If installed from PyPI:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "steam": {
79
+ "command": "uvx",
80
+ "args": ["mcp-steam"],
81
+ "env": {
82
+ "STEAM_API_KEY": "your_api_key",
83
+ "STEAM_ID": "your_steam_id"
84
+ }
85
+ }
86
+ }
87
+ }
88
+ ```
89
+
90
+ Or if running from a local clone:
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "steam": {
96
+ "command": "uv",
97
+ "args": ["--directory", "/path/to/mcp-steam", "run", "mcp-steam"],
98
+ "env": {
99
+ "STEAM_API_KEY": "your_api_key",
100
+ "STEAM_ID": "your_steam_id"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ ## Tools
108
+
109
+ ### Library
110
+
111
+ | Tool | Parameters | Description |
112
+ |------|-----------|-------------|
113
+ | `get_owned_games` | `sort_by="playtime"`, `limit=50` | Your game library with playtime. Sort by playtime, recent, or name. |
114
+ | `get_recently_played` | `limit=10` | Games played in the last 2 weeks. |
115
+
116
+ ### Game Info
117
+
118
+ | Tool | Parameters | Description |
119
+ |------|-----------|-------------|
120
+ | `get_game_details` | `app_id` | Store page info: description, price, genres, metacritic, platforms. |
121
+ | `search_games` | `query`, `limit=10` | Search the Steam store. |
122
+
123
+ ### Achievements & Stats
124
+
125
+ | Tool | Parameters | Description |
126
+ |------|-----------|-------------|
127
+ | `get_achievements` | `app_id` | Your achievement progress with global rarity percentages. |
128
+ | `get_player_stats` | `app_id` | Game-specific stats (kills, deaths, etc.). |
129
+ | `get_global_achievement_stats` | `app_id` | Global unlock percentages for all achievements. |
130
+
131
+ ### Wishlist
132
+
133
+ | Tool | Parameters | Description |
134
+ |------|-----------|-------------|
135
+ | `get_wishlist` | `limit=50` | Your wishlist sorted by priority, with prices and discounts. |
136
+
137
+ ### News
138
+
139
+ | Tool | Parameters | Description |
140
+ |------|-----------|-------------|
141
+ | `get_game_news` | `app_id`, `count=5` | Recent news and updates for a game. |
142
+
143
+ ### Profile
144
+
145
+ | Tool | Parameters | Description |
146
+ |------|-----------|-------------|
147
+ | `get_player_summary` | (none) | Your profile: name, status, currently playing. |
148
+ | `get_friend_list` | (none) | Your friends list with relationship info. |
149
+
150
+ ### Featured
151
+
152
+ | Tool | Parameters | Description |
153
+ |------|-----------|-------------|
154
+ | `get_featured_games` | (none) | Currently featured and on-sale games. |
155
+
156
+ ## Steam Web API Notes
157
+
158
+ - **API key security:** Your config file contains your Steam API key. Never commit it to version control or share it publicly.
159
+ - **Rate limits:** The Steam Web API has undocumented rate limits. If you hit them, the server will return a rate limit error.
160
+ - **Profile visibility:** Some tools require your Steam profile to be public (achievements, game stats). Library data works regardless.
161
+ - **Game stats availability:** Not all games expose stats through the API. `get_player_stats` will return an error for unsupported games.
162
+ - **Wishlist access:** Wishlist data requires your profile's wishlist to be public.
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ uv run mcp-steam # Run the server
168
+ uv run ruff check src/ # Lint
169
+ uv run ruff format src/ # Format
170
+ uv run pyright src/ # Type check
171
+ ```
172
+
173
+ ### Pre-commit Hooks
174
+
175
+ This project uses [lefthook](https://github.com/evilmartians/lefthook) for pre-commit checks. Install with `brew install lefthook` (or see [other install methods](https://github.com/evilmartians/lefthook/blob/master/docs/install.md)), then:
176
+
177
+ ```bash
178
+ lefthook install
179
+ ```
@@ -0,0 +1,159 @@
1
+ # mcp-steam
2
+
3
+ MCP server for Steam, focused on gaming library management, achievements, stats, and store discovery. 12 granular tools designed for use with Claude and other LLM agents.
4
+
5
+ ## Prerequisites
6
+
7
+ - Python 3.14+
8
+ - [uv](https://docs.astral.sh/uv/)
9
+ - A [Steam Web API Key](https://steamcommunity.com/dev/apikey)
10
+ - Your Steam ID (numeric, up to 17 digits)
11
+
12
+ ## Setup
13
+
14
+ ### 1. Get Your Steam API Key
15
+
16
+ 1. Go to the [Steam Web API Key page](https://steamcommunity.com/dev/apikey)
17
+ 2. Sign in with your Steam account
18
+ 3. Register a domain name (any name works for personal use)
19
+ 4. Note your API key
20
+
21
+ ### 2. Find Your Steam ID
22
+
23
+ Your Steam ID is the numeric identifier in your profile URL. If your profile URL is `https://steamcommunity.com/profiles/76561198012345678`, your Steam ID is `76561198012345678`.
24
+
25
+ If you use a custom URL (e.g., `/id/username`), use a [Steam ID finder](https://www.steamidfinder.com/) to look up the numeric ID.
26
+
27
+ ### 3. Install
28
+
29
+ ```bash
30
+ cd mcp-steam
31
+ uv sync
32
+ ```
33
+
34
+ ### 4. Configure Environment Variables
35
+
36
+ Set these before running the server:
37
+
38
+ ```bash
39
+ export STEAM_API_KEY="your_api_key"
40
+ export STEAM_ID="your_steam_id"
41
+ ```
42
+
43
+ ### 5. Test the Connection
44
+
45
+ ```bash
46
+ uv run mcp-steam
47
+ ```
48
+
49
+ The server verifies your API key and Steam ID on startup by fetching your player summary.
50
+
51
+ ## Claude Desktop / Claude Code Configuration
52
+
53
+ Add to your MCP server config. If installed from PyPI:
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "steam": {
59
+ "command": "uvx",
60
+ "args": ["mcp-steam"],
61
+ "env": {
62
+ "STEAM_API_KEY": "your_api_key",
63
+ "STEAM_ID": "your_steam_id"
64
+ }
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ Or if running from a local clone:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "steam": {
76
+ "command": "uv",
77
+ "args": ["--directory", "/path/to/mcp-steam", "run", "mcp-steam"],
78
+ "env": {
79
+ "STEAM_API_KEY": "your_api_key",
80
+ "STEAM_ID": "your_steam_id"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ ## Tools
88
+
89
+ ### Library
90
+
91
+ | Tool | Parameters | Description |
92
+ |------|-----------|-------------|
93
+ | `get_owned_games` | `sort_by="playtime"`, `limit=50` | Your game library with playtime. Sort by playtime, recent, or name. |
94
+ | `get_recently_played` | `limit=10` | Games played in the last 2 weeks. |
95
+
96
+ ### Game Info
97
+
98
+ | Tool | Parameters | Description |
99
+ |------|-----------|-------------|
100
+ | `get_game_details` | `app_id` | Store page info: description, price, genres, metacritic, platforms. |
101
+ | `search_games` | `query`, `limit=10` | Search the Steam store. |
102
+
103
+ ### Achievements & Stats
104
+
105
+ | Tool | Parameters | Description |
106
+ |------|-----------|-------------|
107
+ | `get_achievements` | `app_id` | Your achievement progress with global rarity percentages. |
108
+ | `get_player_stats` | `app_id` | Game-specific stats (kills, deaths, etc.). |
109
+ | `get_global_achievement_stats` | `app_id` | Global unlock percentages for all achievements. |
110
+
111
+ ### Wishlist
112
+
113
+ | Tool | Parameters | Description |
114
+ |------|-----------|-------------|
115
+ | `get_wishlist` | `limit=50` | Your wishlist sorted by priority, with prices and discounts. |
116
+
117
+ ### News
118
+
119
+ | Tool | Parameters | Description |
120
+ |------|-----------|-------------|
121
+ | `get_game_news` | `app_id`, `count=5` | Recent news and updates for a game. |
122
+
123
+ ### Profile
124
+
125
+ | Tool | Parameters | Description |
126
+ |------|-----------|-------------|
127
+ | `get_player_summary` | (none) | Your profile: name, status, currently playing. |
128
+ | `get_friend_list` | (none) | Your friends list with relationship info. |
129
+
130
+ ### Featured
131
+
132
+ | Tool | Parameters | Description |
133
+ |------|-----------|-------------|
134
+ | `get_featured_games` | (none) | Currently featured and on-sale games. |
135
+
136
+ ## Steam Web API Notes
137
+
138
+ - **API key security:** Your config file contains your Steam API key. Never commit it to version control or share it publicly.
139
+ - **Rate limits:** The Steam Web API has undocumented rate limits. If you hit them, the server will return a rate limit error.
140
+ - **Profile visibility:** Some tools require your Steam profile to be public (achievements, game stats). Library data works regardless.
141
+ - **Game stats availability:** Not all games expose stats through the API. `get_player_stats` will return an error for unsupported games.
142
+ - **Wishlist access:** Wishlist data requires your profile's wishlist to be public.
143
+
144
+ ## Development
145
+
146
+ ```bash
147
+ uv run mcp-steam # Run the server
148
+ uv run ruff check src/ # Lint
149
+ uv run ruff format src/ # Format
150
+ uv run pyright src/ # Type check
151
+ ```
152
+
153
+ ### Pre-commit Hooks
154
+
155
+ This project uses [lefthook](https://github.com/evilmartians/lefthook) for pre-commit checks. Install with `brew install lefthook` (or see [other install methods](https://github.com/evilmartians/lefthook/blob/master/docs/install.md)), then:
156
+
157
+ ```bash
158
+ lefthook install
159
+ ```
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "mcp-steam"
3
+ version = "0.1.2"
4
+ description = "MCP server for Steam gaming library, achievements, stats, and store search"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Matthew O'Brien", email = "obrien.mlotwis@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.14"
11
+ keywords = ["mcp", "steam", "gaming", "model-context-protocol"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Games/Entertainment",
18
+ ]
19
+ dependencies = [
20
+ "mcp>=1.27.0,<2",
21
+ "httpx>=0.28.0",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/obrien-matthew/mcp-steam"
26
+ Issues = "https://github.com/obrien-matthew/mcp-steam/issues"
27
+
28
+ [tool.uv.build-backend]
29
+ module-name = "steam_mcp"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.3,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "ruff>=0.11.0",
38
+ "pyright>=1.1.400",
39
+ ]
40
+
41
+ [tool.ruff]
42
+ target-version = "py314"
43
+ line-length = 88
44
+
45
+ [tool.ruff.lint]
46
+ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
47
+
48
+ [tool.pyright]
49
+ pythonVersion = "3.14"
50
+ typeCheckingMode = "standard"
51
+ venvPath = "."
52
+ venv = ".venv"
53
+
54
+ [project.scripts]
55
+ mcp-steam = "steam_mcp:main"
@@ -0,0 +1,10 @@
1
+ """MCP server for Steam gaming library, achievements, stats, and store search."""
2
+
3
+ from .server import mcp
4
+
5
+
6
+ def main():
7
+ mcp.run(transport="stdio")
8
+
9
+
10
+ __all__ = ["main", "mcp"]
@@ -0,0 +1,87 @@
1
+ """Steam API key validation and HTTP client singleton."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import httpx
7
+
8
+ _client: httpx.Client | None = None
9
+ _steam_id: str | None = None
10
+
11
+
12
+ def get_steam_id() -> str:
13
+ """Return the configured Steam ID."""
14
+ global _steam_id
15
+ if _steam_id is not None:
16
+ return _steam_id
17
+
18
+ steam_id = os.environ.get("STEAM_ID")
19
+ if not steam_id:
20
+ raise RuntimeError(
21
+ "STEAM_ID environment variable is required. "
22
+ "See README.md for setup instructions."
23
+ )
24
+ _steam_id = steam_id
25
+ return _steam_id
26
+
27
+
28
+ def get_http_client() -> httpx.Client:
29
+ """Return a cached HTTP client for Steam API calls.
30
+
31
+ Reads STEAM_API_KEY from environment variables and verifies
32
+ the key works by fetching the player summary.
33
+
34
+ Raises RuntimeError if required env vars are missing or key is invalid.
35
+ """
36
+ global _client
37
+ if _client is not None:
38
+ return _client
39
+
40
+ api_key = os.environ.get("STEAM_API_KEY")
41
+ if not api_key:
42
+ raise RuntimeError(
43
+ "STEAM_API_KEY environment variable is required. "
44
+ "See README.md for setup instructions."
45
+ )
46
+
47
+ steam_id = get_steam_id()
48
+
49
+ client = httpx.Client(timeout=30.0)
50
+
51
+ # Verify API key works by fetching player summary
52
+ try:
53
+ resp = client.get(
54
+ "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/",
55
+ params={"key": api_key, "steamids": steam_id},
56
+ )
57
+ resp.raise_for_status()
58
+ data = resp.json()
59
+ players = data.get("response", {}).get("players", [])
60
+ if not players:
61
+ raise RuntimeError(
62
+ f"No player found for Steam ID '{steam_id}'. "
63
+ "Please check your STEAM_ID."
64
+ )
65
+ except httpx.HTTPStatusError as e:
66
+ client.close()
67
+ print(f"Steam API key verification failed: {e}", file=sys.stderr)
68
+ raise RuntimeError(
69
+ "Failed to verify Steam API key. Please check your credentials."
70
+ ) from e
71
+ except Exception as e:
72
+ client.close()
73
+ print(f"Steam API connection failed: {e}", file=sys.stderr)
74
+ raise RuntimeError(
75
+ "Failed to connect to Steam API. Please check your network."
76
+ ) from e
77
+
78
+ _client = client
79
+ return _client
80
+
81
+
82
+ def get_api_key() -> str:
83
+ """Return the Steam API key from environment."""
84
+ api_key = os.environ.get("STEAM_API_KEY")
85
+ if not api_key:
86
+ raise RuntimeError("STEAM_API_KEY environment variable is required.")
87
+ return api_key
@@ -0,0 +1,319 @@
1
+ """Thin wrapper over the Steam Web API with validation and clean error handling."""
2
+
3
+ import sys
4
+ from typing import Any, NoReturn
5
+
6
+ import httpx
7
+
8
+ from .auth import get_api_key, get_http_client, get_steam_id
9
+ from .formatting import (
10
+ format_achievement,
11
+ format_featured_game,
12
+ format_friend,
13
+ format_game_details,
14
+ format_news_item,
15
+ format_owned_game,
16
+ format_player_summary,
17
+ format_wishlist_item,
18
+ )
19
+ from .validation import validate_app_id, validate_limit
20
+
21
+ STEAM_API_BASE = "https://api.steampowered.com"
22
+ STORE_API_BASE = "https://store.steampowered.com"
23
+
24
+
25
+ class SteamError(Exception):
26
+ """User-facing Steam API error."""
27
+
28
+ def __init__(self, message: str, status_code: int | None = None):
29
+ self.message = message
30
+ self.status_code = status_code
31
+ super().__init__(message)
32
+
33
+
34
+ class SteamClient:
35
+ """Validated, formatted interface to the Steam API."""
36
+
37
+ def __init__(self) -> None:
38
+ self._http = get_http_client()
39
+ self._api_key = get_api_key()
40
+ self._steam_id = get_steam_id()
41
+
42
+ def _handle_error(self, e: Exception, action: str) -> NoReturn:
43
+ msg = f"Steam API error while {action}"
44
+ status_code: int | None = None
45
+ if isinstance(e, httpx.HTTPStatusError):
46
+ status_code = e.response.status_code
47
+ if status_code == 403:
48
+ msg = f"Access denied while {action} (check API key permissions)"
49
+ elif status_code == 404:
50
+ msg = f"Not found while {action}"
51
+ elif status_code == 429:
52
+ msg = f"Rate limited while {action}. Please try again shortly."
53
+ elif status_code == 500:
54
+ msg = f"Steam server error while {action}. Try again later."
55
+ print(f"{msg}: {e}", file=sys.stderr)
56
+ raise SteamError(msg, status_code) from e
57
+
58
+ def _api_request(self, endpoint: str, params: dict[str, Any] | None = None) -> dict:
59
+ """Make a request to the Steam Web API (api.steampowered.com)."""
60
+ url = f"{STEAM_API_BASE}/{endpoint}"
61
+ request_params: dict[str, Any] = {"key": self._api_key}
62
+ if params:
63
+ request_params.update(params)
64
+ resp = self._http.get(url, params=request_params)
65
+ resp.raise_for_status()
66
+ return resp.json()
67
+
68
+ def _store_request(self, path: str, params: dict[str, Any] | None = None) -> dict:
69
+ """Make a request to the Steam Store API (store.steampowered.com)."""
70
+ url = f"{STORE_API_BASE}/{path}"
71
+ resp = self._http.get(url, params=params or {})
72
+ resp.raise_for_status()
73
+ return resp.json()
74
+
75
+ # -- Library --
76
+
77
+ def get_owned_games(self, sort_by: str = "playtime", limit: int = 50) -> list[dict]:
78
+ limit = validate_limit(limit, max_val=100)
79
+ try:
80
+ data = self._api_request(
81
+ "IPlayerService/GetOwnedGames/v1/",
82
+ {
83
+ "steamid": self._steam_id,
84
+ "include_appinfo": 1,
85
+ "include_played_free_games": 1,
86
+ },
87
+ )
88
+ games = data.get("response", {}).get("games", [])
89
+ if sort_by == "playtime":
90
+ games.sort(key=lambda g: g.get("playtime_forever", 0), reverse=True)
91
+ elif sort_by == "recent":
92
+ games.sort(key=lambda g: g.get("rtime_last_played", 0), reverse=True)
93
+ elif sort_by == "name":
94
+ games.sort(key=lambda g: g.get("name", "").lower())
95
+ return [format_owned_game(g) for g in games[:limit]]
96
+ except httpx.HTTPStatusError as e:
97
+ self._handle_error(e, "fetching owned games")
98
+
99
+ def get_recently_played(self, limit: int = 10) -> list[dict]:
100
+ limit = validate_limit(limit)
101
+ try:
102
+ data = self._api_request(
103
+ "IPlayerService/GetRecentlyPlayedGames/v1/",
104
+ {"steamid": self._steam_id, "count": limit},
105
+ )
106
+ games = data.get("response", {}).get("games", [])
107
+ return [format_owned_game(g) for g in games]
108
+ except httpx.HTTPStatusError as e:
109
+ self._handle_error(e, "fetching recently played games")
110
+
111
+ # -- Game Info --
112
+
113
+ def get_game_details(self, app_id: str) -> dict:
114
+ app_id_int = validate_app_id(app_id)
115
+ try:
116
+ data = self._store_request(
117
+ "api/appdetails",
118
+ {"appids": str(app_id_int)},
119
+ )
120
+ app_data = data.get(str(app_id_int), {})
121
+ if not app_data.get("success"):
122
+ raise SteamError(f"App {app_id_int} not found on Steam Store")
123
+ return format_game_details(app_data.get("data", {}))
124
+ except httpx.HTTPStatusError as e:
125
+ self._handle_error(e, "fetching game details")
126
+
127
+ def search_games(self, query: str, limit: int = 10) -> list[dict]:
128
+ limit = validate_limit(limit, max_val=25)
129
+ try:
130
+ data = self._store_request(
131
+ "api/storesearch",
132
+ {"term": query, "l": "english", "cc": "us"},
133
+ )
134
+ items = data.get("items", [])[:limit]
135
+ results: list[dict] = []
136
+ for item in items:
137
+ results.append(
138
+ {
139
+ "name": item.get("name", ""),
140
+ "appid": item.get("id"),
141
+ "price": item.get("price", {}).get("final", 0),
142
+ "platforms": {
143
+ k: v for k, v in item.get("platforms", {}).items() if v
144
+ },
145
+ }
146
+ )
147
+ return results
148
+ except httpx.HTTPStatusError as e:
149
+ self._handle_error(e, "searching games")
150
+
151
+ # -- Achievements & Stats --
152
+
153
+ def get_achievements(self, app_id: str) -> dict:
154
+ app_id_int = validate_app_id(app_id)
155
+ try:
156
+ data = self._api_request(
157
+ "ISteamUserStats/GetPlayerAchievements/v1/",
158
+ {"steamid": self._steam_id, "appid": app_id_int},
159
+ )
160
+ stats = data.get("playerstats", {})
161
+ achievements = stats.get("achievements", [])
162
+
163
+ # Try to get global percentages for context
164
+ global_pcts = self._get_global_percentages(app_id_int)
165
+
166
+ formatted = [format_achievement(a, global_pcts) for a in achievements]
167
+ unlocked = sum(1 for a in formatted if a.get("achieved"))
168
+ return {
169
+ "game": stats.get("gameName", ""),
170
+ "unlocked": unlocked,
171
+ "total": len(formatted),
172
+ "achievements": formatted,
173
+ }
174
+ except httpx.HTTPStatusError as e:
175
+ self._handle_error(e, "fetching achievements")
176
+
177
+ def _get_global_percentages(self, app_id: int) -> dict[str, float]:
178
+ """Fetch global achievement percentages for enrichment."""
179
+ try:
180
+ data = self._api_request(
181
+ "ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
182
+ {"gameid": app_id},
183
+ )
184
+ achievements = data.get("achievementpercentages", {}).get(
185
+ "achievements", []
186
+ )
187
+ return {a["name"]: a["percent"] for a in achievements}
188
+ except Exception:
189
+ return {}
190
+
191
+ def get_player_stats(self, app_id: str) -> dict:
192
+ app_id_int = validate_app_id(app_id)
193
+ try:
194
+ data = self._api_request(
195
+ "ISteamUserStats/GetUserStatsForGame/v2/",
196
+ {"steamid": self._steam_id, "appid": app_id_int},
197
+ )
198
+ stats = data.get("playerstats", {})
199
+ return {
200
+ "game": stats.get("gameName", ""),
201
+ "stats": {s["name"]: s["value"] for s in stats.get("stats", [])},
202
+ }
203
+ except httpx.HTTPStatusError as e:
204
+ self._handle_error(e, "fetching player stats")
205
+
206
+ def get_global_achievement_stats(self, app_id: str) -> list[dict]:
207
+ app_id_int = validate_app_id(app_id)
208
+ try:
209
+ data = self._api_request(
210
+ "ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
211
+ {"gameid": app_id_int},
212
+ )
213
+ achievements = data.get("achievementpercentages", {}).get(
214
+ "achievements", []
215
+ )
216
+ return [
217
+ {
218
+ "name": a.get("name", ""),
219
+ "percent": round(a.get("percent", 0.0), 1),
220
+ }
221
+ for a in achievements
222
+ ]
223
+ except httpx.HTTPStatusError as e:
224
+ self._handle_error(e, "fetching global achievement stats")
225
+
226
+ # -- Wishlist --
227
+
228
+ def get_wishlist(self, limit: int = 50) -> list[dict]:
229
+ limit = validate_limit(limit, max_val=100)
230
+ try:
231
+ data = self._store_request(
232
+ f"wishlist/profiles/{self._steam_id}/wishlistdata",
233
+ )
234
+ items = [
235
+ format_wishlist_item(appid, info)
236
+ for appid, info in data.items()
237
+ if isinstance(info, dict)
238
+ ]
239
+ # Sort by priority (0 = no priority, otherwise lower = higher priority)
240
+ items.sort(
241
+ key=lambda x: (
242
+ x.get("priority", 0) == 0,
243
+ x.get("priority", 0),
244
+ )
245
+ )
246
+ return items[:limit]
247
+ except httpx.HTTPStatusError as e:
248
+ self._handle_error(e, "fetching wishlist")
249
+
250
+ # -- News --
251
+
252
+ def get_game_news(self, app_id: str, count: int = 5) -> list[dict]:
253
+ app_id_int = validate_app_id(app_id)
254
+ count = validate_limit(count, max_val=20)
255
+ try:
256
+ data = self._api_request(
257
+ "ISteamNews/GetNewsForApp/v2/",
258
+ {"appid": app_id_int, "count": count, "maxlength": 500},
259
+ )
260
+ items = data.get("appnews", {}).get("newsitems", [])
261
+ return [format_news_item(item) for item in items]
262
+ except httpx.HTTPStatusError as e:
263
+ self._handle_error(e, "fetching game news")
264
+
265
+ # -- Profile --
266
+
267
+ def get_player_summary(self) -> dict:
268
+ try:
269
+ data = self._api_request(
270
+ "ISteamUser/GetPlayerSummaries/v2/",
271
+ {"steamids": self._steam_id},
272
+ )
273
+ players = data.get("response", {}).get("players", [])
274
+ if not players:
275
+ raise SteamError("Player profile not found")
276
+ return format_player_summary(players[0])
277
+ except httpx.HTTPStatusError as e:
278
+ self._handle_error(e, "fetching player summary")
279
+
280
+ def get_friend_list(self) -> list[dict]:
281
+ try:
282
+ data = self._api_request(
283
+ "ISteamUser/GetFriendList/v1/",
284
+ {"steamid": self._steam_id, "relationship": "friend"},
285
+ )
286
+ friends = data.get("friendslist", {}).get("friends", [])
287
+ return [format_friend(f) for f in friends]
288
+ except httpx.HTTPStatusError as e:
289
+ self._handle_error(e, "fetching friend list")
290
+
291
+ # -- Featured --
292
+
293
+ def get_featured_games(self) -> dict:
294
+ try:
295
+ data = self._store_request("api/featured")
296
+ featured_win = data.get("featured_win", [])
297
+ featured_mac = data.get("featured_mac", [])
298
+ featured_linux = data.get("featured_linux", [])
299
+
300
+ # Combine and deduplicate by appid
301
+ seen: set[int] = set()
302
+ all_featured: list[dict] = []
303
+ for game in featured_win + featured_mac + featured_linux:
304
+ appid = game.get("id")
305
+ if appid and appid not in seen:
306
+ seen.add(appid)
307
+ all_featured.append(format_featured_game(game))
308
+
309
+ # Separate into on-sale and regular
310
+ on_sale = [g for g in all_featured if g.get("discounted")]
311
+ regular = [g for g in all_featured if not g.get("discounted")]
312
+
313
+ return {
314
+ "on_sale": on_sale,
315
+ "regular": regular,
316
+ "total": len(all_featured),
317
+ }
318
+ except httpx.HTTPStatusError as e:
319
+ self._handle_error(e, "fetching featured games")
@@ -0,0 +1,194 @@
1
+ """Response formatters that produce clean, LLM-friendly dicts.
2
+
3
+ Only includes fields useful for an LLM -- no images, screenshots, or raw HTML.
4
+ """
5
+
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from .validation import format_playtime
10
+
11
+
12
+ def format_owned_game(game: dict) -> dict:
13
+ """Format a game from the owned games list."""
14
+ result: dict[str, Any] = {
15
+ "name": game.get("name", "Unknown"),
16
+ "appid": game.get("appid"),
17
+ "playtime_total": format_playtime(game.get("playtime_forever", 0)),
18
+ }
19
+ playtime_2weeks = game.get("playtime_2weeks")
20
+ if playtime_2weeks:
21
+ result["playtime_2weeks"] = format_playtime(playtime_2weeks)
22
+ last_played = game.get("rtime_last_played")
23
+ if last_played and last_played > 0:
24
+ result["last_played"] = datetime.fromtimestamp(last_played, tz=UTC).isoformat()
25
+ return result
26
+
27
+
28
+ def format_game_details(data: dict) -> dict:
29
+ """Format store app details into an LLM-friendly dict."""
30
+ result: dict[str, Any] = {
31
+ "name": data.get("name", "Unknown"),
32
+ "appid": data.get("steam_appid"),
33
+ "type": data.get("type"),
34
+ "is_free": data.get("is_free", False),
35
+ "description": data.get("short_description", ""),
36
+ }
37
+
38
+ # Price
39
+ price_overview = data.get("price_overview")
40
+ if price_overview:
41
+ result["price"] = price_overview.get("final_formatted", "")
42
+ discount = price_overview.get("discount_percent", 0)
43
+ if discount > 0:
44
+ result["discount_percent"] = discount
45
+ elif data.get("is_free"):
46
+ result["price"] = "Free"
47
+
48
+ # Reviews
49
+ if data.get("metacritic"):
50
+ result["metacritic_score"] = data["metacritic"].get("score")
51
+
52
+ # Genres
53
+ genres = data.get("genres", [])
54
+ if genres:
55
+ result["genres"] = [g.get("description", "") for g in genres]
56
+
57
+ # Release date
58
+ release = data.get("release_date", {})
59
+ if release:
60
+ result["release_date"] = release.get("date", "")
61
+ result["coming_soon"] = release.get("coming_soon", False)
62
+
63
+ # Platforms
64
+ platforms = data.get("platforms", {})
65
+ supported = [p for p, v in platforms.items() if v]
66
+ if supported:
67
+ result["platforms"] = supported
68
+
69
+ return result
70
+
71
+
72
+ def format_achievement(ach: dict, global_percentages: dict | None = None) -> dict:
73
+ """Format a player achievement."""
74
+ result: dict[str, Any] = {
75
+ "name": ach.get("apiname", ach.get("name", "")),
76
+ "achieved": bool(ach.get("achieved", 0)),
77
+ }
78
+ display_name = ach.get("name")
79
+ if display_name and display_name != result["name"]:
80
+ result["display_name"] = display_name
81
+ description = ach.get("description")
82
+ if description:
83
+ result["description"] = description
84
+ unlock_time = ach.get("unlocktime", 0)
85
+ if unlock_time > 0:
86
+ result["unlocked_at"] = datetime.fromtimestamp(unlock_time, tz=UTC).isoformat()
87
+ if global_percentages and result["name"] in global_percentages:
88
+ result["global_percent"] = round(global_percentages[result["name"]], 1)
89
+ return result
90
+
91
+
92
+ def format_player_summary(player: dict) -> dict:
93
+ """Format a Steam player summary."""
94
+ status_map = {
95
+ 0: "offline",
96
+ 1: "online",
97
+ 2: "busy",
98
+ 3: "away",
99
+ 4: "snooze",
100
+ 5: "looking to trade",
101
+ 6: "looking to play",
102
+ }
103
+ result: dict[str, Any] = {
104
+ "name": player.get("personaname", ""),
105
+ "steamid": player.get("steamid", ""),
106
+ "status": status_map.get(player.get("personastate", 0), "unknown"),
107
+ "profile_url": player.get("profileurl", ""),
108
+ }
109
+ last_logoff = player.get("lastlogoff")
110
+ if last_logoff:
111
+ result["last_logoff"] = datetime.fromtimestamp(last_logoff, tz=UTC).isoformat()
112
+ game_name = player.get("gameextrainfo")
113
+ if game_name:
114
+ result["currently_playing"] = game_name
115
+ return result
116
+
117
+
118
+ def format_news_item(item: dict) -> dict:
119
+ """Format a Steam news item."""
120
+ contents = item.get("contents", "")
121
+ # Truncate long content for LLM consumption
122
+ if len(contents) > 500:
123
+ contents = contents[:500] + "..."
124
+ result: dict[str, Any] = {
125
+ "title": item.get("title", ""),
126
+ "author": item.get("author", ""),
127
+ "contents": contents,
128
+ "url": item.get("url", ""),
129
+ }
130
+ date = item.get("date")
131
+ if date:
132
+ result["date"] = datetime.fromtimestamp(date, tz=UTC).isoformat()
133
+ return result
134
+
135
+
136
+ def format_wishlist_item(appid: str, item: dict) -> dict:
137
+ """Format a wishlist item."""
138
+ result: dict[str, Any] = {
139
+ "name": item.get("name", "Unknown"),
140
+ "appid": int(appid),
141
+ "priority": item.get("priority", 0),
142
+ }
143
+ subs = item.get("subs", [])
144
+ if subs:
145
+ for sub in subs:
146
+ if "price" in sub:
147
+ # Price is in cents
148
+ price_cents = sub["price"]
149
+ result["price"] = f"${price_cents / 100:.2f}"
150
+ discount = sub.get("discount_pct", 0)
151
+ if discount > 0:
152
+ result["discount_percent"] = discount
153
+ break
154
+ added = item.get("added")
155
+ if added:
156
+ result["added_at"] = datetime.fromtimestamp(added, tz=UTC).isoformat()
157
+ return result
158
+
159
+
160
+ def format_friend(friend: dict) -> dict:
161
+ """Format a friend entry."""
162
+ result: dict[str, Any] = {
163
+ "steamid": friend.get("steamid", ""),
164
+ "relationship": friend.get("relationship", ""),
165
+ }
166
+ friends_since = friend.get("friend_since", 0)
167
+ if friends_since > 0:
168
+ result["friends_since"] = datetime.fromtimestamp(
169
+ friends_since, tz=UTC
170
+ ).isoformat()
171
+ return result
172
+
173
+
174
+ def format_featured_game(game: dict) -> dict:
175
+ """Format a featured/sale game."""
176
+ result: dict[str, Any] = {
177
+ "name": game.get("name", "Unknown"),
178
+ "appid": game.get("id"),
179
+ "discounted": game.get("discounted", False),
180
+ }
181
+ if game.get("discounted"):
182
+ result["discount_percent"] = game.get("discount_percent", 0)
183
+ final_price = game.get("final_price", 0)
184
+ result["price"] = f"${final_price / 100:.2f}"
185
+ original_price = game.get("original_price", 0)
186
+ if original_price:
187
+ result["original_price"] = f"${original_price / 100:.2f}"
188
+ else:
189
+ final_price = game.get("final_price", 0)
190
+ if final_price > 0:
191
+ result["price"] = f"${final_price / 100:.2f}"
192
+ elif game.get("free"):
193
+ result["price"] = "Free"
194
+ return result
File without changes
@@ -0,0 +1,220 @@
1
+ """MCP server with Steam tools for gaming library, achievements, and store search."""
2
+
3
+ import json
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from .client import SteamClient, SteamError
8
+
9
+ mcp = FastMCP("mcp-steam")
10
+
11
+ _client: SteamClient | None = None
12
+
13
+
14
+ def _get_client() -> SteamClient:
15
+ global _client
16
+ if _client is None:
17
+ _client = SteamClient()
18
+ return _client
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Library
23
+ # ---------------------------------------------------------------------------
24
+
25
+
26
+ @mcp.tool()
27
+ def get_owned_games(sort_by: str = "playtime", limit: int = 50) -> str:
28
+ """Get your Steam game library with playtime stats.
29
+
30
+ sort_by options: "playtime" (default, most played first),
31
+ "recent" (most recently played first), "name" (alphabetical).
32
+
33
+ Returns game names, app IDs, and total/recent playtime.
34
+ """
35
+ try:
36
+ results = _get_client().get_owned_games(sort_by, limit)
37
+ return json.dumps(results, indent=2)
38
+ except (SteamError, ValueError) as e:
39
+ return f"Error: {e}"
40
+
41
+
42
+ @mcp.tool()
43
+ def get_recently_played(limit: int = 10) -> str:
44
+ """Get your recently played Steam games (last 2 weeks).
45
+
46
+ Returns game names, app IDs, and playtime for the period.
47
+ """
48
+ try:
49
+ results = _get_client().get_recently_played(limit)
50
+ return json.dumps(results, indent=2)
51
+ except (SteamError, ValueError) as e:
52
+ return f"Error: {e}"
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Game Info
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @mcp.tool()
61
+ def get_game_details(app_id: str) -> str:
62
+ """Get detailed store information for a Steam game.
63
+
64
+ Returns name, description, price, genres, platforms, metacritic
65
+ score, and release date. Use the app_id from library or search results.
66
+ """
67
+ try:
68
+ result = _get_client().get_game_details(app_id)
69
+ return json.dumps(result, indent=2)
70
+ except (SteamError, ValueError) as e:
71
+ return f"Error: {e}"
72
+
73
+
74
+ @mcp.tool()
75
+ def search_games(query: str, limit: int = 10) -> str:
76
+ """Search the Steam store for games.
77
+
78
+ Returns game names, app IDs, prices, and supported platforms.
79
+ """
80
+ try:
81
+ results = _get_client().search_games(query, limit)
82
+ return json.dumps(results, indent=2)
83
+ except (SteamError, ValueError) as e:
84
+ return f"Error: {e}"
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Achievements & Stats
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ @mcp.tool()
93
+ def get_achievements(app_id: str) -> str:
94
+ """Get your achievement progress for a Steam game.
95
+
96
+ Returns each achievement's unlock status, description, unlock time,
97
+ and how rare it is globally. Includes unlocked/total summary.
98
+ """
99
+ try:
100
+ result = _get_client().get_achievements(app_id)
101
+ return json.dumps(result, indent=2)
102
+ except (SteamError, ValueError) as e:
103
+ return f"Error: {e}"
104
+
105
+
106
+ @mcp.tool()
107
+ def get_player_stats(app_id: str) -> str:
108
+ """Get your gameplay statistics for a Steam game.
109
+
110
+ Returns game-specific stats like kills, deaths, time played, etc.
111
+ Not all games provide stats.
112
+ """
113
+ try:
114
+ result = _get_client().get_player_stats(app_id)
115
+ return json.dumps(result, indent=2)
116
+ except (SteamError, ValueError) as e:
117
+ return f"Error: {e}"
118
+
119
+
120
+ @mcp.tool()
121
+ def get_global_achievement_stats(app_id: str) -> str:
122
+ """Get global achievement unlock percentages for a Steam game.
123
+
124
+ Shows how rare each achievement is across all players. Useful for
125
+ identifying the hardest or rarest achievements.
126
+ """
127
+ try:
128
+ results = _get_client().get_global_achievement_stats(app_id)
129
+ return json.dumps(results, indent=2)
130
+ except (SteamError, ValueError) as e:
131
+ return f"Error: {e}"
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Wishlist
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ @mcp.tool()
140
+ def get_wishlist(limit: int = 50) -> str:
141
+ """Get your Steam wishlist.
142
+
143
+ Returns wishlisted games sorted by priority, with prices and
144
+ current discounts if available.
145
+ """
146
+ try:
147
+ results = _get_client().get_wishlist(limit)
148
+ return json.dumps(results, indent=2)
149
+ except (SteamError, ValueError) as e:
150
+ return f"Error: {e}"
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # News
155
+ # ---------------------------------------------------------------------------
156
+
157
+
158
+ @mcp.tool()
159
+ def get_game_news(app_id: str, count: int = 5) -> str:
160
+ """Get recent news and updates for a Steam game.
161
+
162
+ Returns news titles, authors, dates, summaries, and URLs.
163
+ """
164
+ try:
165
+ results = _get_client().get_game_news(app_id, count)
166
+ return json.dumps(results, indent=2)
167
+ except (SteamError, ValueError) as e:
168
+ return f"Error: {e}"
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Profile
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ @mcp.tool()
177
+ def get_player_summary() -> str:
178
+ """Get your Steam profile summary.
179
+
180
+ Returns display name, online status, profile URL, and currently
181
+ playing game (if any).
182
+ """
183
+ try:
184
+ result = _get_client().get_player_summary()
185
+ return json.dumps(result, indent=2)
186
+ except (SteamError, ValueError) as e:
187
+ return f"Error: {e}"
188
+
189
+
190
+ @mcp.tool()
191
+ def get_friend_list() -> str:
192
+ """Get your Steam friends list.
193
+
194
+ Returns friend Steam IDs, relationship status, and when you
195
+ became friends.
196
+ """
197
+ try:
198
+ results = _get_client().get_friend_list()
199
+ return json.dumps(results, indent=2)
200
+ except (SteamError, ValueError) as e:
201
+ return f"Error: {e}"
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Featured
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ @mcp.tool()
210
+ def get_featured_games() -> str:
211
+ """Get currently featured and on-sale games on Steam.
212
+
213
+ Returns featured games split into on-sale (with discounts and prices)
214
+ and regular featured titles.
215
+ """
216
+ try:
217
+ result = _get_client().get_featured_games()
218
+ return json.dumps(result, indent=2)
219
+ except (SteamError, ValueError) as e:
220
+ return f"Error: {e}"
@@ -0,0 +1,32 @@
1
+ """Input validation helpers for Steam app IDs and pagination parameters."""
2
+
3
+
4
+ def validate_app_id(value: str | int) -> int:
5
+ """Validate and convert a Steam app ID to int."""
6
+ if isinstance(value, int):
7
+ if value <= 0:
8
+ raise ValueError(f"Invalid app ID: {value}. Must be positive.")
9
+ return value
10
+ value_str = str(value).strip()
11
+ if not value_str.isdigit():
12
+ raise ValueError(f"Invalid app ID: '{value_str}'. Expected a numeric value.")
13
+ result = int(value_str)
14
+ if result <= 0:
15
+ raise ValueError(f"Invalid app ID: {result}. Must be positive.")
16
+ return result
17
+
18
+
19
+ def validate_limit(value: int, max_val: int = 50) -> int:
20
+ """Clamp a limit parameter to the range [1, max_val]."""
21
+ return max(1, min(value, max_val))
22
+
23
+
24
+ def format_playtime(minutes: int) -> str:
25
+ """Convert playtime in minutes to a human-readable string."""
26
+ if minutes < 60:
27
+ return f"{minutes}m"
28
+ hours = minutes // 60
29
+ remaining = minutes % 60
30
+ if remaining == 0:
31
+ return f"{hours}h"
32
+ return f"{hours}h {remaining}m"