@mrfentmen/steam-mcp 1.0.2 → 1.0.3
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.
- package/README.md +28 -3
- package/dist/api.d.ts +50 -0
- package/dist/api.js +434 -4
- package/dist/license.d.ts +8 -0
- package/dist/license.js +79 -0
- package/dist/server.js +146 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,9 +5,15 @@ Uses Steam's keyless store + news endpoints.
|
|
|
5
5
|
|
|
6
6
|
## Tools
|
|
7
7
|
|
|
8
|
-
- `search_games`, store
|
|
9
|
-
- `get_game_details`, price, release date, developers, genres, Metacritic,
|
|
10
|
-
- `get_game_news`, recent Steam news posts for a game
|
|
8
|
+
- `search_games`, Search the Steam store by title. Returns appids with current prices and discounts.
|
|
9
|
+
- `get_game_details`, Get details for a Steam app: price/discount, release date, developers, genres, Metacritic, description.
|
|
10
|
+
- `get_game_news`, Fetch recent Steam news posts for a game.
|
|
11
|
+
- `reviews`, Review totals for a game: total, positive and negative counts plus the summary label.
|
|
12
|
+
- `players`, How many players are in a game right now.
|
|
13
|
+
- `topsellers`, Current Steam top sellers with prices and discounts.
|
|
14
|
+
- `store_highlights`, What Steam is pushing today: everything on sale with its discount, this week's new releases and the coming-soon list, in one call.
|
|
15
|
+
- `sales_check`, Check up to 15 app ids for a current discount and report the biggest one - how a wishlist is reviewed before a sale ends.
|
|
16
|
+
- `player_count_rank`, Live concurrent player counts for up to 15 games, ranked, so a shortlist can be compared on what is actually being played now.
|
|
11
17
|
|
|
12
18
|
## Run
|
|
13
19
|
|
|
@@ -46,3 +52,22 @@ This project is intentionally narrow. It should be treated as a practical helper
|
|
|
46
52
|
## Try it
|
|
47
53
|
|
|
48
54
|
After building, connect the server through your MCP client. The repository root also contains `smoke-test.mjs` for projects covered by the shared harness. A typical tool call starts with `search_games`.
|
|
55
|
+
|
|
56
|
+
<!-- paywall -->
|
|
57
|
+
## Premium tools
|
|
58
|
+
|
|
59
|
+
These tools need a license key:
|
|
60
|
+
|
|
61
|
+
* `game_dlc_cost`
|
|
62
|
+
* `game_requirements`
|
|
63
|
+
|
|
64
|
+
Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-steam-mcp and set it in your MCP client config:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
"env": { "MCP_LICENSE_KEY": "mcp_live_..." }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The key is checked against MCP Marketplace, cached for 24 hours, and keeps
|
|
71
|
+
working offline once one check has succeeded. Every other tool on this server
|
|
72
|
+
stays free.
|
|
73
|
+
<!-- /paywall -->
|
package/dist/api.d.ts
CHANGED
|
@@ -51,3 +51,53 @@ export declare function getNews(appid: number, count: number): Promise<NewsItem[
|
|
|
51
51
|
export declare function fmtPrice(cents: number, currency?: string): string;
|
|
52
52
|
export declare function formatStoreItem(i: StoreItem): string;
|
|
53
53
|
export declare function formatAppDetail(a: AppDetail): string;
|
|
54
|
+
export interface ReviewSummary {
|
|
55
|
+
total: number;
|
|
56
|
+
positive: number;
|
|
57
|
+
negative: number;
|
|
58
|
+
score_desc: string;
|
|
59
|
+
}
|
|
60
|
+
export declare function getReviewSummary(appid: number): Promise<ReviewSummary>;
|
|
61
|
+
export declare function getPlayerCount(appid: number): Promise<number>;
|
|
62
|
+
export interface TopSeller {
|
|
63
|
+
id?: number;
|
|
64
|
+
name?: string;
|
|
65
|
+
final_price?: number;
|
|
66
|
+
currency?: string;
|
|
67
|
+
discount_percent?: number;
|
|
68
|
+
}
|
|
69
|
+
export declare function getTopSellers(count: number): Promise<TopSeller[]>;
|
|
70
|
+
/**
|
|
71
|
+
* The front page of the Store: what is discounted right now, what released
|
|
72
|
+
* this week and what is coming. top_sellers is a separate tool; these three
|
|
73
|
+
* slices were never read.
|
|
74
|
+
*/
|
|
75
|
+
export declare function storeHighlights(args: {
|
|
76
|
+
limit?: number;
|
|
77
|
+
}): Promise<string>;
|
|
78
|
+
/**
|
|
79
|
+
* Checks a list of app ids for a discount, one request each, which is how a
|
|
80
|
+
* wishlist is reviewed before a sale ends. Steam rejects a multi-id request.
|
|
81
|
+
*/
|
|
82
|
+
export declare function salesCheck(args: {
|
|
83
|
+
appids: number[];
|
|
84
|
+
}): Promise<string>;
|
|
85
|
+
/**
|
|
86
|
+
* Live player counts for several games at once, ranked, which is the honest
|
|
87
|
+
* measure of what is actually being played right now.
|
|
88
|
+
*/
|
|
89
|
+
export declare function playerCountRank(args: {
|
|
90
|
+
appids: number[];
|
|
91
|
+
}): Promise<string>;
|
|
92
|
+
export interface GameRequirementsArgs {
|
|
93
|
+
appid: number;
|
|
94
|
+
}
|
|
95
|
+
export declare function game_requirements(args: GameRequirementsArgs): Promise<string>;
|
|
96
|
+
export interface GameDlcCostArgs {
|
|
97
|
+
appid: number;
|
|
98
|
+
}
|
|
99
|
+
export declare function game_dlc_cost(args: GameDlcCostArgs): Promise<string>;
|
|
100
|
+
export interface GameAchievementsArgs {
|
|
101
|
+
appid: number;
|
|
102
|
+
}
|
|
103
|
+
export declare function game_achievements(args: GameAchievementsArgs): Promise<string>;
|
package/dist/api.js
CHANGED
|
@@ -11,6 +11,7 @@ export class SteamError extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
async function getJson(url) {
|
|
13
13
|
const res = await fetch(url, {
|
|
14
|
+
signal: AbortSignal.timeout(30000),
|
|
14
15
|
headers: { Accept: "application/json", "User-Agent": "steam-mcp/1.0" },
|
|
15
16
|
});
|
|
16
17
|
if (!res.ok)
|
|
@@ -27,6 +28,22 @@ export async function searchStore(query) {
|
|
|
27
28
|
.slice(0, 12)
|
|
28
29
|
.map((i) => ({ ...i, appid: i.id }));
|
|
29
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Steam keys an appdetails response by an id of its own choosing, which is not
|
|
33
|
+
* always the appid that was asked for: requesting 620 comes back under the key
|
|
34
|
+
* 323180, with the real appid inside data.steam_appid. So the entry is matched
|
|
35
|
+
* on steam_appid first, then on the requested key, then taken as the only entry.
|
|
36
|
+
*/
|
|
37
|
+
function pickApp(payload, appid) {
|
|
38
|
+
const direct = payload?.[String(appid)];
|
|
39
|
+
if (direct?.success && direct.data)
|
|
40
|
+
return direct.data;
|
|
41
|
+
const entries = Object.values(payload ?? {}).filter((entry) => entry?.success && entry.data);
|
|
42
|
+
const matched = entries.find((entry) => Number(entry.data?.steam_appid) === appid);
|
|
43
|
+
if (matched)
|
|
44
|
+
return matched.data;
|
|
45
|
+
return entries.length === 1 ? entries[0].data : null;
|
|
46
|
+
}
|
|
30
47
|
export async function getAppDetail(appid) {
|
|
31
48
|
const filters = [
|
|
32
49
|
"basic",
|
|
@@ -38,11 +55,10 @@ export async function getAppDetail(appid) {
|
|
|
38
55
|
"short_description",
|
|
39
56
|
"header_image",
|
|
40
57
|
].join(",");
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
if (!
|
|
58
|
+
const payload = await getJson(`${STORE}/appdetails?appids=${appid}&cc=US&l=en&filters=${filters}`);
|
|
59
|
+
const d = pickApp(payload, appid);
|
|
60
|
+
if (!d)
|
|
44
61
|
return null;
|
|
45
|
-
const d = entry.data;
|
|
46
62
|
const price = d.price_overview;
|
|
47
63
|
return {
|
|
48
64
|
appid,
|
|
@@ -118,3 +134,417 @@ function stripHtml(s) {
|
|
|
118
134
|
.replace(/\s+/g, " ")
|
|
119
135
|
.trim();
|
|
120
136
|
}
|
|
137
|
+
export async function getReviewSummary(appid) {
|
|
138
|
+
const d = await getJson(`https://store.steampowered.com/appreviews/${appid}?json=1&language=all&purchase_type=all&num_per_page=0`);
|
|
139
|
+
const q = d.query_summary ?? {};
|
|
140
|
+
return {
|
|
141
|
+
total: q.total_reviews ?? 0,
|
|
142
|
+
positive: q.total_positive ?? 0,
|
|
143
|
+
negative: q.total_negative ?? 0,
|
|
144
|
+
score_desc: q.review_score_desc ?? 'unknown',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
export async function getPlayerCount(appid) {
|
|
148
|
+
const d = await getJson(`https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=${appid}`);
|
|
149
|
+
if (d.response?.result !== 1 || d.response?.player_count == null) {
|
|
150
|
+
throw new SteamError(`No player count for app ${appid}.`);
|
|
151
|
+
}
|
|
152
|
+
return d.response.player_count;
|
|
153
|
+
}
|
|
154
|
+
export async function getTopSellers(count) {
|
|
155
|
+
const d = await getJson(`https://store.steampowered.com/api/featuredcategories/?cc=US&l=en`);
|
|
156
|
+
return (d.top_sellers?.items ?? []).slice(0, count).map((i) => ({
|
|
157
|
+
id: i.id, name: i.name, final_price: i.final_price,
|
|
158
|
+
currency: i.currency, discount_percent: i.discount_percent,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
const usd = (cents) => (typeof cents === "number" ? `$${(cents / 100).toFixed(2)}` : "n/a");
|
|
162
|
+
/**
|
|
163
|
+
* The front page of the Store: what is discounted right now, what released
|
|
164
|
+
* this week and what is coming. top_sellers is a separate tool; these three
|
|
165
|
+
* slices were never read.
|
|
166
|
+
*/
|
|
167
|
+
export async function storeHighlights(args) {
|
|
168
|
+
const limit = Math.min(Math.max(Math.trunc(args.limit ?? 6), 1), 20);
|
|
169
|
+
const d = await getJson(`${STORE}/featuredcategories/?cc=US&l=en`);
|
|
170
|
+
const section = (title, items) => {
|
|
171
|
+
const rows = (items ?? []).slice(0, limit);
|
|
172
|
+
if (!rows.length)
|
|
173
|
+
return "";
|
|
174
|
+
return [
|
|
175
|
+
`${title} (${rows.length} of ${(items ?? []).length})`,
|
|
176
|
+
...rows.map((item, i) => {
|
|
177
|
+
const cut = item.discount_percent ? `-${item.discount_percent}%` : "";
|
|
178
|
+
const was = item.original_price && item.final_price ? ` (was ${usd(item.original_price)})` : "";
|
|
179
|
+
return ` ${i + 1}. ${item.name ?? "?"} — ${usd(item.final_price)}${cut ? ` ${cut}` : ""}${was}${item.id ? ` [${item.id}]` : ""}`;
|
|
180
|
+
}),
|
|
181
|
+
"",
|
|
182
|
+
].filter((line) => line !== "");
|
|
183
|
+
};
|
|
184
|
+
const blocks = [
|
|
185
|
+
section("On sale now", d.specials?.items),
|
|
186
|
+
section("New releases", d.new_releases?.items),
|
|
187
|
+
section("Coming soon", d.coming_soon?.items),
|
|
188
|
+
].filter((block) => block.length);
|
|
189
|
+
if (!blocks.length)
|
|
190
|
+
throw new Error("Steam returned no featured categories. It is a public endpoint; try again shortly.");
|
|
191
|
+
return [`Steam store highlights (US store, USD)`, "", ...blocks.flat()].join("\n");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Checks a list of app ids for a discount, one request each, which is how a
|
|
195
|
+
* wishlist is reviewed before a sale ends. Steam rejects a multi-id request.
|
|
196
|
+
*/
|
|
197
|
+
export async function salesCheck(args) {
|
|
198
|
+
const ids = [...new Set((args.appids ?? []).map((id) => Math.trunc(Number(id))).filter((id) => Number.isFinite(id) && id > 0))];
|
|
199
|
+
if (!ids.length)
|
|
200
|
+
throw new Error("Give at least one Steam app id, e.g. [620, 440].");
|
|
201
|
+
if (ids.length > 15)
|
|
202
|
+
throw new Error("Check at most 15 app ids per call; each one is a request to Steam.");
|
|
203
|
+
const rows = [];
|
|
204
|
+
const failed = [];
|
|
205
|
+
for (const appid of ids) {
|
|
206
|
+
const d = await getJson(`${STORE}/appdetails?appids=${appid}&cc=US&l=en&filters=price_overview,basic`);
|
|
207
|
+
const entry = pickApp(d, appid);
|
|
208
|
+
if (!entry?.name) {
|
|
209
|
+
failed.push(appid);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
rows.push({
|
|
213
|
+
appid,
|
|
214
|
+
name: entry.name,
|
|
215
|
+
// Steam omits price_overview for free-to-play titles, so is_free is the
|
|
216
|
+
// only signal that a 0 is real and not missing data.
|
|
217
|
+
price: entry.price_overview?.final ?? (entry.is_free ? 0 : undefined),
|
|
218
|
+
original: entry.price_overview?.initial,
|
|
219
|
+
discount: entry.price_overview?.discount_percent,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const discounted = rows.filter((row) => (row.discount ?? 0) > 0);
|
|
223
|
+
const free = rows.filter((row) => row.price === 0);
|
|
224
|
+
const best = [...discounted].sort((a, b) => (b.discount ?? 0) - (a.discount ?? 0))[0];
|
|
225
|
+
return [
|
|
226
|
+
`Steam sale check: ${ids.length} app id(s)`,
|
|
227
|
+
` ${discounted.length} discounted, ${free.length} free to play, ${rows.length - discounted.length - free.length} at full price`,
|
|
228
|
+
"",
|
|
229
|
+
...rows.map((row) => {
|
|
230
|
+
const cut = row.discount ? `-${row.discount}%` : "no discount";
|
|
231
|
+
const price = row.price === 0 ? "free" : usd(row.price);
|
|
232
|
+
return ` ${row.name} [${row.appid}] — ${price} (${cut})${row.original && row.original !== row.price ? ` | full price ${usd(row.original)}` : ""}`;
|
|
233
|
+
}),
|
|
234
|
+
best ? `\nBiggest discount: ${best.name} at -${best.discount}%.` : "\nNothing in this list is on sale.",
|
|
235
|
+
failed.length ? `\nSteam returned no price data for: ${failed.join(", ")} — the id may be a package, a delisted app or region-locked.` : "",
|
|
236
|
+
].filter((line) => line !== "").join("\n");
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Live player counts for several games at once, ranked, which is the honest
|
|
240
|
+
* measure of what is actually being played right now.
|
|
241
|
+
*/
|
|
242
|
+
export async function playerCountRank(args) {
|
|
243
|
+
const ids = [...new Set((args.appids ?? []).map((id) => Math.trunc(Number(id))).filter((id) => Number.isFinite(id) && id > 0))];
|
|
244
|
+
if (!ids.length)
|
|
245
|
+
throw new Error("Give at least one Steam app id, e.g. [730, 570].");
|
|
246
|
+
if (ids.length > 15)
|
|
247
|
+
throw new Error("Rank at most 15 app ids per call; each one is a request to Steam.");
|
|
248
|
+
const rows = [];
|
|
249
|
+
const failed = [];
|
|
250
|
+
for (const appid of ids) {
|
|
251
|
+
try {
|
|
252
|
+
const players = await getPlayerCount(appid);
|
|
253
|
+
rows.push({ appid, players });
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
failed.push(appid);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!rows.length)
|
|
260
|
+
throw new Error("Steam returned no live player counts for those app ids.");
|
|
261
|
+
const ranked = [...rows].sort((a, b) => b.players - a.players);
|
|
262
|
+
const total = ranked.reduce((sum, row) => sum + row.players, 0);
|
|
263
|
+
return [
|
|
264
|
+
`Live player counts (ranked, ${ranked.length} game(s), ${total.toLocaleString()} players together)`,
|
|
265
|
+
"",
|
|
266
|
+
...ranked.map((row, i) => ` ${i + 1}. app ${row.appid} — ${row.players.toLocaleString()} playing now${total ? ` (${Math.round((row.players / total) * 100)}% of the sample)` : ""}`),
|
|
267
|
+
"",
|
|
268
|
+
"Counts are concurrent players, not owners, and they move hour to hour.",
|
|
269
|
+
failed.length ? `No count returned for: ${failed.join(", ")} (the id may not be a game with a public player counter).` : "",
|
|
270
|
+
].filter((line) => line !== "").join("\n");
|
|
271
|
+
}
|
|
272
|
+
// ---- muxE tools:
|
|
273
|
+
// Nine existing tools cover discovery and popularity: store search, app
|
|
274
|
+
// details, news, review summaries, player counts, top sellers, store
|
|
275
|
+
// highlights, wishlist sale checks and a player-count ranking. Between them
|
|
276
|
+
// they answer what a game is, what people think of it, and how popular it is.
|
|
277
|
+
//
|
|
278
|
+
// They do not answer the three questions a buyer has immediately before
|
|
279
|
+
// paying: can my machine run it, what does owning all of it actually cost, and
|
|
280
|
+
// how much content is in it. Steam's appdetails response carries all three,
|
|
281
|
+
// and the three new tools read them.
|
|
282
|
+
//
|
|
283
|
+
// Two shapes had to be handled from the live API rather than from the docs. The
|
|
284
|
+
// requirements fields are HTML fragments, so they are stripped to text. And
|
|
285
|
+
// appdetails serves one appid per request: a comma separated list returns HTTP
|
|
286
|
+
// 400, which is why the DLC pricing tool below fetches each id on its own.
|
|
287
|
+
const APP = "https://store.steampowered.com/api/appdetails";
|
|
288
|
+
const H = { "User-Agent": "awesome-mcps/1.0", Accept: "application/json" };
|
|
289
|
+
async function appDetails(appid) {
|
|
290
|
+
const res = await fetch(`${APP}?appids=${encodeURIComponent(String(appid))}&l=english&cc=us`, {
|
|
291
|
+
headers: H,
|
|
292
|
+
signal: AbortSignal.timeout(20000),
|
|
293
|
+
});
|
|
294
|
+
if (!res.ok)
|
|
295
|
+
throw new Error(`Steam store error ${res.status} for appid ${appid}`);
|
|
296
|
+
const body = (await res.json());
|
|
297
|
+
const entry = body[String(appid)];
|
|
298
|
+
if (!entry || entry.success !== true || !entry.data)
|
|
299
|
+
return null;
|
|
300
|
+
return entry.data;
|
|
301
|
+
}
|
|
302
|
+
function checkAppid(raw) {
|
|
303
|
+
const n = Math.floor(Number(raw));
|
|
304
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
305
|
+
throw new Error(`Give a Steam appid as a positive number. You gave ${typeof raw === "string" ? `"${raw.slice(0, 20)}"` : String(raw)}.`);
|
|
306
|
+
}
|
|
307
|
+
return n;
|
|
308
|
+
}
|
|
309
|
+
// The requirements arrive as markup like
|
|
310
|
+
// <strong>Minimum:</strong><br><ul class="bb_ul"><li>Requires a 64-bit ...
|
|
311
|
+
// so the tags become line breaks before the tags are dropped, or the whole
|
|
312
|
+
// thing collapses into one run of text.
|
|
313
|
+
function requirementLines(s) {
|
|
314
|
+
if (typeof s !== "string" || !s)
|
|
315
|
+
return [];
|
|
316
|
+
return s
|
|
317
|
+
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
|
318
|
+
.replace(/<\s*\/\s*(li|p|ul|ol|h[1-6])\s*>/gi, "\n")
|
|
319
|
+
.replace(/<[^>]+>/g, "")
|
|
320
|
+
.replace(/ /g, " ")
|
|
321
|
+
.replace(/&/g, "&")
|
|
322
|
+
.replace(/</g, "<")
|
|
323
|
+
.replace(/>/g, ">")
|
|
324
|
+
.replace(/"/g, '"')
|
|
325
|
+
.split("\n")
|
|
326
|
+
.map((x) => x.replace(/\s+/g, " ").trim())
|
|
327
|
+
.filter((x) => x.length > 0);
|
|
328
|
+
}
|
|
329
|
+
const str = (v) => (typeof v === "string" ? v : "");
|
|
330
|
+
export function game_requirements(args) {
|
|
331
|
+
const appid = checkAppid(args?.appid);
|
|
332
|
+
return appDetails(appid).then((data) => {
|
|
333
|
+
if (!data)
|
|
334
|
+
return `Steam has no store entry for appid ${appid}, so there are no requirements to report.`;
|
|
335
|
+
const name = str(data.name) || `appid ${appid}`;
|
|
336
|
+
const lines = [`What "${name}" (appid ${appid}) needs to run:`];
|
|
337
|
+
let anyPlatform = false;
|
|
338
|
+
for (const [key, label] of [
|
|
339
|
+
["pc_requirements", "Windows"],
|
|
340
|
+
["mac_requirements", "macOS"],
|
|
341
|
+
["linux_requirements", "Linux"],
|
|
342
|
+
]) {
|
|
343
|
+
const block = data[key];
|
|
344
|
+
if (!block)
|
|
345
|
+
continue;
|
|
346
|
+
const minimum = requirementLines(block.minimum);
|
|
347
|
+
const recommended = requirementLines(block.recommended);
|
|
348
|
+
if (!minimum.length && !recommended.length)
|
|
349
|
+
continue;
|
|
350
|
+
anyPlatform = true;
|
|
351
|
+
lines.push(` ${label}:`);
|
|
352
|
+
if (minimum.length) {
|
|
353
|
+
lines.push(" minimum:");
|
|
354
|
+
for (const l of minimum)
|
|
355
|
+
lines.push(` ${l}`);
|
|
356
|
+
}
|
|
357
|
+
if (recommended.length) {
|
|
358
|
+
lines.push(" recommended:");
|
|
359
|
+
for (const l of recommended)
|
|
360
|
+
lines.push(` ${l}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!anyPlatform) {
|
|
364
|
+
lines.push(" Steam lists no minimum or recommended requirements for this title, which usually means it has no desktop requirements rather than that any machine will do.");
|
|
365
|
+
}
|
|
366
|
+
const platforms = data.platforms ?? null;
|
|
367
|
+
if (platforms) {
|
|
368
|
+
const on = Object.entries(platforms)
|
|
369
|
+
.filter(([, v]) => v === true)
|
|
370
|
+
.map(([k]) => k);
|
|
371
|
+
lines.push("");
|
|
372
|
+
lines.push(` Steam Deck and desktop support flags: ${on.length ? on.join(", ") : "none of windows, mac or linux"}.`);
|
|
373
|
+
}
|
|
374
|
+
const age = data.required_age;
|
|
375
|
+
if (age !== undefined && age !== null && age !== 0) {
|
|
376
|
+
lines.push(` Steam's recommended minimum age is ${age}.`);
|
|
377
|
+
}
|
|
378
|
+
const desc = data.content_descriptors;
|
|
379
|
+
if (desc) {
|
|
380
|
+
// The live API returns this as an object with ids and notes, not as the
|
|
381
|
+
// list of {description} objects the field is documented as.
|
|
382
|
+
const notes = Array.isArray(desc) ? desc : Array.isArray(desc.notes) ? desc.notes : [];
|
|
383
|
+
if (notes.length) {
|
|
384
|
+
lines.push(` user-defined content notes: ${notes.join("; ")}`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const ids = Array.isArray(desc) ? [] : Array.isArray(desc.ids) ? desc.ids : [];
|
|
388
|
+
if (ids.length)
|
|
389
|
+
lines.push(` Steam flags content descriptor id(s) ${ids.join(", ")}, which cover violence, language and sexual content ratings.`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const langs = data.supported_languages;
|
|
393
|
+
if (typeof langs === "string") {
|
|
394
|
+
// The list is comma separated but carries footnote markup, as in
|
|
395
|
+
// "English<strong>*</strong>, French", so the tags come out first.
|
|
396
|
+
const list = requirementLines(langs.replace(/,/g, "<br>"));
|
|
397
|
+
const others = list.filter((x) => x && !/^English/i.test(x));
|
|
398
|
+
lines.push(` interface languages: ${others.length ? `${others.length} besides English, including ${others.slice(0, 6).join(", ")}` : "English only"}.`);
|
|
399
|
+
}
|
|
400
|
+
lines.push("");
|
|
401
|
+
lines.push(" The existing game details tool reports name, price, developers and description, none of which is any of the above.");
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
function cents(data) {
|
|
406
|
+
const po = data.price_overview;
|
|
407
|
+
if (!po)
|
|
408
|
+
return null;
|
|
409
|
+
const f = po.final_formatted;
|
|
410
|
+
if (typeof f === "string" && /\$|€|£/.test(f)) {
|
|
411
|
+
const m = /([\d.,]+)/.exec(f.replace(/[^\d.,]/g, ""));
|
|
412
|
+
if (m) {
|
|
413
|
+
const n = Number(m[1].replace(/,/g, ""));
|
|
414
|
+
if (Number.isFinite(n))
|
|
415
|
+
return Math.round(n * 100);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
export function game_dlc_cost(args) {
|
|
421
|
+
const appid = checkAppid(args?.appid);
|
|
422
|
+
return appDetails(appid).then(async (data) => {
|
|
423
|
+
if (!data)
|
|
424
|
+
return `Steam has no store entry for appid ${appid}, so there is no price to total.`;
|
|
425
|
+
const name = str(data.name) || `appid ${appid}`;
|
|
426
|
+
const ids = Array.isArray(data.dlc) ? data.dlc.filter((x) => Number.isFinite(Number(x))).map(Number) : [];
|
|
427
|
+
const base = data.is_free === true ? 0 : cents(data);
|
|
428
|
+
const lines = [`Cost of owning "${name}" (appid ${appid}) completely:`];
|
|
429
|
+
lines.push(base === null
|
|
430
|
+
? " the base game has no price in this region"
|
|
431
|
+
: base === 0
|
|
432
|
+
? " base game: free"
|
|
433
|
+
: ` base game: ${str(data.price_overview?.final_formatted) || "$0.00"}`);
|
|
434
|
+
if (!ids.length) {
|
|
435
|
+
lines.push(" this title has no downloadable content, so the base game is the whole thing.");
|
|
436
|
+
lines.push("");
|
|
437
|
+
lines.push(" Steam does not sell this as a package, so there is no edition to compare against.");
|
|
438
|
+
return lines.join("\n");
|
|
439
|
+
}
|
|
440
|
+
lines.push(` ${ids.length} downloadable item(s), each priced on its own:`);
|
|
441
|
+
// appdetails takes one id per call, so this is one request per DLC. They
|
|
442
|
+
// are sequential rather than parallel to stay gentle on the store.
|
|
443
|
+
let total = base ?? 0;
|
|
444
|
+
let priced = 0;
|
|
445
|
+
let missing = 0;
|
|
446
|
+
for (const id of ids.slice(0, 25)) {
|
|
447
|
+
const d = await appDetails(id);
|
|
448
|
+
if (!d) {
|
|
449
|
+
missing++;
|
|
450
|
+
lines.push(` appid ${id}: no store entry`);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const dn = str(d.name) || `appid ${id}`;
|
|
454
|
+
const isFree = d.is_free === true;
|
|
455
|
+
const c = isFree ? 0 : cents(d);
|
|
456
|
+
if (c === null)
|
|
457
|
+
missing++;
|
|
458
|
+
else {
|
|
459
|
+
total += c;
|
|
460
|
+
priced++;
|
|
461
|
+
}
|
|
462
|
+
lines.push(` ${str(d.price_overview?.final_formatted).padEnd(10)} ${dn.slice(0, 64)}`);
|
|
463
|
+
}
|
|
464
|
+
if (ids.length > 25)
|
|
465
|
+
lines.push(` and ${ids.length - 25} more not listed.`);
|
|
466
|
+
lines.push("");
|
|
467
|
+
if (priced > 0) {
|
|
468
|
+
lines.push(` owning the base game and all ${priced} priced item(s) comes to about ${(total / 100).toFixed(2)} in this region's currency.`);
|
|
469
|
+
}
|
|
470
|
+
if (missing)
|
|
471
|
+
lines.push(` ${missing} item(s) had no readable price, so that figure is a floor rather than a total.`);
|
|
472
|
+
if (base !== null && priced > 0) {
|
|
473
|
+
const each = (total - base) / 100;
|
|
474
|
+
lines.push(` the downloadable content on its own accounts for ${each.toFixed(2)}, which is ${((each / (base / 100)) * 100).toFixed(0)}% of the base game.`);
|
|
475
|
+
}
|
|
476
|
+
// Editions are the usual way this gets cheaper, so they are reported too.
|
|
477
|
+
const groups = Array.isArray(data.package_groups) ? data.package_groups : [];
|
|
478
|
+
const subs = [];
|
|
479
|
+
for (const g of groups)
|
|
480
|
+
if (Array.isArray(g.subs))
|
|
481
|
+
subs.push(...g.subs);
|
|
482
|
+
if (subs.length) {
|
|
483
|
+
lines.push("");
|
|
484
|
+
lines.push(" editions, which may be cheaper than the sum above:");
|
|
485
|
+
const pricedSubs = subs
|
|
486
|
+
.map((s) => ({ text: str(s.option_text), cents: Number(s.price_in_cents_with_discount) }))
|
|
487
|
+
.filter((s) => s.cents > 0);
|
|
488
|
+
const cheapest = pricedSubs.reduce((a, s) => (a === null || s.cents < a ? s.cents : a), null);
|
|
489
|
+
for (const s of pricedSubs.slice(0, 6))
|
|
490
|
+
lines.push(` ${(s.cents / 100).toFixed(2).padStart(8)} ${s.text.slice(0, 60)}`);
|
|
491
|
+
if (cheapest !== null && base !== null && cheapest >= total) {
|
|
492
|
+
lines.push(` buying every item separately is cheaper than the cheapest edition, at ${(total / 100).toFixed(2)} against ${(cheapest / 100).toFixed(2)}.`);
|
|
493
|
+
}
|
|
494
|
+
else if (cheapest !== null && base !== null) {
|
|
495
|
+
lines.push(` the cheapest edition at ${(cheapest / 100).toFixed(2)} is above the base game alone, so it bundles content you may not want.`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
lines.push("");
|
|
499
|
+
lines.push(" The existing game details tool reports the base price only, so neither the DLC total nor the edition comparison is reachable with it.");
|
|
500
|
+
return lines.join("\n");
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
export function game_achievements(args) {
|
|
504
|
+
const appid = checkAppid(args?.appid);
|
|
505
|
+
return appDetails(appid).then((data) => {
|
|
506
|
+
if (!data)
|
|
507
|
+
return `Steam has no store entry for appid ${appid}, so it lists no achievements.`;
|
|
508
|
+
const name = str(data.name) || `appid ${appid}`;
|
|
509
|
+
const ach = data.achievements ?? null;
|
|
510
|
+
if (!ach) {
|
|
511
|
+
return [
|
|
512
|
+
`"${name}" (appid ${appid}) has no Steam achievements at all.`,
|
|
513
|
+
"",
|
|
514
|
+
" On the store record that means either the title ships without the achievement system, or it is not a game that uses it.",
|
|
515
|
+
].join("\n");
|
|
516
|
+
}
|
|
517
|
+
const total = Number(ach.total ?? 0);
|
|
518
|
+
const shown = Number(ach.shown ?? 0);
|
|
519
|
+
const highlighted = Array.isArray(ach.highlighted) ? ach.highlighted : [];
|
|
520
|
+
// hidden is 0 or 1 per achievement, and a hidden one is a secret the store
|
|
521
|
+
// will not name in advance.
|
|
522
|
+
const hidden = highlighted.filter((h) => Number(h.hidden ?? 0) === 1).length;
|
|
523
|
+
const visible = highlighted.length - hidden;
|
|
524
|
+
const lines = [`Steam achievements for "${name}" (appid ${appid}):`];
|
|
525
|
+
if (total > 0) {
|
|
526
|
+
lines.push(` ${total} achievement${total === 1 ? "" : "s"} in total.`);
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
lines.push(" the store reports an achievement object with a total of zero, which is a known Steam state for titles that removed or never wired up the system.");
|
|
530
|
+
}
|
|
531
|
+
if (shown > 0)
|
|
532
|
+
lines.push(` ${shown} currently visible on the store page.`);
|
|
533
|
+
if (highlighted.length) {
|
|
534
|
+
lines.push(` ${highlighted.length} are listed with a name, of which ${hidden} are hidden secret${hidden === 1 ? "" : "s"} and ${visible} ${visible === 1 ? "is" : "are"} named.`);
|
|
535
|
+
lines.push(" the ones Steam will name up front:");
|
|
536
|
+
for (const h of highlighted.filter((x) => Number(x.hidden ?? 0) !== 1).slice(0, 8)) {
|
|
537
|
+
lines.push(` ${(str(h.localized_name) || str(h.name) || "unnamed").slice(0, 60)}`);
|
|
538
|
+
}
|
|
539
|
+
if (hidden)
|
|
540
|
+
lines.push(` the other ${hidden} are hidden, so their names are not on the store page until they are earned.`);
|
|
541
|
+
}
|
|
542
|
+
const cat = data.categories ?? [];
|
|
543
|
+
const hasMult = cat.some((c) => str(c.description) === "Steam Achievements");
|
|
544
|
+
if (!hasMult && total > 0)
|
|
545
|
+
lines.push(" the categories do not include Steam Achievements even though the record lists some, so the store page may not surface them.");
|
|
546
|
+
lines.push("");
|
|
547
|
+
lines.push(" The existing game details tool does not report achievements, and the review summary tool reports sentiment rather than the achievement list.");
|
|
548
|
+
return lines.join("\n");
|
|
549
|
+
});
|
|
550
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
3
|
+
* them how to get a key.
|
|
4
|
+
*
|
|
5
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
6
|
+
* license server on every call and keeps working through a brief outage.
|
|
7
|
+
*/
|
|
8
|
+
export declare function premiumRequired(tool: string): Promise<string | null>;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Premium tools on this server need a license key bought from MCP Marketplace.
|
|
2
|
+
// Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
|
|
3
|
+
// that is not listed in PREMIUM keeps working without a key.
|
|
4
|
+
const SLUG = "steam-mcp";
|
|
5
|
+
// Tools that need a key. Everything else is free.
|
|
6
|
+
const PREMIUM = new Set([
|
|
7
|
+
"game_dlc_cost",
|
|
8
|
+
"game_requirements",
|
|
9
|
+
]);
|
|
10
|
+
const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
|
|
11
|
+
// The license check calls the marketplace's verify endpoint directly instead of
|
|
12
|
+
// using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
|
|
13
|
+
// check it makes is rejected by Supabase before it reaches the function. The
|
|
14
|
+
// publishable key below is public by design - it ships in mcp-marketplace.io's
|
|
15
|
+
// own JavaScript and only ever reaches their licence check.
|
|
16
|
+
const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
|
|
17
|
+
const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
|
|
18
|
+
const OK_CACHE_MS = 60 * 60 * 1000;
|
|
19
|
+
const BAD_CACHE_MS = 60 * 1000;
|
|
20
|
+
const seen = new Map();
|
|
21
|
+
const REASONS = {
|
|
22
|
+
missing_key: "no key is set",
|
|
23
|
+
invalid_format: "that key is not a valid MCP Marketplace key",
|
|
24
|
+
not_found: "that key was not found",
|
|
25
|
+
revoked: "that key has been revoked",
|
|
26
|
+
rotated: "that key was rotated, use your new one",
|
|
27
|
+
expired: "that key has expired, renew it",
|
|
28
|
+
rate_limited: "there were too many checks just now, try again shortly",
|
|
29
|
+
network_error: "the license server could not be reached",
|
|
30
|
+
};
|
|
31
|
+
function blocked(tool, reason) {
|
|
32
|
+
const why = REASONS[reason] ?? `the license server answered "${reason}"`;
|
|
33
|
+
return `"${tool}" needs a license key, but ${why}. ` +
|
|
34
|
+
`Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
|
|
35
|
+
}
|
|
36
|
+
async function verify(key) {
|
|
37
|
+
const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
apikey: PUBLISHABLE_KEY,
|
|
41
|
+
Authorization: `Bearer ${PUBLISHABLE_KEY}`,
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({ key, slug: SLUG }),
|
|
45
|
+
// an unusable key answers 400 with a JSON body, so read the body either way
|
|
46
|
+
signal: AbortSignal.timeout(15000),
|
|
47
|
+
});
|
|
48
|
+
const data = (await res.json());
|
|
49
|
+
if (typeof data?.valid !== "boolean")
|
|
50
|
+
return { valid: false, reason: "unexpected_response" };
|
|
51
|
+
return data;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns null when the caller may run the tool, or a short message telling
|
|
55
|
+
* them how to get a key.
|
|
56
|
+
*
|
|
57
|
+
* A good key is remembered for an hour, so a busy session does not hit the
|
|
58
|
+
* license server on every call and keeps working through a brief outage.
|
|
59
|
+
*/
|
|
60
|
+
export async function premiumRequired(tool) {
|
|
61
|
+
if (!PREMIUM.has(tool))
|
|
62
|
+
return null;
|
|
63
|
+
const key = process.env.MCP_LICENSE_KEY;
|
|
64
|
+
if (!key)
|
|
65
|
+
return blocked(tool, "missing_key");
|
|
66
|
+
const hit = seen.get(key);
|
|
67
|
+
if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
|
|
68
|
+
return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
|
|
69
|
+
}
|
|
70
|
+
let result;
|
|
71
|
+
try {
|
|
72
|
+
result = await verify(key);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
result = { valid: false, reason: "network_error" };
|
|
76
|
+
}
|
|
77
|
+
seen.set(key, { ...result, at: Date.now() });
|
|
78
|
+
return result.valid ? null : blocked(tool, result.reason ?? "invalid");
|
|
79
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { SteamError, formatAppDetail, formatStoreItem, getAppDetail, getNews, searchStore, } from "./api.js";
|
|
3
|
+
import { SteamError, fmtPrice, formatAppDetail, formatStoreItem, getAppDetail, getNews, getPlayerCount, getReviewSummary, getTopSellers, searchStore, storeHighlights, salesCheck, playerCountRank, game_requirements, game_dlc_cost, game_achievements } from "./api.js";
|
|
4
|
+
import { premiumRequired } from "./license.js";
|
|
4
5
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
6
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
7
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -69,6 +70,150 @@ export function createServer() {
|
|
|
69
70
|
return textError(errorMessage(e));
|
|
70
71
|
}
|
|
71
72
|
});
|
|
73
|
+
server.registerTool("reviews", {
|
|
74
|
+
title: "Review summary",
|
|
75
|
+
description: "Review totals for a game: total, positive and negative counts plus the summary label.",
|
|
76
|
+
inputSchema: z.object({ appid: z.number().describe("Steam app id, e.g. 292030.") }),
|
|
77
|
+
annotations: READ_ONLY,
|
|
78
|
+
}, async ({ appid }) => {
|
|
79
|
+
try {
|
|
80
|
+
const s = await getReviewSummary(appid);
|
|
81
|
+
return text(`Reviews: ${s.total.toLocaleString()} total, ${s.positive.toLocaleString()} positive, ${s.negative.toLocaleString()} negative — ${s.score_desc}.`);
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
return textError(errorMessage(e));
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
server.registerTool("players", {
|
|
88
|
+
title: "Player count",
|
|
89
|
+
description: "How many players are in a game right now.",
|
|
90
|
+
inputSchema: z.object({ appid: z.number().describe("Steam app id, e.g. 292030.") }),
|
|
91
|
+
annotations: READ_ONLY,
|
|
92
|
+
}, async ({ appid }) => {
|
|
93
|
+
try {
|
|
94
|
+
const n = await getPlayerCount(appid);
|
|
95
|
+
return text(`Current players: ${n.toLocaleString()}.`);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
return textError(errorMessage(e));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
server.registerTool("topsellers", {
|
|
102
|
+
title: "Top sellers",
|
|
103
|
+
description: "Current Steam top sellers with prices and discounts.",
|
|
104
|
+
inputSchema: z.object({ count: z.number().describe("How many entries, 1-10. Defaults to 10.").optional() }),
|
|
105
|
+
annotations: READ_ONLY,
|
|
106
|
+
}, async ({ count }) => {
|
|
107
|
+
try {
|
|
108
|
+
const items = await getTopSellers(Math.min(Math.max(count ?? 10, 1), 10));
|
|
109
|
+
if (!items.length)
|
|
110
|
+
return text('No top sellers returned.');
|
|
111
|
+
return text(`Steam top sellers:\n` + items.map((g, n) => `${n + 1}. [${g.id}] ${g.name} — ${g.final_price != null ? fmtPrice(g.final_price, g.currency) : 'price n/a'}${g.discount_percent ? ` (-${g.discount_percent}%)` : ''}`).join('\n'));
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return textError(errorMessage(e));
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
server.registerTool("store_highlights", {
|
|
118
|
+
title: "Store highlights",
|
|
119
|
+
description: "What Steam is pushing today: everything on sale with its discount, this week's new releases and the coming-soon list, in one call.",
|
|
120
|
+
inputSchema: z.object({
|
|
121
|
+
limit: z.number().describe("Items per section, 1-20. Defaults to 6.").optional(),
|
|
122
|
+
}),
|
|
123
|
+
annotations: READ_ONLY,
|
|
124
|
+
}, async ({ limit }) => {
|
|
125
|
+
try {
|
|
126
|
+
return text(await storeHighlights({ limit }));
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return textError(errorMessage(e));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
server.registerTool("sales_check", {
|
|
133
|
+
title: "Check a wishlist for sales",
|
|
134
|
+
description: "Check up to 15 app ids for a current discount and report the biggest one - how a wishlist is reviewed before a sale ends.",
|
|
135
|
+
inputSchema: z.object({
|
|
136
|
+
appids: z.array(z.number()).describe('Steam app ids, e.g. [620, 440, 730]'),
|
|
137
|
+
}),
|
|
138
|
+
annotations: READ_ONLY,
|
|
139
|
+
}, async ({ appids }) => {
|
|
140
|
+
try {
|
|
141
|
+
return text(await salesCheck({ appids }));
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
return textError(errorMessage(e));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
server.registerTool("player_count_rank", {
|
|
148
|
+
title: "Rank by live players",
|
|
149
|
+
description: "Live concurrent player counts for up to 15 games, ranked, so a shortlist can be compared on what is actually being played now.",
|
|
150
|
+
inputSchema: z.object({
|
|
151
|
+
appids: z.array(z.number()).describe('Steam app ids, e.g. [730, 570, 440]'),
|
|
152
|
+
}),
|
|
153
|
+
annotations: READ_ONLY,
|
|
154
|
+
}, async ({ appids }) => {
|
|
155
|
+
try {
|
|
156
|
+
return text(await playerCountRank({ appids }));
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
return textError(errorMessage(e));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
server.registerTool("game_requirements", {
|
|
163
|
+
title: "Game requirements",
|
|
164
|
+
description: "What a Steam title needs to run: minimum and recommended requirements for Windows, macOS and Linux with the store markup stripped to text, plus platform support flags, recommended age and interface languages.",
|
|
165
|
+
inputSchema: z.object({
|
|
166
|
+
appid: z.number().describe("Steam appid, e.g. 1245620 for Elden Ring."),
|
|
167
|
+
}),
|
|
168
|
+
annotations: READ_ONLY,
|
|
169
|
+
}, async ({ appid }) => {
|
|
170
|
+
// ---- paywall: game_requirements ----
|
|
171
|
+
const paywallMessage = await premiumRequired("game_requirements");
|
|
172
|
+
if (paywallMessage)
|
|
173
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
174
|
+
// ---- paywall: end ----
|
|
175
|
+
try {
|
|
176
|
+
return text(await game_requirements({ appid }));
|
|
177
|
+
}
|
|
178
|
+
catch (e) {
|
|
179
|
+
return textError(errorMessage(e));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
server.registerTool("game_dlc_cost", {
|
|
183
|
+
title: "Cost of all DLC",
|
|
184
|
+
description: "Total what owning a Steam title completely costs: the base game plus each downloadable item priced on its own, compared against the editions Steam sells, since buying them separately is often cheaper.",
|
|
185
|
+
inputSchema: z.object({
|
|
186
|
+
appid: z.number().describe("Steam appid, e.g. 1245620 for Elden Ring."),
|
|
187
|
+
}),
|
|
188
|
+
annotations: READ_ONLY,
|
|
189
|
+
}, async ({ appid }) => {
|
|
190
|
+
// ---- paywall: game_dlc_cost ----
|
|
191
|
+
const paywallMessage = await premiumRequired("game_dlc_cost");
|
|
192
|
+
if (paywallMessage)
|
|
193
|
+
return { content: [{ type: "text", text: paywallMessage }], isError: true };
|
|
194
|
+
// ---- paywall: end ----
|
|
195
|
+
try {
|
|
196
|
+
return text(await game_dlc_cost({ appid }));
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
return textError(errorMessage(e));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
server.registerTool("game_achievements", {
|
|
203
|
+
title: "Game achievements",
|
|
204
|
+
description: "How many Steam achievements a title has, how many are named up front versus hidden secrets, and whether the store record lists any at all.",
|
|
205
|
+
inputSchema: z.object({
|
|
206
|
+
appid: z.number().describe("Steam appid, e.g. 1245620 for Elden Ring."),
|
|
207
|
+
}),
|
|
208
|
+
annotations: READ_ONLY,
|
|
209
|
+
}, async ({ appid }) => {
|
|
210
|
+
try {
|
|
211
|
+
return text(await game_achievements({ appid }));
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
return textError(errorMessage(e));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
72
217
|
return server;
|
|
73
218
|
}
|
|
74
219
|
function errorMessage(e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/steam-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Use this MCP server to Steam, store search, prices and discounts, app details, and game news. Tools include search games, get game details, get game news",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"mcpName": "io.github.mrfentmen/steam-mcp",
|