ikea-mcp 1.0.0 → 1.2.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
@@ -9,13 +9,15 @@ Read-only MCP server for IKEA product search and in-store stock lookup.
9
9
  | Tool | What it does |
10
10
  |---|---|
11
11
  | `search_products` | Search IKEA products by keyword |
12
+ | `get_product_details` | Get details for a single product by item number |
12
13
  | `check_store_stock` | Check cash-and-carry stock at one store |
14
+ | `check_multi_item_stock` | Check stock for multiple items at one store |
13
15
  | `compare_store_stock` | Compare stock across multiple stores |
14
16
  | `find_best_store_for_item` | Rank stores by in-stock quantity |
15
17
 
16
18
  ## MVP limitations
17
19
  - Uses unofficial public IKEA APIs — no SLA, may break without notice
18
- - ~50 confirmed US stores in catalog (`src/data/stores.ts`); some IDs remain unverified
20
+ - ~65 confirmed US and Canada stores in catalog (`src/data/stores.ts`); some IDs remain unverified
19
21
  - Cash-and-carry availability only — click-and-collect and home delivery not exposed
20
22
  - HTTP transport has no auth — do not expose publicly without adding authentication
21
23
  - Read-only — no cart, order, or account operations
@@ -54,6 +56,36 @@ Search IKEA products by keyword.
54
56
 
55
57
  ---
56
58
 
59
+ ### `get_product_details`
60
+
61
+ Get details for a single IKEA product by item number.
62
+
63
+ **Input**
64
+ | param | type | default | required |
65
+ |---|---|---|---|
66
+ | `itemNo` | string | — | yes |
67
+ | `countryCode` | string | `"us"` | no |
68
+ | `langCode` | string | `"en"` | no |
69
+
70
+ **Output**
71
+ ```json
72
+ {
73
+ "itemNo": "20522046",
74
+ "name": "BILLY",
75
+ "typeName": "Bookcase",
76
+ "salesPrice": { "amount": 79, "currencyCode": "USD" },
77
+ "pipUrl": "https://www.ikea.com/us/en/p/billy-bookcase-white-20522046/",
78
+ "designText": "white",
79
+ "measureText": "31 1/2x11x79 1/2 \"",
80
+ "ratingValue": 4.6,
81
+ "ratingCount": 2620
82
+ }
83
+ ```
84
+
85
+ > `shortDescription` and `materials` are not available from the underlying API.
86
+
87
+ ---
88
+
57
89
  ### `check_store_stock`
58
90
 
59
91
  Check stock at a single IKEA store.
@@ -104,6 +136,36 @@ Compare stock for one item across multiple stores.
104
136
 
105
137
  ---
106
138
 
139
+ ### `check_multi_item_stock`
140
+
141
+ Check cash-and-carry stock for multiple items at a single store in one call.
142
+
143
+ **Input**
144
+ | param | type | default | required |
145
+ |---|---|---|---|
146
+ | `storeId` | string | — | yes |
147
+ | `itemNos` | string[] (min 1, max 20) | — | yes |
148
+
149
+ **Output** — array of per-item stock entries in the same order as `itemNos`:
150
+
151
+ ```json
152
+ [
153
+ {
154
+ "itemNo": "20522046",
155
+ "storeId": "399",
156
+ "storeLabel": "399 (Burbank, CA)",
157
+ "availableForCashCarry": true,
158
+ "quantity": 104,
159
+ "messageType": "HIGH_IN_STOCK",
160
+ "errors": []
161
+ }
162
+ ]
163
+ ```
164
+
165
+ Items not stocked at that store appear with `availableForCashCarry: false`, `quantity: null`, and a 404 error entry. An invalid `storeId` (405) returns that error on every entry.
166
+
167
+ ---
168
+
107
169
  ### `find_best_store_for_item`
108
170
 
109
171
  Find stores with the highest in-stock quantity for an item. Queries stores in parallel, excludes invalid stores (405), out-of-stock stores (404), and stores with unknown quantity. Results sorted by quantity descending; ties broken by `storeId` lexicographically.
@@ -128,7 +190,7 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
128
190
  ]
129
191
  ```
130
192
 
131
- Returns `[]` if no store has the item in stock. "All known stores" means the ~50 entries in `src/data/stores.ts`.
193
+ Returns `[]` if no store has the item in stock. "All known stores" means the ~65 US and Canada entries in `src/data/stores.ts`.
132
194
 
133
195
  ---
134
196
 
@@ -148,9 +210,9 @@ node smoke.mjs # end-to-end stdio smoke test
148
210
 
149
211
  **stdio** (default — for Claude Desktop / MCP CLI):
150
212
  ```bash
