ikea-mcp 1.2.0 → 1.3.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
@@ -12,14 +12,14 @@ Read-only MCP server for IKEA product search and in-store stock lookup.
12
12
  | `get_product_details` | Get details for a single product by item number |
13
13
  | `check_store_stock` | Check cash-and-carry stock at one store |
14
14
  | `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 |
15
+ | `compare_store_stock` | Compare stock across explicit stores or a country catalog |
16
+ | `find_best_store_for_item` | Rank stores by in-stock quantity (optionally filter by country) |
17
17
 
18
18
  ## MVP limitations
19
19
  - Uses unofficial public IKEA APIs — no SLA, may break without notice
20
20
  - ~65 confirmed US and Canada stores in catalog (`src/data/stores.ts`); some IDs remain unverified
21
21
  - Cash-and-carry availability only — click-and-collect and home delivery not exposed
22
- - HTTP transport has no auth — do not expose publicly without adding authentication
22
+ - HTTP transport is open by default — set `API_KEY` env var to require `x-api-key` header on `/mcp`
23
23
  - Read-only — no cart, order, or account operations
24
24
 
25
25
  ## Tools
@@ -123,14 +123,24 @@ On error (e.g. item not carried):
123
123
 
124
124
  ### `compare_store_stock`
125
125
 
126
- Compare stock for one item across multiple stores.
126
+ 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
127
 
128
128
  **Input**
129
129
  | param | type | default | required |
130
130
  |---|---|---|---|
131
131
  | `itemNo` | string | — | yes |
132
- | `storeIds` | string[] (min 2) | — | yes |
133
- | `countryCode` | string | `"us"` | no |
132
+ | `storeIds` | string[] (min 2) | — | one of `storeIds`/`countryCode` |
133
+ | `countryCode` | `"US"` \| `"CA"` | — | one of `storeIds`/`countryCode` |
134
+
135
+ `storeIds` takes precedence — if both are provided, `countryCode` only sets the IKEA API locale.
136
+
137
+ **Examples**
138
+ ```json
139
+ { "itemNo": "20522046", "storeIds": ["399", "026", "921"] }
140
+ ```
141
+ ```json
142
+ { "itemNo": "20522046", "countryCode": "CA" }
143
+ ```
134
144
 
135
145
  **Output** — array of the same shape as `check_store_stock` (one entry per store).
136
146
 
@@ -176,6 +186,9 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
176
186
  | `itemNo` | string | — | yes |
177
187
  | `storeIds` | string[] | all known stores | no |
178
188
  | `maxResults` | number | `3` (max 10) | no |
189
+ | `countryCode` | `"US"` \| `"CA"` | — | no |
190
+
191
+ `storeIds` takes precedence. If only `countryCode` is given, searches all catalog stores for that country. If neither is given, searches all ~65 known stores.
179
192
 
180
193
  **Output** — array of matching stores, up to `maxResults`:
181
194
  ```json
@@ -194,6 +207,50 @@ Returns `[]` if no store has the item in stock. "All known stores" means the ~65
194
207
 
195
208
  ---
196
209
 
210
+ ## Example workflows
211
+
212
+ ### 1. Search → inspect → check one store
213
+
214
+ ```
215
+ 1. search_products { "query": "BILLY bookcase" }
216
+ → pick itemNo from results, e.g. "20522046"
217
+
218
+ 2. get_product_details { "itemNo": "20522046" }
219
+ → confirms name, price, dimensions before checking stock
220
+
221
+ 3. check_store_stock { "itemNo": "20522046", "storeId": "399" }
222
+ → { "availableForCashCarry": true, "quantity": 95, "messageType": "HIGH_IN_STOCK" }
223
+ ```
224
+
225
+ ### 2. Shopping list at one store
226
+
227
+ Check whether several items are available in a single trip:
228
+
229
+ ```json
230
+ {
231
+ "tool": "check_multi_item_stock",
232
+ "storeId": "399",
233
+ "itemNos": ["20522046", "40477340", "89268919"]
234
+ }
235
+ ```
236
+
237
+ Returns one entry per item in the same order — items not stocked appear with `availableForCashCarry: false` and a 404 error.
238
+
239
+ ### 3. Best store from a mixed US + Canada subset
240
+
241
+ ```json
242
+ {
243
+ "tool": "find_best_store_for_item",
244
+ "itemNo": "20522046",
245
+ "storeIds": ["399", "039", "216", "149", "026"],
246
+ "maxResults": 3
247
+ }
248
+ ```
249
+
250
+ Returns the top 3 stores by in-stock quantity across the mixed US/Canada subset. Omit `storeIds` to search all ~65 known stores.
251
+
252
+ ---
253
+
197
254
  ## Build and test
198
255
 
