Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions src/tools/__tests__/pricing.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../utils/steam-api.js")>(),
steamFetch: vi.fn(),
}));

type ToolResult = { content: Array<{ type: string; text: string }> };
type Handler = (args: Record<string, unknown>) => Promise<ToolResult>;

function handlerFor(register: (server: McpServer) => void): Handler {
let handler: Handler | undefined;
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;
}

async function resultOf(register: (server: McpServer) => void, args: Record<string, unknown>) {
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());
});
});
2 changes: 1 addition & 1 deletion src/tools/getPriceOverview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,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;
}
Expand Down
29 changes: 21 additions & 8 deletions src/tools/getRegionalPricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { steamStoreUrl, steamFetch, errorResponse } from "../utils/steam-api.js";
import { READ_ONLY } from "../utils/annotations.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
Expand All @@ -17,7 +18,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.",
),
};

Expand All @@ -36,7 +37,7 @@ export function register(server: McpServer): void {
const regions = countries ?? DEFAULT_REGIONS;
const results: Record<string, unknown> = {};

const fetches = regions.map(async (cc) => {
const fetchRegion = async (cc: string) => {
try {
const url = steamStoreUrl("/api/appdetails", {
appids: appid,
Expand All @@ -53,16 +54,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: [
Expand Down
Loading