151
- node dist/index.js
152
- # or during dev:
153
- npm run dev
213
+ npx ikea-mcp # after npm install (uses bin entry)
214
+ node dist/index.js # after local build
215
+ npm run dev # dev (tsx, no build needed)
154
216
  ```
155
217
 
156
218
  **Streamable HTTP** (for remote / network clients):
@@ -184,7 +246,23 @@ Endpoints after deploy:
184
246
 
185
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.
186
248
 
187
- ## Connecting a remote MCP client
249
+ ## Connecting a local MCP client (stdio)
250
+
251
+ **Claude Desktop** (`claude_desktop_config.json`):
252
+ ```json
253
+ {
254
+ "mcpServers": {
255
+ "ikea-mcp": {
256
+ "command": "npx",
257
+ "args": ["-y", "ikea-mcp"]
258
+ }
259
+ }
260
+ }
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Connecting a remote MCP client (HTTP)
188
266
 
189
267
  Point your MCP client at `https://<your-host>/mcp`.
190
268
 
@@ -224,12 +302,12 @@ The `Accept: application/json, text/event-stream` header is required by the MCP
224
302
 
225
303
  ## Store IDs
226
304
 
227
- Store metadata (ID → city label) lives in `src/data/stores.ts`. ~50 US stores are confirmed from `ikea.com/us/en/stores/<slug>/` pages.
305
+ Store metadata (ID → city label) lives in `src/data/stores.ts`. ~50 US stores confirmed from `ikea.com/us/en/stores/` pages; 15 Canada stores confirmed from `ikea.com/ca/en/stores/` pages (all probed against the stock API).
228
306
 
229
307
  Confirmed compatible `storeId` formats:
230
- - Standard 3-digit: `"399"` (Burbank, CA)
231
- - Leading-zero 3-digit: `"026"` (Canton, MI), `"042"` (Tampa, FL)
232
- - 4-digit: `"921"` (Brooklyn, NY), `"1129"` (Syracuse, NY)
308
+ - Standard 3-digit: `"399"` (Burbank, CA, US), `"216"` (Calgary, AB, CA)
309
+ - Leading-zero 3-digit: `"026"` (Canton, MI, US), `"039"` (Montreal, QC, CA)
310
+ - 4-digit: `"921"` (Brooklyn, NY, US), `"1129"` (Syracuse, NY, US)
233
311
 
234
312
  Some stores remain unconfirmed (no store page ID found or not yet probed). A few valid-looking IDs from the stock API have no known city mapping — see TODO comments in `stores.ts`.
235
313
 
@@ -1,10 +1,14 @@
1
1
  // US store labels confirmed from ikea.com/us/en/stores/<slug>/ pages (store_id= survey param).
2
+ // CA store labels confirmed from ikea.com/ca/en/stores/<slug>/ pages (storeNo= embedded JSON).
2
3
  // IDs match the sto/{storeId} path used by the IKEA stock API.
3
4
  // 4-digit IDs (921, 1099, 1129) are newer-format stores — confirmed working with stock API.
4
5
  // 3136 (San Francisco) returns 405 from stock API — excluded.