199
256
  ```bash
@@ -244,7 +301,11 @@ Endpoints after deploy:
244
301
  - `POST /mcp` — MCP Streamable HTTP (requires `Accept: application/json, text/event-stream`)
245
302
  - `GET /health` — returns `{"status":"ok"}`
246
303
 
247
- > **Security note:** No auth is implemented in this MVP. Do not expose the `/mcp` endpoint publicly without adding authentication. The server is read-only — no cart, order, or account operations are possible.
304
+ > **Security note:** Set `API_KEY` to protect the `/mcp` endpoint. Requests without a matching `x-api-key` header return 401. `/health` is always open. The server is read-only — no cart, order, or account operations are possible.
305
+ >
306
+ > ```bash
307
+ > API_KEY=your-secret node dist/http.js
308
+ > ```
248
309
 
249
310
  ## Connecting a local MCP client (stdio)
250
311
 
@@ -317,6 +378,6 @@ An invalid or unsupported `storeId` returns a 405 error in the `errors` array.
317
378
 
318
379
  - Uses unofficial public IKEA APIs — no SLA, no auth required, may break without notice.
319
380
  - Read-only: no cart, no order, no account operations.
320
- - `compare_store_stock` fires all store requests in parallel — large `storeIds` arrays may hit rate limits.
381
+ - `compare_store_stock` fires all store requests in parallel — large `storeIds` arrays or a full `countryCode` expansion may hit rate limits.
321
382
  - Click-and-collect and home-delivery availability are not exposed (cash-and-carry only).
322
383
  - `size` in `search_products` is capped by IKEA's API (observed max ~24 per page; `total` reflects the full catalogue count).
@@ -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
+ }
package/dist/http.js CHANGED
@@ -3,6 +3,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
3
3
  import { createServer } from "./server.js";
4
4
  const PORT = Number(process.env.PORT ?? 3000);
5
5
  const BODY_LIMIT = 1024 * 1024; // 1 MB
6
+ const API_KEY = process.env.API_KEY ?? "";
6
7
  function readBody(req) {
7
8
  return new Promise((resolve, reject) => {
8
9
  let raw = "";
@@ -40,6 +41,10 @@ const httpServer = createHttpServer(async (req, res) => {
40
41
  sendJson(res, 200, { status: "ok" });
41
42
  return;
42
43
  }
44
+ if (API_KEY && req.headers["x-api-key"] !== API_KEY) {
45
+ sendJson(res, 401, { error: "unauthorized" });
46
+ return;
47
+ }
43
48
  if (method === "POST" && url === "/mcp") {
44
49
  const body = await readBody(req);
45
50
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
@@ -60,4 +65,5 @@ const httpServer = createHttpServer(async (req, res) => {
60
65
  httpServer.listen(PORT, () => {
61
66
  console.error(`ikea-mcp HTTP listening on http://localhost:${PORT}/mcp`);
62
67
  console.error(`health: http://localhost:${PORT}/health`);
68
+ console.error(`auth: ${API_KEY ? "API_KEY required (x-api-key header)" : "open (set API_KEY to enable)"}`);
63
69
  });
@@ -12,13 +12,14 @@ export const CheckStoreStockInput = z.object({
12
12
  });
13
13
  export const CompareStoreStockInput = z.object({
14
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"),
17
- });
15
+ storeIds: z.array(z.string().min(1)).min(2).max(10).optional(),
16
+ countryCode: z.enum(["US", "CA"]).optional(),
17
+ }).refine((d) => d.storeIds !== undefined || d.countryCode !== undefined, { message: "provide storeIds or countryCode" });
18
18
  export const FindBestStoreInput = z.object({
19
19
  itemNo: z.string().min(1),
20
20
  storeIds: z.array(z.string().min(1)).optional(),
21
21
  maxResults: z.number().int().min(1).max(10).default(3),
22
+ countryCode: z.enum(["US", "CA"]).optional(),
22
23
  });
23
24
  export const GetProductDetailsInput = z.object({
24
25
  itemNo: z.string().min(1),
@@ -1,21 +1,24 @@
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
4
  export const compareStoreStockTool = {
5
5
  name: "compare_store_stock",
6
- description: "Compare stock availability for an item across multiple IKEA stores.",
6
+ 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
7
  inputSchema: {
8
8
  type: "object",
9
9
  properties: {
10
10
  itemNo: { type: "string" },
11
11
  storeIds: { type: "array", items: { type: "string" }, minItems: 2 },
12
- countryCode: { type: "string", default: "us" },
12
+ countryCode: { type: "string", enum: ["US", "CA"] },
13
13
  },
14
- required: ["itemNo", "storeIds"],
14
+ required: ["itemNo"],
15
15
  },
16
16
  async handler(rawInput) {
17
17
  const input = CompareStoreStockInput.parse(rawInput);
18
- const results = await Promise.all(input.storeIds.map((storeId) => getStoreStock(input.itemNo, storeId, input.countryCode).then((data) => {
18
+ const targetIds = input.storeIds
19
+ ?? storeIdsByCountry(input.countryCode);
20
+ const apiCountry = input.countryCode?.toLowerCase() ?? "us";
21
+ const results = await Promise.all(targetIds.map((storeId) => getStoreStock(input.itemNo, storeId, apiCountry).then((data) => {
19
22
  const label = storeLabel(storeId);
20
23
  return {
21
24
  storeId,
@@ -1,21 +1,23 @@
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
4
  export const findBestStoreForItemTool = {
5
5
  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.",
6
+ 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
7
  inputSchema: {
8
8
  type: "object",
9
9
  properties: {
10
10
  itemNo: { type: "string" },
11
11
  storeIds: { type: "array", items: { type: "string" } },
12
12
  maxResults: { type: "number", default: 3 },
13
+ countryCode: { type: "string", enum: ["US", "CA"] },
13
14
  },
14
15
  required: ["itemNo"],
15
16
  },
16
17
  async handler(rawInput) {
17
18
  const input = FindBestStoreInput.parse(rawInput);
18
- const targetIds = input.storeIds ?? Object.keys(STORE_LABELS);
19
+ const targetIds = input.storeIds
20
+ ?? (input.countryCode ? storeIdsByCountry(input.countryCode) : Object.keys(STORE_LABELS));
19
21
  const results = await Promise.all(targetIds.map(async (storeId) => {
20
22
  const data = await getStoreStock(input.itemNo, storeId, "us");
21
23
  const stock = projectStock(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikea-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.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",