Skip to content
Merged
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
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({ 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<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 @@ -17,7 +17,7 @@

export function register(server: McpServer): void {
server.tool(
"steam_getPriceOverview",

Check failure on line 20 in src/tools/getPriceOverview.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

src/tools/__tests__/pricing.test.ts > price availability > does not infer free from price-only free, coming-soon or unavailable app responses

TypeError: server.registerTool is not a function ❯ register src/tools/getPriceOverview.ts:20:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:41:26

Check failure on line 20 in src/tools/getPriceOverview.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

src/tools/__tests__/pricing.test.ts > price availability > does not infer free from price-only free, coming-soon or unavailable app responses

TypeError: server.registerTool is not a function ❯ register src/tools/getPriceOverview.ts:20:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:41:26
"Batch price check for multiple Steam apps in a specific region. Returns formatted price data including base price, discount, and final price. No API key required.",
inputSchema,
async ({ appids, cc }) => {
Expand All @@ -39,7 +39,7 @@
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 @@ -2,7 +2,8 @@
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
Expand All @@ -16,13 +17,13 @@
.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.",
),
};

export function register(server: McpServer): void {
server.tool(
"steam_getRegionalPricing",

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

src/tools/__tests__/pricing.test.ts > price availability > limits a twenty-country request to four simultaneous fetches

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:92:26

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

src/tools/__tests__/pricing.test.ts > price availability > keeps typed fetch failures visible without losing other regions

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:75:26

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

src/tools/__tests__/pricing.test.ts > price availability > uses a real eurozone country in the default region list

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:64:11

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (20)

src/tools/__tests__/pricing.test.ts > price availability > reports unknown regional prices, preserves paid prices and identifies region locks

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:56:26

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

src/tools/__tests__/pricing.test.ts > price availability > limits a twenty-country request to four simultaneous fetches

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:92:26

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

src/tools/__tests__/pricing.test.ts > price availability > keeps typed fetch failures visible without losing other regions

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:75:26

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

src/tools/__tests__/pricing.test.ts > price availability > uses a real eurozone country in the default region list

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:64:11

Check failure on line 26 in src/tools/getRegionalPricing.ts

View workflow job for this annotation

GitHub Actions / build-and-test (22)

src/tools/__tests__/pricing.test.ts > price availability > reports unknown regional prices, preserves paid prices and identifies region locks

TypeError: server.registerTool is not a function ❯ register src/tools/getRegionalPricing.ts:26:10 ❯ handlerFor src/tools/__tests__/pricing.test.ts:18:3 ❯ resultOf src/tools/__tests__/pricing.test.ts:24:28 ❯ src/tools/__tests__/pricing.test.ts:56:26
"Get pricing for a Steam app across multiple regions/countries. Returns price, currency, and discount info for each region. No API key required.",
inputSchema,
async ({ appid, countries }) => {
Expand All @@ -30,7 +31,7 @@
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 @@ -47,16 +48,28 @@
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