ikea-mcp 1.2.1 → 1.4.0

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 CHANGED
@@ -8,12 +8,13 @@ Read-only MCP server for IKEA product search and in-store stock lookup.
8
8
  ## Capabilities
9
9
  | Tool | What it does |
10
10
  |---|---|
11
+ | `list_stores` | List known store IDs and labels, optionally filtered by country |
11
12
  | `search_products` | Search IKEA products by keyword |
12
13
  | `get_product_details` | Get details for a single product by item number |
13
14
  | `check_store_stock` | Check cash-and-carry stock at one store |
14
15
  | `check_multi_item_stock` | Check stock for multiple items at one store |
15
- | `compare_store_stock` | Compare stock across multiple stores |
16
- | `find_best_store_for_item` | Rank stores by in-stock quantity |
16
+ | `compare_store_stock` | Compare stock across explicit stores or a country catalog |
17
+ | `find_best_store_for_item` | Rank stores by in-stock quantity (optionally filter by country) |
17
18
 
18
19
  ## MVP limitations
19
20
  - Uses unofficial public IKEA APIs — no SLA, may break without notice
@@ -32,7 +33,7 @@ Search IKEA products by keyword.
32
33
  | param | type | default | required |
33
34
  |---|---|---|---|
34
35
  | `query` | string | — | yes |
35
- | `countryCode` | string | `"us"` | no |
36
+ | `countryCode` | string | `"US"` | no |
36
37
  | `langCode` | string | `"en"` | no |
37
38
  | `size` | number | `10` | no |
38
39
 
@@ -64,7 +65,7 @@ Get details for a single IKEA product by item number.
64
65
  | param | type | default | required |
65
66
  |---|---|---|---|
66
67
  | `itemNo` | string | — | yes |
67
- | `countryCode` | string | `"us"` | no |
68
+ | `countryCode` | string | `"US"` | no |
68
69
  | `langCode` | string | `"en"` | no |
69
70
 
70
71
  **Output**
@@ -95,7 +96,7 @@ Check stock at a single IKEA store.
95
96
  |---|---|---|---|
96
97
  | `itemNo` | string | — | yes |
97
98
  | `storeId` | string | — | yes |
98
- | `countryCode` | string | `"us"` | no |
99
+ | `countryCode` | string | `"US"` | no |
99
100
 
100
101
  **Output**
101
102
  ```json
@@ -123,17 +124,31 @@ On error (e.g. item not carried):
123
124
 
124
125
  ### `compare_store_stock`
125
126
 
126
- Compare stock for one item across multiple stores.
127
+ Compare stock for one item across multiple stores. Provide explicit `storeIds`, or use `countryCode` to expand to all catalog stores for that country. At least one of `storeIds` or `countryCode` is required.
127
128
 
128
129
  **Input**
129
130
  | param | type | default | required |
130
131
  |---|---|---|---|
131
132
  | `itemNo` | string | — | yes |
132
- | `storeIds` | string[] (min 2) | — | yes |
133
- | `countryCode` | string | `"us"` | no |
133
+ | `storeIds` | string[] (min 2) | — | one of `storeIds`/`countryCode` |
134
+ | `countryCode` | `"US"` \| `"CA"` | — | one of `storeIds`/`countryCode` |
135
+ | `sortBy` | `"quantity"` \| `"storeId"` | — | no |
136
+
137
+ `storeIds` takes precedence — if both are provided, `countryCode` only sets the IKEA API locale.
138
+ `sortBy: "quantity"` sorts descending, null quantities last, `storeId` as tie-breaker. `sortBy: "storeId"` sorts ascending. Omitting `sortBy` preserves input order.
139
+
140
+ **Examples**
141
+ ```json
142
+ { "itemNo": "20522046", "storeIds": ["399", "026", "921"] }
143
+ ```
144
+ ```json
145
+ { "itemNo": "20522046", "countryCode": "CA" }
146
+ ```
134
147
 
135
148
  **Output** — array of the same shape as `check_store_stock` (one entry per store).
136
149
 
150
+ **Detecting partial failures:** rows with `errors` containing any code other than `404` indicate a store-level or API failure (e.g. `405` = invalid store ID). Rows with only `404` errors mean the item is simply not stocked at that store — this is expected, not a failure.
151
+
137
152
  ---
138
153
 
139
154
  ### `check_multi_item_stock`
@@ -175,7 +190,11 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
175
190
  |---|---|---|---|
176
191
  | `itemNo` | string | — | yes |
177
192
  | `storeIds` | string[] | all known stores | no |
178
- | `maxResults` | number | `3` (max 10) | no |
193
+ | `maxResults` | number | `3` (max 50) | no |
194
+ | `countryCode` | `"US"` \| `"CA"` | — | no |
195
+ | `minQuantity` | number (int ≥ 1) | — | no |
196
+
197
+ `storeIds` takes precedence. If only `countryCode` is given, searches all catalog stores for that country. If neither is given, searches all ~65 known stores. `minQuantity` excludes stores with quantity below the threshold.
179
198
 
180
199
  **Output** — array of matching stores, up to `maxResults`:
181
200
  ```json
