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
|
|
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
|
|
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) | — |
|
|
133
|
-
| `countryCode` |
|
|
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:**
|
|
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).
|
package/dist/data/stores.js
CHANGED
|
@@ -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
|
});
|
package/dist/schemas/index.js
CHANGED
|
@@ -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.
|
|
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",
|
|
12
|
+
countryCode: { type: "string", enum: ["US", "CA"] },
|
|
13
13
|
},
|
|
14
|
-
required: ["itemNo"
|
|
14
|
+
required: ["itemNo"],
|
|
15
15
|
},
|
|
16
16
|
async handler(rawInput) {
|
|
17
17
|
const input = CompareStoreStockInput.parse(rawInput);
|
|
18
|
-
const
|
|
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
|
|
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