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.
- mcp_steam-0.1.2/PKG-INFO +179 -0
- mcp_steam-0.1.2/README.md +159 -0
- mcp_steam-0.1.2/pyproject.toml +55 -0
- mcp_steam-0.1.2/src/steam_mcp/__init__.py +10 -0
- mcp_steam-0.1.2/src/steam_mcp/auth.py +87 -0
- mcp_steam-0.1.2/src/steam_mcp/client.py +319 -0
- mcp_steam-0.1.2/src/steam_mcp/formatting.py +194 -0
- mcp_steam-0.1.2/src/steam_mcp/py.typed +0 -0
- mcp_steam-0.1.2/src/steam_mcp/server.py +220 -0
- mcp_steam-0.1.2/src/steam_mcp/validation.py +32 -0
mcp_steam-0.1.2/PKG-INFO
ADDED
|
@@ -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,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"
|