@@ -192,6 +211,8 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
192
211
 
193
212
  Returns `[]` if no store has the item in stock. "All known stores" means the ~65 US and Canada entries in `src/data/stores.ts`.
194
213
 
214
+ **Note on failures:** stores that return a store-level error (405 invalid store ID) are silently excluded from results rather than appearing as rows. Use `compare_store_stock` with the same `storeIds` to inspect per-store `errors` directly.
215
+
195
216
  ---
196
217
 
197
218
  ## Example workflows
@@ -365,6 +386,46 @@ An invalid or unsupported `storeId` returns a 405 error in the `errors` array.
365
386
 
366
387
  - Uses unofficial public IKEA APIs — no SLA, no auth required, may break without notice.
367
388
  - Read-only: no cart, no order, no account operations.
368
- - `compare_store_stock` fires all store requests in parallel — large `storeIds` arrays may hit rate limits.
389
+ - Country-wide fan-out (`countryCode: "US"` ≈ 52 stores, `"CA"` ≈ 15) is capped at 10 concurrent requests and retries once on transient 5xx/network errors.
369
390
  - Click-and-collect and home-delivery availability are not exposed (cash-and-carry only).
370
391
  - `size` in `search_products` is capped by IKEA's API (observed max ~24 per page; `total` reflects the full catalogue count).
392
+ - US and Canada only — no other countries supported.
393
+
394
+ ## Item numbers
395
+
396
+ `itemNo` fields accept several formats — all are normalised to 8 digits internally:
397
+
398
+ | Input | Normalised |
399
+ |---|---|
400
+ | `"20522046"` | `"20522046"` |
401
+ | `"522132"` | `"00522132"` |
402
+ | `"005.221.32"` | `"00522132"` |
403
+ | `"5-221-32"` | `"00522132"` |
404
+
405
+ 6- and 7-digit inputs are left-padded to 8 digits. 8- and 9-digit inputs are kept as-is. Values outside 6–9 digits after stripping are rejected.
406
+
407
+ ## Supported countries
408
+
409
+ | Country | Code | Store count |
410
+ |---|---|---|
411
+ | United States | `US` | ~52 |
412
+ | Canada | `CA` | ~15 |
413
+
414
+ Use `list_stores` to get the current catalog. Some store IDs in the catalog are unverified — they are listed but may return 405 from the stock API.
415
+
416
+ ## Rate limits & reliability
417
+
418
+ - Fan-out requests (country-wide `compare_store_stock` / `find_best_store_for_item`) are capped at **10 concurrent** outbound requests.
419
+ - `fetchJson` retries **once** after 500 ms on 5xx, 429, or network errors. 404 and 405 are not retried (they are semantic responses, not transient failures).
420
+ - `Retry-After` header is respected for 429 responses.
421
+ - Do not use in high-frequency loops — the upstream IKEA API has no published rate limit but will block repeated bursts.
422
+
423
+ ## Troubleshooting
424
+
425
+ | Symptom | Likely cause |
426
+ |---|---|
427
+ | `405` in `errors` | Invalid `storeId` — use `list_stores` to find valid IDs |
428
+ | `404` in `errors` | Item not stocked at that store |
429
+ | Empty `find_best_store_for_item` result | No store has the item in stock, or `minQuantity` is too high |
430
+ | Slow `countryCode` query | Normal — fan-out to all country stores (capped at 10 concurrent) |
431
+ | `itemNo` validation error | Input must resolve to 6–9 digits; see Item numbers above |
@@ -83,3 +83,11 @@ export const STORE_LABELS = {
83
83
  export function storeLabel(storeId) {
84
84
  return STORE_LABELS[storeId];
85
85
  }
86
+ // Canada labels have a province code before the country: "(City, BC, CA)"
87
+ // US state abbreviations like California "(City, CA)" must not match.
88
+ const CA_LABEL_RE = /,\s+[A-Z]{2},\s+CA\)$/;
89
+ export function storeIdsByCountry(countryCode) {
90
+ return Object.entries(STORE_LABELS)
91
+ .filter(([, label]) => countryCode === "CA" ? CA_LABEL_RE.test(label) : !CA_LABEL_RE.test(label))
92
+ .map(([id]) => id);
93
+ }
@@ -1,31 +1,71 @@
1
1
  import { z } from "zod";
