From 66a9aac929caa5192317effccff1080cdd016c92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emilio=20Castej=C3=B3n?= <107069576+ect2000@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:58:03 +0100 Subject: [PATCH 1/2] fix(pricing): distinguish missing prices and bound regional fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Emilio Castejón <107069576+ect2000@users.noreply.github.com> --- src/tools/__tests__/pricing.test.ts | 98 +++++++++++++++++++++++++++++ src/tools/getPriceOverview.ts | 2 +- src/tools/getRegionalPricing.ts | 29 ++++++--- 3 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 src/tools/__tests__/pricing.test.ts diff --git a/src/tools/__tests__/pricing.test.ts b/src/tools/__tests__/pricing.test.ts new file mode 100644 index 0000000..7ff2890 --- /dev/null +++ b/src/tools/__tests__/pricing.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { steamFetch } from "../../utils/steam-api.js"; +import { RateLimitError } from "../../utils/errors.js"; +import { register as registerOverview } from "../getPriceOverview.js"; +import { register as registerRegional } from "../getRegionalPricing.js"; + +vi.mock("../../utils/steam-api.js", async (importOriginal) => ({ + ...await importOriginal(), + steamFetch: vi.fn(), +})); + +type ToolResult = { content: Array<{ type: string; text: string }> }; +type Handler = (args: Record) => Promise; + +function handlerFor(register: (server: McpServer) => void): Handler { + let handler: Handler | undefined; + register({ tool(...args: unknown[]) { handler = args[args.length - 1] as Handler; } } as unknown as McpServer); + if (!handler) throw new Error("Tool was not registered"); + return handler; +} + +async function resultOf(register: (server: McpServer) => void, args: Record) { + return JSON.parse((await handlerFor(register)(args)).content[0].text); +} + +const fetchMock = vi.mocked(steamFetch); +const noPrice = { price_overview: null, reason: "no_price_listed" }; +const paid = { currency: "USD", initial: 1000, final: 500, discount_percent: 50 }; + +beforeEach(() => { fetchMock.mockReset(); }); + +describe("price availability", () => { + it("does not infer free from price-only free, coming-soon or unavailable app responses", async () => { + fetchMock.mockResolvedValue({ + "440": { success: true, data: [] }, + "1": { success: true, data: {} }, + "2": { success: false }, + "400": { success: true, data: { price_overview: paid } }, + }); + const result = await resultOf(registerOverview, { appids: [440, 1, 2, 400], cc: "US" }); + expect(result.prices[440]).toEqual(noPrice); + expect(result.prices[1]).toEqual(noPrice); + expect(result.prices[2].error).toBe("App not found or region-locked"); + expect(result.prices[400]).toEqual(paid); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports unknown regional prices, preserves paid prices and identifies region locks", async () => { + fetchMock.mockImplementation(async (url) => { + const cc = new URL(url).searchParams.get("cc"); + return { "440": cc === "GB" ? { success: false } : { + success: true, data: cc === "DE" ? { price_overview: paid } : [], + } }; + }); + const result = await resultOf(registerRegional, { appid: 440, countries: ["US", "DE", "GB"] }); + expect(result.regions.US).toEqual(noPrice); + expect(result.regions.DE).toEqual(paid); + expect(result.regions.GB.error).toBe("Not available in this region"); + }); + + it("uses a real eurozone country in the default region list", async () => { + fetchMock.mockResolvedValue({ "440": { success: true, data: [] } }); + await resultOf(registerRegional, { appid: 440 }); + const countries = fetchMock.mock.calls.map(([url]) => new URL(url).searchParams.get("cc")); + expect(countries).toContain("DE"); + expect(countries).not.toContain("EU"); + }); + + it("keeps typed fetch failures visible without losing other regions", async () => { + fetchMock.mockImplementation(async (url) => { + if (new URL(url).searchParams.get("cc") === "US") throw new RateLimitError(); + return { "400": { success: true, data: { price_overview: paid } } }; + }); + const result = await resultOf(registerRegional, { appid: 400, countries: ["US", "DE"] }); + expect(result.regions.US.error_type).toBe("RateLimitError"); + expect(result.regions.US.error).toContain("rate limit"); + expect(result.regions.DE).toEqual(paid); + }); + + it("limits a twenty-country request to four simultaneous fetches", async () => { + let active = 0; + let peak = 0; + fetchMock.mockImplementation(async () => { + active++; + peak = Math.max(peak, active); + await Promise.resolve(); + active--; + return { "440": { success: true, data: [] } }; + }); + const countries = ["US", "GB", "DE", "BR", "RU", "CN", "JP", "AU", "IN", "TR", "FR", "ES", "IT", "CA", "NZ", "MX", "PL", "NL", "SE", "NO"]; + const result = await resultOf(registerRegional, { appid: 440, countries }); + expect(peak).toBe(4); + expect(active).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(20); + expect(Object.keys(result.regions).sort()).toEqual([...countries].sort()); + }); +}); diff --git a/src/tools/getPriceOverview.ts b/src/tools/getPriceOverview.ts index 2aae9e2..962c2d2 100644 --- a/src/tools/getPriceOverview.ts +++ b/src/tools/getPriceOverview.ts @@ -39,7 +39,7 @@ export function register(server: McpServer): void { if (!entry || !entry.success) { results[String(id)] = { error: "App not found or region-locked" }; } else if (!entry.data?.price_overview) { - results[String(id)] = { free: true, price_overview: null }; + results[String(id)] = { price_overview: null, reason: "no_price_listed" }; } else { results[String(id)] = entry.data.price_overview; } diff --git a/src/tools/getRegionalPricing.ts b/src/tools/getRegionalPricing.ts index 69f343e..e7d9d5f 100644 --- a/src/tools/getRegionalPricing.ts +++ b/src/tools/getRegionalPricing.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { steamStoreUrl, steamFetch, errorResponse } from "../utils/steam-api.js"; -const DEFAULT_REGIONS = ["US", "GB", "EU", "BR", "RU", "CN", "JP", "AU", "IN", "TR"]; +const DEFAULT_REGIONS = ["US", "GB", "DE", "BR", "RU", "CN", "JP", "AU", "IN", "TR"]; +const MAX_CONCURRENT_REGIONS = 4; const inputSchema = { appid: z @@ -16,7 +17,7 @@ const inputSchema = { .max(20) .optional() .describe( - "Two-letter country codes to check (default: US, GB, EU, BR, RU, CN, JP, AU, IN, TR). Max 20.", + "Two-letter country codes to check (default: US, GB, DE, BR, RU, CN, JP, AU, IN, TR). Max 20.", ), }; @@ -30,7 +31,7 @@ export function register(server: McpServer): void { const regions = countries ?? DEFAULT_REGIONS; const results: Record = {}; - const fetches = regions.map(async (cc) => { + const fetchRegion = async (cc: string) => { try { const url = steamStoreUrl("/api/appdetails", { appids: appid, @@ -47,16 +48,28 @@ export function register(server: McpServer): void { if (!entry || !entry.success) { results[cc] = { error: "Not available in this region" }; } else if (!entry.data?.price_overview) { - results[cc] = { free: true }; + results[cc] = { price_overview: null, reason: "no_price_listed" }; } else { results[cc] = entry.data.price_overview; } - } catch { - results[cc] = { error: "Failed to fetch" }; + } catch (error) { + results[cc] = { + error: errorResponse(error).content[0].text, + error_type: error instanceof Error ? error.name : "UnknownError", + }; } - }); + }; - await Promise.all(fetches); + let nextRegion = 0; + await Promise.all(Array.from( + { length: Math.min(MAX_CONCURRENT_REGIONS, regions.length) }, + async () => { + while (nextRegion < regions.length) { + const cc = regions[nextRegion++]; + await fetchRegion(cc); + } + }, + )); return { content: [ From b055056d80597aa3ceed708e622815459d57c1cb Mon Sep 17 00:00:00 2001 From: TM Hospitality Strategies Date: Sun, 25 Oct 2026 18:46:03 -0400 Subject: [PATCH 2/2] test(pricing): register through registerTool; add CHANGELOG entry main moved every tool to server.registerTool(), so the test's fake server must expose registerTool rather than the deprecated tool(). Co-Authored-By: Claude Opus 5.5 Signed-off-by: TM Hospitality Strategies --- CHANGELOG.md | 1 + src/tools/__tests__/pricing.test.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddf855..23168e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `steam_getPriceOverview` and `steam_getRegionalPricing` no longer report `free: true` when Steam lists no price. That response also covers coming-soon, delisted, and unavailable paid games, so it now returns `price_overview: null` with `reason: "no_price_listed"`. **Breaking:** the `free` field is gone. The regional default list uses `DE` instead of the non-country `EU`, failed regions keep their error message and `error_type`, and regional lookups run at most four at a time. Contributed by @ect2000. (#33) - `steam_getReviews` and `steam_getAppReviewSummary` now call `IUserReviewsService/GetAppReviews`. Valve disabled the Store `/appreviews` endpoint on 2026-10-22, so both tools failed on every call. Both remain keyless. `steam_getReviews` adds a `funny` sort and accepts a comma-separated `language` list. `steam_getAppReviewSummary` now counts all purchase types, matching the store page total. (#27) - `steam_setAchievement` and `steam_clearAchievement` report success only when Steam returns EResult 1. A response with no `result` object used to read as success. Failures now include the EResult. (#52) - `steam_grantInventoryItem` sends the documented AddItem parameters: one `itemdefid[n]` per copy (`quantity` capped at 100), `notify` as `1`/`0`, and a `requestid` so Steam executes a grant at most once. The dry run generates a `requestid`; pass it to the live call. New optional `itempropsjson` input. (#28) diff --git a/src/tools/__tests__/pricing.test.ts b/src/tools/__tests__/pricing.test.ts index 7ff2890..a7136f3 100644 --- a/src/tools/__tests__/pricing.test.ts +++ b/src/tools/__tests__/pricing.test.ts @@ -15,7 +15,7 @@ type Handler = (args: Record) => Promise; function handlerFor(register: (server: McpServer) => void): Handler { let handler: Handler | undefined; - register({ tool(...args: unknown[]) { handler = args[args.length - 1] as Handler; } } as unknown as McpServer); + register({ registerTool(...args: unknown[]) { handler = args[args.length - 1] as Handler; } } as unknown as McpServer); if (!handler) throw new Error("Tool was not registered"); return handler; }