5
- // TODO: huntsville, arcadia, mcallen-pharr, san-marcos — IDs not found on store pages.
6
- // TODO: 313, 372, 414, 448, 529, 559, 612, 634, 652, 661, 663, 692, 693 — valid API IDs, city unknown (possibly CA/other).
6
+ // 698 (CA — unknown city) returns 422 from stock API — excluded.
7
+ // 659 (Toronto Downtown) returns 200 but null quantity — included conservatively.
8
+ // TODO: huntsville, arcadia, mcallen-pharr, san-marcos (US) — IDs not found on store pages.
9
+ // TODO: 448, 612, 634, 652, 661, 663, 692, 693 — valid API IDs, city/country unknown.
7
10
  export const STORE_LABELS = {
11
+ // ── United States ────────────────────────────────────────────────────────────
8
12
  "026": "026 (Canton, MI)",
9
13
  "027": "027 (Round Rock, TX)",
10
14
  "028": "028 (Portland, OR)",
@@ -57,6 +61,24 @@ export const STORE_LABELS = {
57
61
  "921": "921 (Brooklyn, NY)",
58
62
  "1099": "1099 (University Park, TX)",
59
63
  "1129": "1129 (Syracuse, NY)",
64
+ // ── Canada ───────────────────────────────────────────────────────────────────
65
+ // Confirmed from ikea.com/ca/en/stores/<slug>/ pages (storeNo= embedded JSON).
66
+ // All probed against stock API with item 20522046 — all return 200.
67
+ "003": "003 (Richmond, BC, CA)",
68
+ "004": "004 (Ottawa, ON, CA)",
69
+ "039": "039 (Montreal, QC, CA)",
70
+ "040": "040 (Burlington, ON, CA)",
71
+ "149": "149 (North York, ON, CA)",
72
+ "216": "216 (Calgary, AB, CA)",
73
+ "249": "249 (Winnipeg, MB, CA)",
74
+ "256": "256 (Etobicoke, ON, CA)",
75
+ "313": "313 (Coquitlam, BC, CA)",
76
+ "349": "349 (Edmonton, AB, CA)",
77
+ "372": "372 (Vaughan, ON, CA)",
78
+ "414": "414 (Boucherville, QC, CA)",
79
+ "529": "529 (Halifax, NS, CA)",
80
+ "559": "559 (Quebec City, QC, CA)",
81
+ "659": "659 (Toronto Downtown, ON, CA)",
60
82
  };
61
83
  export function storeLabel(storeId) {
62
84
  return STORE_LABELS[storeId];
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
3
  import { createServer } from "./server.js";
3
4
  const transport = new StdioServerTransport();
@@ -20,3 +20,12 @@ export const FindBestStoreInput = z.object({
20
20
  storeIds: z.array(z.string().min(1)).optional(),
21
21
  maxResults: z.number().int().min(1).max(10).default(3),
22
22
  });
23
+ export const GetProductDetailsInput = z.object({
24
+ itemNo: z.string().min(1),
25
+ countryCode: z.string().length(2).default("us"),
26
+ langCode: z.string().length(2).default("en"),
27
+ });
28
+ export const CheckMultiItemStockInput = z.object({
29
+ storeId: z.string().min(1),
30
+ itemNos: z.array(z.string().min(1)).min(1).max(20),
31
+ });
package/dist/server.js CHANGED
@@ -4,7 +4,9 @@ import { searchProductsTool } from "./tools/search_products.js";
4
4
  import { checkStoreStockTool } from "./tools/check_store_stock.js";
5
5
  import { compareStoreStockTool } from "./tools/compare_store_stock.js";
6
6
  import { findBestStoreForItemTool } from "./tools/find_best_store_for_item.js";
7
- const tools = [searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool];
7
+ import { getProductDetailsTool } from "./tools/get_product_details.js";
8
+ import { checkMultiItemStockTool } from "./tools/check_multi_item_stock.js";
9
+ const tools = [searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool];
8
10
  export function createServer() {
9
11
  const server = new Server({ name: "ikea-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
10
12
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -19,6 +19,68 @@ export function projectStock(result) {
19
19
  errors: annotateStockErrors(result.errors) ?? [],
20
20
  };
21
21
  }
22
+ export function projectMultiItemStock(result, itemNos) {
23
+ // Build lookup maps
24
+ const byItemNo = new Map();
25
+ for (const a of result.availabilities ?? []) {
26
+ byItemNo.set(a.itemKey.itemNo, a);
27
+ }
28
+ const errorByItemNo = new Map();
29
+ for (const e of result.errors ?? []) {
30
+ if (e.details?.itemNo)
31
+ errorByItemNo.set(e.details.itemNo, e);
32
+ }
33
+ // 405 = store doesn't exist — applies to all items
34
+ const storeError = result.errors?.find((e) => e.code === 405);
35
+ return itemNos.map((itemNo) => {
36
+ if (storeError) {
37
+ return {
38
+ itemNo,
39
+ availableForCashCarry: false,
40
+ quantity: null,
41
+ messageType: null,
42
+ errors: [{ ...storeError, meaning: "store ID does not exist" }],
43
+ };
44
+ }
45
+ const a = byItemNo.get(itemNo);
46
+ if (a) {
47
+ return {
48
+ itemNo,
49
+ availableForCashCarry: a.availableForCashCarry,
50
+ quantity: a.buyingOption.cashCarry.availability.quantity,
51
+ messageType: a.buyingOption.cashCarry.availability.probability.thisDay.messageType,
52
+ errors: [],
53
+ };
54
+ }
55
+ const itemErr = errorByItemNo.get(itemNo);
56
+ const annotatedNotStocked = [
57
+ itemErr
58
+ ? { ...itemErr, meaning: itemErr.code === 404 ? "item not stocked at this store" : "unknown" }
59
+ : { code: 404, message: "Not found", meaning: "item not stocked at this store" },
60
+ ];
61
+ return {
62
+ itemNo,
63
+ availableForCashCarry: false,
64
+ quantity: null,
65
+ messageType: null,
66
+ errors: annotatedNotStocked,
67
+ };
68
+ });
69
+ }
70
+ // ── Product projection ────────────────────────────────────────────────────────
71
+ export function projectProduct(p) {
72
+ return {
73
+ itemNo: p.itemNo,
74
+ name: p.name,
75
+ typeName: p.typeName,
76
+ salesPrice: { amount: p.salesPrice.numeral, currencyCode: p.salesPrice.currencyCode },
77
+ pipUrl: p.pipUrl,
78
+ designText: p.validDesignText ?? null,
79
+ measureText: p.itemMeasureReferenceText ?? null,
80
+ ratingValue: p.ratingValue,
81
+ ratingCount: p.ratingCount,
82
+ };
83
+ }
22
84
  // ── Service functions ─────────────────────────────────────────────────────────
23
85
  export async function searchProducts(query, countryCode, langCode, size) {
24
86
  // Correct endpoint: /search-result-page (not /search — returns 404)
@@ -28,6 +90,22 @@ export async function searchProducts(query, countryCode, langCode, size) {
28
90
  "User-Agent": "Mozilla/5.0",
29
91
  });
30
92
  }
93
+ export async function getProductDetails(itemNo, countryCode, langCode) {
94
+ const result = await searchProducts(itemNo, countryCode, langCode, 5);
95
+ const items = result.searchResultPage.products.main.items;
96
+ const match = items.find((i) => i.product?.itemNo === itemNo || i.product?.itemNoGlobal === itemNo);
97
+ if (!match) {
98
+ throw new Error(`item not found: ${itemNo}`);
99
+ }
100
+ return projectProduct(match.product);
101
+ }
102
+ export async function getMultiItemStock(itemNos, storeId) {
103
+ const url = `${STOCK_BASE}/sto/${storeId}` +
104
+ `?itemNos=${itemNos.map(encodeURIComponent).join(",")}&expand=StoresList,Restocks`;
105
+ return fetchJson(url, {
106
+ "X-Client-Id": STOCK_CLIENT_ID,
107
+ });
108
+ }
31
109
  export async function getStoreStock(itemNo, storeId,
32
110
  // countryCode unused: endpoint path is /sto/{storeId}, country is implicit in storeId
33
111
  _countryCode) {
@@ -0,0 +1,30 @@
1
+ import { CheckMultiItemStockInput } from "../schemas/index.js";
2
+ import { getMultiItemStock, projectMultiItemStock } from "../services/ikea.js";
3
+ import { storeLabel } from "../data/stores.js";
4
+ export const checkMultiItemStockTool = {
5
+ name: "check_multi_item_stock",
6
+ description: "Check cash-and-carry stock for multiple items at a single IKEA store.",
7
+ inputSchema: {
8
+ type: "object",
9
+ properties: {
10
+ storeId: { type: "string" },
11
+ itemNos: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 20 },
12
+ },
13
+ required: ["storeId", "itemNos"],
14
+ },
15
+ async handler(rawInput) {
16
+ const input = CheckMultiItemStockInput.parse(rawInput);
17
+ const result = await getMultiItemStock(input.itemNos, input.storeId);
18
+ const label = storeLabel(input.storeId);
19
+ const rows = projectMultiItemStock(result, input.itemNos).map((row) => ({
20
+ itemNo: row.itemNo,
21
+ storeId: input.storeId,
22
+ ...(label ? { storeLabel: label } : {}),
23
+ availableForCashCarry: row.availableForCashCarry,
24
+ quantity: row.quantity,
25
+ messageType: row.messageType,
26
+ errors: row.errors,
27
+ }));
28
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
29
+ },
30
+ };
@@ -0,0 +1,20 @@
1
+ import { GetProductDetailsInput } from "../schemas/index.js";
2
+ import { getProductDetails } from "../services/ikea.js";
3
+ export const getProductDetailsTool = {
4
+ name: "get_product_details",
5
+ description: "Get details for a single IKEA product by item number.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ itemNo: { type: "string" },
10
+ countryCode: { type: "string", default: "us" },
11
+ langCode: { type: "string", default: "en" },
12
+ },
13
+ required: ["itemNo"],
14
+ },
15
+ async handler(rawInput) {
16
+ const input = GetProductDetailsInput.parse(rawInput);
17
+ const output = await getProductDetails(input.itemNo, input.countryCode, input.langCode);
18
+ return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
19
+ },
20
+ };
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "ikea-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",
7
7
  "main": "dist/index.js",
8
+ "bin": {
9
+ "ikea-mcp": "dist/index.js"
10
+ },
8
11
  "files": [
9
12
  "dist/",
10
13
  "Procfile"