2
+ // ── normalizeItemNo ───────────────────────────────────────────────────────────
3
+ /**
4
+ * Strips non-digit characters, then left-pads to 8 digits when the result is
5
+ * 6 or 7 digits long. 8- and 9-digit results are kept as-is. All other lengths
6
+ * are left unchanged and will be rejected by the downstream Zod regex.
7
+ *
8
+ * Exported so it can be unit-tested independently.
9
+ */
10
+ export function normalizeItemNo(raw) {
11
+ const digits = raw.replace(/\D/g, "");
12
+ return digits.length === 6 || digits.length === 7
13
+ ? digits.padStart(8, "0")
14
+ : digits;
15
+ }
16
+ // Zod schema for a single IKEA item number.
17
+ // Accepts dotted ("005.221.32"), hyphenated ("5-221-32"), and plain ("522132") inputs.
18
+ // Rejects empty strings, <6-digit results, and >9-digit results.
19
+ const ItemNo = z
20
+ .string()
21
+ .transform(normalizeItemNo)
22
+ .pipe(z
23
+ .string()
24
+ .regex(/^\d{8,9}$/, "itemNo must resolve to 8 or 9 digits after stripping non-digit characters"));
25
+ // ── countryCode helpers ───────────────────────────────────────────────────────
26
+ const toUpper = (v) => typeof v === "string" ? v.toUpperCase() : v;
27
+ // Free-form 2-letter ISO country code normalised to uppercase.
28
+ // Used by search and product-detail tools that accept any country code.
29
+ const CountryCode2 = z.preprocess(toUpper, z.string().length(2));
30
+ // Uppercase-normalised enum restricted to the two countries supported by the
31
+ // stock-lookup API. Accepts "us"/"ca" and normalises to "US"/"CA".
32
+ const CountryCodeEnum = z.preprocess(toUpper, z.enum(["US", "CA"]));
33
+ // ── Input schemas ─────────────────────────────────────────────────────────────
2
34
  export const SearchProductsInput = z.object({
3
35
  query: z.string().min(1),
4
- countryCode: z.string().length(2).default("us"),
36
+ countryCode: CountryCode2.default("US"),
5
37
  langCode: z.string().length(2).default("en"),
6
38
  size: z.number().int().min(1).max(50).default(10),
7
39
  });
8
40
  export const CheckStoreStockInput = z.object({
9
- itemNo: z.string().min(1),
41
+ itemNo: ItemNo,
10
42
  storeId: z.string().min(1),
11
- countryCode: z.string().length(2).default("us"),
43
+ countryCode: CountryCode2.default("US"),
12
44
  });
