ikea-mcp 1.0.0 → 1.1.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 +82 -4
- 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,7 +9,9 @@ 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
|
|
|
@@ -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.
|
|
@@ -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
|
|
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.1.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"
|