Compare commits

...

3 Commits

Author SHA1 Message Date
d2c3c07e7d docs: price filtering schema adjustments
Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
2026-04-30 23:18:49 -04:00
0470a7bec7 docs(mcp): clarify price filters are dollars 2026-04-30 23:17:59 -04:00
89ad1c521f fix(api): parse price filters as dollars 2026-04-30 23:17:56 -04:00
7 changed files with 257 additions and 40 deletions

View File

@@ -0,0 +1,82 @@
# Marketplace Dollar Price Inputs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make public marketplace price inputs use dollars while preserving core scraper cent-based filtering.
**Architecture:** API server owns HTTP query parsing and converts dollar amounts to cents before calling core. MCP server keeps forwarding numeric dollar values as query params. Core scraper internals remain unchanged because parsed listing prices already use cents. This applies to eBay `minPrice`/`maxPrice` and Kijiji `priceMin`/`priceMax`; Facebook exposes no price filter inputs.
**Tech Stack:** Bun, TypeScript, `bun:test`, MCP JSON-RPC adapter, framework-free Bun HTTP routes.
---
### Task 1: API Dollar Parsing
**Files:**
- Modify: `packages/api-server/src/routes/helpers.ts`
- Modify: `packages/api-server/src/routes/ebay.ts`
- Modify: `packages/api-server/src/routes/kijiji.ts`
- Test: `packages/api-server/test/routes.test.ts`
- [ ] **Step 1: Add failing API route tests**
Add tests proving eBay `minPrice=999.99` / `maxPrice=1000` and Kijiji `priceMin=999.99` / `priceMax=1000` are forwarded to core as `99999` and `100000` cents. Add validation tests for empty, whitespace, negative, hex, mixed text, and malformed decimal price values.
Run: `bun test packages/api-server/test/routes.test.ts`
Expected: new forwarding tests fail because route currently rejects decimals and forwards integer dollars unchanged.
- [ ] **Step 2: Implement dollar parser helper**
Add `parseDollarPriceParam(searchParams, name)` in `packages/api-server/src/routes/helpers.ts`. Accept `0`, `1000`, `999.99`, and `0.99`. Reject values that do not match `^\d+(?:\.\d{1,2})?$`. Convert to cents with `Math.round(Number(rawValue) * 100)`.
- [ ] **Step 3: Use dollar parser in eBay route**
Replace `parseNonNegativeIntegerParam` calls for eBay `minPrice`/`maxPrice` and Kijiji `priceMin`/`priceMax` with `parseDollarPriceParam`. Keep pagination/count params on integer parsing.
- [ ] **Step 4: Verify API tests**
Run: `bun test packages/api-server/test/routes.test.ts`
Expected: all API route tests pass.
### Task 2: MCP Schema Contract
**Files:**
- Modify: `packages/mcp-server/src/protocol/tools.ts`
- Test: `packages/mcp-server/test/protocol.test.ts`
- [ ] **Step 1: Add MCP schema/forwarding tests**
Add tests that `search_ebay` describes `minPrice` and `maxPrice` as dollar filters and forwards numeric dollar values unchanged in API query params.
Run: `bun test packages/mcp-server/test/protocol.test.ts`
Expected: description test fails until schema text changes; forwarding behavior should already pass or reveal mapping gaps.
- [ ] **Step 2: Update tool descriptions**
Change eBay `minPrice` and Kijiji `priceMin` descriptions to `Minimum price in dollars`. Change eBay `maxPrice` and Kijiji `priceMax` descriptions to `Maximum price in dollars`.
- [ ] **Step 3: Verify MCP tests**
Run: `bun test packages/mcp-server/test/protocol.test.ts`
Expected: all MCP protocol tests pass.
### Task 3: Cross-Package Verification
**Files:**
- No additional edits expected.
- [ ] **Step 1: Run relevant package tests**
Run: `bun test packages/api-server/test packages/mcp-server/test`
Expected: all tests pass.
- [ ] **Step 2: Run CI**
Run: `bun run ci`
Expected: typecheck and Biome pass without changing lint config.

View File

