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 +88 -10
- package/dist/data/stores.js +24 -2
- package/dist/index.js +1 -0
- package/dist/schemas/index.js +9 -0
- package/dist/server.js +3 -1
- package/dist/services/ikea.js +78 -0
- package/dist/tools/check_multi_item_stock.js +30 -0
- package/dist/tools/get_product_details.js +20 -0
- package/package.json +4 -1
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
|
-
- ~
|
|
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 ~
|
|
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
|
-
|
|
152
|
-
#
|
|
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
|
|
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
|
|
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), `"
|
|
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
|
|
package/dist/data/stores.js
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
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
package/dist/schemas/index.js
CHANGED
|
@@ -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
|
-
|
|
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 () => ({
|
package/dist/services/ikea.js
CHANGED
|
@@ -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.
|
|
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"
|