13
- export const CompareStoreStockInput = z.object({
14
- itemNo: z.string().min(1),
15
- storeIds: z.array(z.string().min(1)).min(2).max(10),
16
- countryCode: z.string().length(2).default("us"),
45
+ export const CompareStoreStockInput = z
46
+ .object({
47
+ itemNo: ItemNo,
48
+ storeIds: z.array(z.string().min(1)).min(2).max(10).optional(),
49
+ countryCode: CountryCodeEnum.optional(),
50
+ sortBy: z.enum(["quantity", "storeId"]).optional(),
51
+ })
52
+ .refine((d) => d.storeIds !== undefined || d.countryCode !== undefined, { message: "provide storeIds or countryCode" });
53
+ export const ListStoresInput = z.object({
54
+ countryCode: CountryCodeEnum.optional(),
17
55
  });
18
56
  export const FindBestStoreInput = z.object({
19
- itemNo: z.string().min(1),
57
+ itemNo: ItemNo,
20
58
  storeIds: z.array(z.string().min(1)).optional(),
21
- maxResults: z.number().int().min(1).max(10).default(3),
59
+ maxResults: z.number().int().min(1).max(50).default(3),
60
+ countryCode: CountryCodeEnum.optional(),
61
+ minQuantity: z.number().int().min(1).optional(),
22
62
  });
23
63
  export const GetProductDetailsInput = z.object({
24
- itemNo: z.string().min(1),
25
- countryCode: z.string().length(2).default("us"),
64
+ itemNo: ItemNo,
65
+ countryCode: CountryCode2.default("US"),
26
66
  langCode: z.string().length(2).default("en"),
27
67
  });
28
68
  export const CheckMultiItemStockInput = z.object({
29
69
  storeId: z.string().min(1),
30
- itemNos: z.array(z.string().min(1)).min(1).max(20),
70
+ itemNos: z.array(ItemNo).min(1).max(20),
31
71
  });
package/dist/server.js CHANGED
@@ -6,7 +6,8 @@ import { compareStoreStockTool } from "./tools/compare_store_stock.js";
6
6
  import { findBestStoreForItemTool } from "./tools/find_best_store_for_item.js";
7
7
  import { getProductDetailsTool } from "./tools/get_product_details.js";
8
8
  import { checkMultiItemStockTool } from "./tools/check_multi_item_stock.js";
9
- const tools = [searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool];
9
+ import { listStoresTool } from "./tools/list_stores.js";
10
+ const tools = [listStoresTool, searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool];
10
11
  export function createServer() {
11
12
  const server = new Server({ name: "ikea-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
12
13
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -16,6 +16,7 @@ export function projectStock(result) {
16
16
  availableForCashCarry: a?.availableForCashCarry ?? false,
17
17
  quantity: a?.buyingOption.cashCarry.availability.quantity ?? null,
18
18
  messageType: a?.buyingOption.cashCarry.availability.probability.thisDay.messageType ?? null,
19
+ eligibleForStockNotification: a?.buyingOption.cashCarry.eligibleForStockNotification ?? null,
19
20
  errors: annotateStockErrors(result.errors) ?? [],
20
21
  };
21
22
  }
@@ -15,11 +15,10 @@ export const checkMultiItemStockTool = {
15
15
  async handler(rawInput) {
16
16
  const input = CheckMultiItemStockInput.parse(rawInput);
17
17
  const result = await getMultiItemStock(input.itemNos, input.storeId);
18
- const label = storeLabel(input.storeId);
19
18
  const rows = projectMultiItemStock(result, input.itemNos).map((row) => ({
20
19
  itemNo: row.itemNo,
21
20
  storeId: input.storeId,
22
- ...(label ? { storeLabel: label } : {}),
21
+ storeLabel: storeLabel(input.storeId) ?? input.storeId,
23
22
  availableForCashCarry: row.availableForCashCarry,
24
23
  quantity: row.quantity,
25
24
  messageType: row.messageType,
@@ -16,10 +16,9 @@ export const checkStoreStockTool = {
16
16
  async handler(rawInput) {
17
17
  const input = CheckStoreStockInput.parse(rawInput);
18
18
  const result = await getStoreStock(input.itemNo, input.storeId, input.countryCode);
19
- const label = storeLabel(input.storeId);
20
19
  const output = {
21
20
  storeId: input.storeId,
22
- ...(label ? { storeLabel: label } : {}),
21
+ storeLabel: storeLabel(input.storeId) ?? input.storeId,
23
22
  ...projectStock(result),
24
23
  };
25
24
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
@@ -1,28 +1,39 @@
1
1
  import { CompareStoreStockInput } from "../schemas/index.js";
2
2
  import { getStoreStock, projectStock } from "../services/ikea.js";
3
- import { storeLabel } from "../data/stores.js";
3
+ import { storeLabel, storeIdsByCountry } from "../data/stores.js";
4
+ import { pMap } from "../utils/http.js";
4
5
  export const compareStoreStockTool = {
5
6
  name: "compare_store_stock",
6
- description: "Compare stock availability for an item across multiple IKEA stores.",
7
+ description: "Compare stock availability for an item across multiple IKEA stores. Provide explicit storeIds, or use countryCode ('US' or 'CA') to compare all catalog stores for that country.",
7
8
  inputSchema: {
8
9
  type: "object",
9
10
  properties: {
10
11
  itemNo: { type: "string" },
11
12
  storeIds: { type: "array", items: { type: "string" }, minItems: 2 },
12
- countryCode: { type: "string", default: "us" },
13
+ countryCode: { type: "string", enum: ["US", "CA"] },
14
+ sortBy: { type: "string", enum: ["quantity", "storeId"] },
13
15
  },
14
- required: ["itemNo", "storeIds"],
16
+ required: ["itemNo"],
15
17
  },
16
18
  async handler(rawInput) {
17
19
  const input = CompareStoreStockInput.parse(rawInput);
18
- const results = await Promise.all(input.storeIds.map((storeId) => getStoreStock(input.itemNo, storeId, input.countryCode).then((data) => {
19
- const label = storeLabel(storeId);
20
- return {
21
- storeId,
22
- ...(label ? { storeLabel: label } : {}),
23
- ...projectStock(data),
24
- };
25
- })));
20
+ const targetIds = input.storeIds
21
+ ?? storeIdsByCountry(input.countryCode);
22
+ const apiCountry = input.countryCode?.toLowerCase() ?? "us";
23
+ const results = await pMap(targetIds, (storeId) => getStoreStock(input.itemNo, storeId, apiCountry).then((data) => ({
24
+ storeId,
25
+ storeLabel: storeLabel(storeId) ?? storeId,
26
+ ...projectStock(data),
27
+ })), 10);
28
+ if (input.sortBy === "quantity") {
29
+ results.sort((a, b) => {
30
+ const diff = (b.quantity ?? -Infinity) - (a.quantity ?? -Infinity);
31
+ return diff !== 0 ? diff : a.storeId.localeCompare(b.storeId);
32
+ });
33
+ }
34
+ else if (input.sortBy === "storeId") {
35
+ results.sort((a, b) => a.storeId.localeCompare(b.storeId));
36
+ }
26
37
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
27
38
  },
28
39
  };
@@ -1,43 +1,46 @@
1
1
  import { FindBestStoreInput } from "../schemas/index.js";
2
2
  import { getStoreStock, projectStock } from "../services/ikea.js";
3
- import { STORE_LABELS, storeLabel } from "../data/stores.js";
3
+ import { STORE_LABELS, storeLabel, storeIdsByCountry } from "../data/stores.js";
4
+ import { pMap } from "../utils/http.js";
4
5
  export const findBestStoreForItemTool = {
5
6
  name: "find_best_store_for_item",
6
- description: "Find stores with the highest in-stock quantity for an item. Returns up to maxResults stores sorted by quantity descending.",
7
+ description: "Find stores with the highest in-stock quantity for an item. Returns up to maxResults stores sorted by quantity descending. Optionally filter by countryCode ('US' or 'CA'). Explicit storeIds take precedence over countryCode.",
7
8
  inputSchema: {
8
9
  type: "object",
9
10
  properties: {
10
11
  itemNo: { type: "string" },
11
12
  storeIds: { type: "array", items: { type: "string" } },
12
13
  maxResults: { type: "number", default: 3 },
14
+ countryCode: { type: "string", enum: ["US", "CA"] },
15
+ minQuantity: { type: "number" },
13
16
  },
14
17
  required: ["itemNo"],
15
18
  },
16
19
  async handler(rawInput) {
17
20
  const input = FindBestStoreInput.parse(rawInput);
18
- const targetIds = input.storeIds ?? Object.keys(STORE_LABELS);
19
- const results = await Promise.all(targetIds.map(async (storeId) => {
21
+ const targetIds = input.storeIds
22
+ ?? (input.countryCode ? storeIdsByCountry(input.countryCode) : Object.keys(STORE_LABELS));
23
+ const results = await pMap(targetIds, async (storeId) => {
20
24
  const data = await getStoreStock(input.itemNo, storeId, "us");
21
25
  const stock = projectStock(data);
22
26
  return { storeId, stock };
23
- }));
27
+ }, 10);
24
28
  const ranked = results
25
- .filter((r) => r.stock.availableForCashCarry && r.stock.quantity !== null)
29
+ .filter((r) => r.stock.availableForCashCarry &&
30
+ r.stock.quantity !== null &&
31
+ (input.minQuantity === undefined || r.stock.quantity >= input.minQuantity))
26
32
  .sort((a, b) => {
27
33
  const diff = (b.stock.quantity ?? 0) - (a.stock.quantity ?? 0);
28
34
  return diff !== 0 ? diff : a.storeId.localeCompare(b.storeId);
29
35
  })
30
36
  .slice(0, input.maxResults)
31
- .map(({ storeId, stock }) => {
32
- const label = storeLabel(storeId);
33
- return {
34
- storeId,
35
- ...(label ? { storeLabel: label } : {}),
36
- availableForCashCarry: stock.availableForCashCarry,
37
- quantity: stock.quantity,
38
- messageType: stock.messageType,
39
- };
40
- });
37
+ .map(({ storeId, stock }) => ({
38
+ storeId,
39
+ storeLabel: storeLabel(storeId) ?? storeId,
40
+ availableForCashCarry: stock.availableForCashCarry,
41
+ quantity: stock.quantity,
42
+ messageType: stock.messageType,
43
+ }));
41
44
  return { content: [{ type: "text", text: JSON.stringify(ranked, null, 2) }] };
42
45
  },
43
46
  };
@@ -0,0 +1,30 @@
1
+ import { ListStoresInput } from "../schemas/index.js";
2
+ import { STORE_LABELS, storeLabel, storeIdsByCountry } from "../data/stores.js";
3
+ export const listStoresTool = {
4
+ name: "list_stores",
5
+ description: "List known IKEA store IDs and display names. Use countryCode ('US' or 'CA') to filter by country, or omit to return all stores.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ countryCode: { type: "string", enum: ["US", "CA"] },
10
+ },
11
+ },
12
+ async handler(rawInput) {
13
+ const input = ListStoresInput.parse(rawInput);
14
+ const storeIds = input.countryCode
15
+ ? storeIdsByCountry(input.countryCode)
16
+ : Object.keys(STORE_LABELS);
17
+ // When returning all stores, build a CA set for tagging.
18
+ const caSet = input.countryCode === "CA"
19
+ ? new Set(storeIds)
20
+ : input.countryCode === "US"
21
+ ? new Set()
22
+ : new Set(storeIdsByCountry("CA"));
23
+ const stores = storeIds.map((storeId) => ({
24
+ storeId,
25
+ storeLabel: storeLabel(storeId) ?? storeId,
26
+ countryCode: caSet.has(storeId) ? "CA" : "US",
27
+ }));
28
+ return { content: [{ type: "text", text: JSON.stringify({ stores }, null, 2) }] };
29
+ },
30
+ };
@@ -1,12 +1,52 @@
1
- export async function fetchJson(url, headers) {
2
- const res = await fetch(url, {
3
- headers: {
4
- Accept: "application/json",
5
- ...headers,
6
- },
7
- });
1
+ const RETRY_DELAY_MS = 500;
2
+ function shouldRetry(status) {
3
+ return status === 429 || status >= 500;
4
+ }
5
+ async function attempt(url, headers) {
6
+ const res = await fetch(url, { headers: { Accept: "application/json", ...headers } });
8
7
  if (!res.ok) {
8
+ if (shouldRetry(res.status)) {
9
+ const retryAfter = Number(res.headers.get("Retry-After") ?? 0) * 1000;
10
+ throw Object.assign(new Error(`HTTP ${res.status} ${res.statusText}: ${url}`), {
11
+ retryable: true,
12
+ delay: retryAfter > 0 ? retryAfter : RETRY_DELAY_MS,
13
+ });
14
+ }
9
15
  throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
10
16
  }
11
17
  return res.json();
12
18
  }
19
+ export async function fetchJson(url, headers = {}) {
20
+ try {
21
+ return await attempt(url, headers);
22
+ }
23
+ catch (err) {
24
+ const e = err;
25
+ if (e.retryable) {
26
+ await new Promise((r) => setTimeout(r, e.delay ?? RETRY_DELAY_MS));
27
+ return attempt(url, headers);
28
+ }
29
+ // Network-level failures (TypeError from fetch) also get one retry.
30
+ if (err instanceof TypeError) {
31
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
32
+ return attempt(url, headers);
33
+ }
34
+ throw err;
35
+ }
36
+ }
37
+ /**
38
+ * Runs tasks with at most `limit` concurrent promises, preserving input order.
39
+ */
40
+ export async function pMap(items, fn, limit) {
41
+ const results = new Array(items.length);
42
+ let next = 0;
43
+ async function worker() {
44
+ while (next < items.length) {
45
+ const i = next++;
46
+ results[i] = await fn(items[i]);
47
+ }
48
+ }
49
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
50
+ await Promise.all(workers);
51
+ return results;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikea-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Read-only MCP server for IKEA product search and in-store stock lookup. Supports stdio and Streamable HTTP transports.",
5
5
  "license": "MIT",
6
6
  "type": "module",