@@ -3,6 +3,7 @@ import { logger } from "../logger";
import { import {
emptySearchResponse, emptySearchResponse,
getRequiredSearchQuery, getRequiredSearchQuery,
parseDollarPriceParam,
parseNonNegativeIntegerParam, parseNonNegativeIntegerParam,
} from "./helpers"; } from "./helpers";
@@ -18,17 +19,11 @@ export async function ebayRoute(req: Request): Promise<Response> {
return SEARCH_QUERY; return SEARCH_QUERY;
} }
const minPrice = parseNonNegativeIntegerParam( const minPrice = parseDollarPriceParam(reqUrl.searchParams, "minPrice");
reqUrl.searchParams,
"minPrice",
);
if (minPrice instanceof Response) { if (minPrice instanceof Response) {
return minPrice; return minPrice;
} }
const maxPrice = parseNonNegativeIntegerParam( const maxPrice = parseDollarPriceParam(reqUrl.searchParams, "maxPrice");
reqUrl.searchParams,
"maxPrice",
);
if (maxPrice instanceof Response) { if (maxPrice instanceof Response) {
return maxPrice; return maxPrice;
} }

View File

@@ -39,6 +39,23 @@ export function parseNonNegativeIntegerParam(
return Number(rawValue); return Number(rawValue);
} }
export function parseDollarPriceParam(
searchParams: URLSearchParams,
name: string,
): number | undefined | Response {
const rawValue = searchParams.get(name);
if (rawValue === null) {
return undefined;
}
if (!/^\d+(?:\.\d{1,2})?$/.test(rawValue)) {
return Response.json(
{ message: `Invalid ${name} parameter` },
{ status: 400 },
);
}
return Math.round(Number(rawValue) * 100);
}
export function emptySearchResponse(hint?: string): Response { export function emptySearchResponse(hint?: string): Response {
const message = hint const message = hint
? `Search didn't return any results! ${hint}` ? `Search didn't return any results! ${hint}`

View File

@@ -3,6 +3,7 @@ import { logger } from "../logger";
import { import {
emptySearchResponse, emptySearchResponse,
getRequiredSearchQuery, getRequiredSearchQuery,
parseDollarPriceParam,
parseNonNegativeIntegerParam, parseNonNegativeIntegerParam,
} from "./helpers"; } from "./helpers";
@@ -26,17 +27,11 @@ export async function kijijiRoute(req: Request): Promise<Response> {
if (maxPages instanceof Response) { if (maxPages instanceof Response) {
return maxPages; return maxPages;
} }
const priceMin = parseNonNegativeIntegerParam( const priceMin = parseDollarPriceParam(reqUrl.searchParams, "priceMin");
reqUrl.searchParams,
"priceMin",
);
if (priceMin instanceof Response) { if (priceMin instanceof Response) {
return priceMin; return priceMin;
} }
const priceMax = parseNonNegativeIntegerParam( const priceMax = parseDollarPriceParam(reqUrl.searchParams, "priceMax");
reqUrl.searchParams,
"priceMax",
);
if (priceMax instanceof Response) { if (priceMax instanceof Response) {
return priceMax; return priceMax;
} }

View File

@@ -282,6 +282,24 @@ describe("API routes", () => {
); );
}); });
test("kijijiRoute forwards dollar price filters to core as cents", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji");
await kijijiRoute(
new Request(
"http://localhost/api/kijiji?q=laptop&priceMin=999.99&priceMax=1000",
),
);
expect(fetchKijijiItems).toHaveBeenCalledWith(
"laptop",
4,
"https://www.kijiji.ca",
expect.objectContaining({ priceMin: 99_999, priceMax: 100_000 }),
{},
);
});
test("kijijiRoute does not forward unstableFilter when false", async () => { test("kijijiRoute does not forward unstableFilter when false", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji"); const { kijijiRoute } = await import("../src/routes/kijiji");
@@ -414,6 +432,24 @@ describe("API routes", () => {
); );
}); });
test("ebayRoute forwards dollar price filters to core as cents", async () => {
const { ebayRoute } = await import("../src/routes/ebay");
fetchEbayItems.mockImplementation(() => Promise.resolve([{ title: "a" }]));
await ebayRoute(
new Request(
"http://localhost/api/ebay?q=macbook&minPrice=999.99&maxPrice=1000",
),
);
expect(fetchEbayItems).toHaveBeenCalledWith(
"macbook",
1,
expect.objectContaining({ minPrice: 99_999, maxPrice: 100_000 }),
);
});
test("ebayRoute passes through scraper payload unchanged in unstable mode", async () => { test("ebayRoute passes through scraper payload unchanged in unstable mode", async () => {
const { ebayRoute } = await import("../src/routes/ebay"); const { ebayRoute } = await import("../src/routes/ebay");
@@ -730,16 +766,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid minPrice parameter"); expect(body.message).toBe("Invalid minPrice parameter");
}); });
test("ebayRoute returns 400 for decimal minPrice", async () => { test("ebayRoute accepts decimal minPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay"); const { ebayRoute } = await import("../src/routes/ebay");
const response = await ebayRoute( await ebayRoute(
new Request("http://localhost/api/ebay?q=laptop&minPrice=1.5"), new Request("http://localhost/api/ebay?q=laptop&minPrice=1.5"),
); );
expect(response.status).toBe(400); expect(fetchEbayItems).toHaveBeenCalledWith(
const body = await response.json(); "laptop",
expect(body.message).toBe("Invalid minPrice parameter"); 1,
expect.objectContaining({ minPrice: 150 }),
);
}); });
test("ebayRoute returns 400 for non-integer maxPrice", async () => { test("ebayRoute returns 400 for non-integer maxPrice", async () => {
@@ -766,16 +804,18 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid maxPrice parameter"); expect(body.message).toBe("Invalid maxPrice parameter");
}); });
test("ebayRoute returns 400 for decimal maxPrice", async () => { test("ebayRoute accepts decimal maxPrice", async () => {
const { ebayRoute } = await import("../src/routes/ebay"); const { ebayRoute } = await import("../src/routes/ebay");
const response = await ebayRoute( await ebayRoute(
new Request("http://localhost/api/ebay?q=laptop&maxPrice=1.5"), new Request("http://localhost/api/ebay?q=laptop&maxPrice=1.5"),
); );
expect(response.status).toBe(400); expect(fetchEbayItems).toHaveBeenCalledWith(
const body = await response.json(); "laptop",
expect(body.message).toBe("Invalid maxPrice parameter"); 1,
expect.objectContaining({ maxPrice: 150 }),
);
}); });
test("kijijiRoute returns 400 for decimal maxPages", async () => { test("kijijiRoute returns 400 for decimal maxPages", async () => {
@@ -862,16 +902,20 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid priceMin parameter"); expect(body.message).toBe("Invalid priceMin parameter");
}); });
test("kijijiRoute returns 400 for decimal priceMin", async () => { test("kijijiRoute accepts decimal priceMin", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji"); const { kijijiRoute } = await import("../src/routes/kijiji");
const response = await kijijiRoute( await kijijiRoute(
new Request("http://localhost/api/kijiji?q=laptop&priceMin=1.5"), new Request("http://localhost/api/kijiji?q=laptop&priceMin=1.5"),
); );
expect(response.status).toBe(400); expect(fetchKijijiItems).toHaveBeenCalledWith(
const body = await response.json(); "laptop",
expect(body.message).toBe("Invalid priceMin parameter"); 4,
"https://www.kijiji.ca",
expect.objectContaining({ priceMin: 150 }),
{},
);
}); });
test("kijijiRoute returns 400 for non-integer priceMin", async () => { test("kijijiRoute returns 400 for non-integer priceMin", async () => {
@@ -934,16 +978,20 @@ describe("API routes", () => {
expect(body.message).toBe("Invalid priceMax parameter"); expect(body.message).toBe("Invalid priceMax parameter");
}); });
test("kijijiRoute returns 400 for decimal priceMax", async () => { test("kijijiRoute accepts decimal priceMax", async () => {
const { kijijiRoute } = await import("../src/routes/kijiji"); const { kijijiRoute } = await import("../src/routes/kijiji");
const response = await kijijiRoute( await kijijiRoute(
new Request("http://localhost/api/kijiji?q=laptop&priceMax=1.5"), new Request("http://localhost/api/kijiji?q=laptop&priceMax=1.5"),
); );
expect(response.status).toBe(400); expect(fetchKijijiItems).toHaveBeenCalledWith(
const body = await response.json(); "laptop",
expect(body.message).toBe("Invalid priceMax parameter"); 4,
"https://www.kijiji.ca",
expect.objectContaining({ priceMax: 150 }),
{},
);
}); });
test("kijijiRoute returns 400 for non-integer priceMax", async () => { test("kijijiRoute returns 400 for non-integer priceMax", async () => {

View File

@@ -50,11 +50,11 @@ export const tools = [
}, },
priceMin: { priceMin: {
type: "number", type: "number",
description: "Minimum price in cents", description: "Minimum price in dollars",
}, },
priceMax: { priceMax: {
type: "number", type: "number",
description: "Maximum price in cents", description: "Maximum price in dollars",
}, },
unstableFilter: { unstableFilter: {
type: "boolean", type: "boolean",
@@ -107,11 +107,11 @@ export const tools = [
}, },
minPrice: { minPrice: {
type: "number", type: "number",
description: "Minimum price filter", description: "Minimum price in dollars",
}, },
maxPrice: { maxPrice: {
type: "number", type: "number",
description: "Maximum price filter", description: "Maximum price in dollars",
}, },
strictMode: { strictMode: {
type: "boolean", type: "boolean",

View File

@@ -128,6 +128,46 @@ describe("MCP protocol unstableFilter", () => {
expect(String(calledUrl)).toContain("unstableFilter=true"); expect(String(calledUrl)).toContain("unstableFilter=true");
}); });
test("search_kijiji should document price filters as dollars", () => {
const tool = tools.find((candidate) => candidate.name === "search_kijiji");
const priceMin = tool?.inputSchema.properties.priceMin as {
description: string;
};
const priceMax = tool?.inputSchema.properties.priceMax as {
description: string;
};
expect(priceMin.description).toContain("dollars");
expect(priceMax.description).toContain("dollars");
});
test("handler should forward Kijiji dollar price filters to API", async () => {
await handleMcpRequest(
new Request("http://localhost", {
method: "POST",
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "search_kijiji",
arguments: {
query: "macbook",
priceMin: 999.99,
priceMax: 1000,
},
},
}),
}),
);
const calledUrl = (global.fetch as unknown as ReturnType<typeof mock>).mock
.calls[0]?.[0];
expect(String(calledUrl)).toContain("priceMin=999.99");
expect(String(calledUrl)).toContain("priceMax=1000");
});
test("handler should forward unstableFilter=true for search_facebook", async () => { test("handler should forward unstableFilter=true for search_facebook", async () => {
await handleMcpRequest( await handleMcpRequest(
new Request("http://localhost", { new Request("http://localhost", {
@@ -204,4 +244,44 @@ describe("MCP protocol unstableFilter", () => {
.calls[0]?.[0]; .calls[0]?.[0];
expect(String(calledUrl)).toContain("unstableFilter=true"); expect(String(calledUrl)).toContain("unstableFilter=true");
}); });
test("search_ebay should document price filters as dollars", () => {
const tool = tools.find((candidate) => candidate.name === "search_ebay");
const minPrice = tool?.inputSchema.properties.minPrice as {
description: string;
};
const maxPrice = tool?.inputSchema.properties.maxPrice as {
description: string;
};
expect(minPrice.description).toContain("dollars");
expect(maxPrice.description).toContain("dollars");
});
test("handler should forward eBay dollar price filters to API", async () => {
await handleMcpRequest(
new Request("http://localhost", {
method: "POST",
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "search_ebay",
arguments: {
query: "macbook",
minPrice: 999.99,
maxPrice: 1000,
},
},
}),
}),
);
const calledUrl = (global.fetch as unknown as ReturnType<typeof mock>).mock
.calls[0]?.[0];
expect(String(calledUrl)).toContain("minPrice=999.99");
expect(String(calledUrl)).toContain("maxPrice=1000");
});
}); });