ikea-mcp 1.0.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/LICENSE +21 -0
- package/Procfile +1 -0
- package/README.md +244 -0
- package/dist/data/stores.js +63 -0
- package/dist/http.js +63 -0
- package/dist/index.js +4 -0
- package/dist/schemas/index.js +22 -0
- package/dist/server.js +25 -0
- package/dist/services/ikea.js +40 -0
- package/dist/tools/check_store_stock.js +27 -0
- package/dist/tools/compare_store_stock.js +28 -0
- package/dist/tools/find_best_store_for_item.js +43 -0
- package/dist/tools/search_products.js +34 -0
- package/dist/utils/http.js +12 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 taejin5314
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
web: node dist/http.js
|
package/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# ikea-mcp
|
|
2
|
+
|
|
3
|
+
Read-only MCP server for IKEA product search and in-store stock lookup.
|
|
4
|
+
|
|
5
|
+
**Transports:** stdio (Claude Desktop / MCP CLI) · Streamable HTTP (remote clients)
|
|
6
|
+
**License:** MIT · **No auth required to run locally**
|
|
7
|
+
|
|
8
|
+
## Capabilities
|
|
9
|
+
| Tool | What it does |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `search_products` | Search IKEA products by keyword |
|
|
12
|
+
| `check_store_stock` | Check cash-and-carry stock at one store |
|
|
13
|
+
| `compare_store_stock` | Compare stock across multiple stores |
|
|
14
|
+
| `find_best_store_for_item` | Rank stores by in-stock quantity |
|
|
15
|
+
|
|
16
|
+
## MVP limitations
|
|
17
|
+
- 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
|
|
19
|
+
- Cash-and-carry availability only — click-and-collect and home delivery not exposed
|
|
20
|
+
- HTTP transport has no auth — do not expose publicly without adding authentication
|
|
21
|
+
- Read-only — no cart, order, or account operations
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
### `search_products`
|
|
26
|
+
|
|
27
|
+
Search IKEA products by keyword.
|
|
28
|
+
|
|
29
|
+
**Input**
|
|
30
|
+
| param | type | default | required |
|
|
31
|
+
|---|---|---|---|
|
|
32
|
+
| `query` | string | — | yes |
|
|
33
|
+
| `countryCode` | string | `"us"` | no |
|
|
34
|
+
| `langCode` | string | `"en"` | no |
|
|
35
|
+
| `size` | number | `10` | no |
|
|
36
|
+
|
|
37
|
+
**Output**
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"total": 97,
|
|
41
|
+
"items": [
|
|
42
|
+
{
|
|
43
|
+
"itemNo": "20522046",
|
|
44
|
+
"name": "BILLY",
|
|
45
|
+
"typeName": "Bookcase",
|
|
46
|
+
"salesPrice": { "amount": 69.99, "currencyCode": "USD" },
|
|
47
|
+
"pipUrl": "https://www.ikea.com/us/en/p/...",
|
|
48
|
+
"ratingValue": 4.8,
|
|
49
|
+
"ratingCount": 1234
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
### `check_store_stock`
|
|
58
|
+
|
|
59
|
+
Check stock at a single IKEA store.
|
|
60
|
+
|
|
61
|
+
**Input**
|
|
62
|
+
| param | type | default | required |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| `itemNo` | string | — | yes |
|
|
65
|
+
| `storeId` | string | — | yes |
|
|
66
|
+
| `countryCode` | string | `"us"` | no |
|
|
67
|
+
|
|
68
|
+
**Output**
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"storeId": "399",
|
|
72
|
+
"availableForCashCarry": true,
|
|
73
|
+
"quantity": 110,
|
|
74
|
+
"messageType": "HIGH_IN_STOCK",
|
|
75
|
+
"errors": null
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On error (e.g. item not carried):
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"storeId": "026",
|
|
83
|
+
"availableForCashCarry": false,
|
|
84
|
+
"quantity": null,
|
|
85
|
+
"messageType": null,
|
|
86
|
+
"errors": [{ "code": 404, "message": "Not found", "meaning": "item not stocked at this store" }]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### `compare_store_stock`
|
|
93
|
+
|
|
94
|
+
Compare stock for one item across multiple stores.
|
|
95
|
+
|
|
96
|
+
**Input**
|
|
97
|
+
| param | type | default | required |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| `itemNo` | string | — | yes |
|
|
100
|
+
| `storeIds` | string[] (min 2) | — | yes |
|
|
101
|
+
| `countryCode` | string | `"us"` | no |
|
|
102
|
+
|
|
103
|
+
**Output** — array of the same shape as `check_store_stock` (one entry per store).
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### `find_best_store_for_item`
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
**Input**
|
|
112
|
+
| param | type | default | required |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `itemNo` | string | — | yes |
|
|
115
|
+
| `storeIds` | string[] | all known stores | no |
|
|
116
|
+
| `maxResults` | number | `3` (max 10) | no |
|
|
117
|
+
|
|
118
|
+
**Output** — array of matching stores, up to `maxResults`:
|
|
119
|
+
```json
|
|
120
|
+
[
|
|
121
|
+
{
|
|
122
|
+
"storeId": "399",
|
|
123
|
+
"storeLabel": "399 (Burbank, CA)",
|
|
124
|
+
"availableForCashCarry": true,
|
|
125
|
+
"quantity": 104,
|
|
126
|
+
"messageType": "HIGH_IN_STOCK"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Returns `[]` if no store has the item in stock. "All known stores" means the ~50 entries in `src/data/stores.ts`.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Build and test
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm run build # tsc → dist/
|
|
140
|
+
npm run typecheck # type-check without emit
|
|
141
|
+
npm test # unit tests
|
|
142
|
+
node smoke.mjs # end-to-end stdio smoke test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`smoke.mjs` exercises all 4 tools against the live IKEA API and prints pass/fail lines to stdout.
|
|
146
|
+
|
|
147
|
+
## Transports
|
|
148
|
+
|
|
149
|
+
**stdio** (default — for Claude Desktop / MCP CLI):
|
|
150
|
+
```bash
|
|
151
|
+
node dist/index.js
|
|
152
|
+
# or during dev:
|
|
153
|
+
npm run dev
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Streamable HTTP** (for remote / network clients):
|
|
157
|
+
```bash
|
|
158
|
+
node dist/http.js # listens on http://localhost:3000/mcp
|
|
159
|
+
PORT=8080 node dist/http.js
|
|
160
|
+
# or during dev:
|
|
161
|
+
npm run dev:http
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Requests must include `Accept: application/json, text/event-stream`. Stateless — no session management.
|
|
165
|
+
|
|
166
|
+
## Deploy (HTTP transport)
|
|
167
|
+
|
|
168
|
+
Tested target: **Railway** (also works on Render, Heroku, or any Procfile-aware host).
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 1. build
|
|
172
|
+
npm install && npm run build
|
|
173
|
+
|
|
174
|
+
# 2. run (Procfile: web: node dist/http.js)
|
|
175
|
+
# PORT is set automatically by the host
|
|
176
|
+
node dist/http.js
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `Procfile` in the repo root declares `web: node dist/http.js`. `PORT` is read from the environment (default `3000`). No other env vars required.
|
|
180
|
+
|
|
181
|
+
Endpoints after deploy:
|
|
182
|
+
- `POST /mcp` — MCP Streamable HTTP (requires `Accept: application/json, text/event-stream`)
|
|
183
|
+
- `GET /health` — returns `{"status":"ok"}`
|
|
184
|
+
|
|
185
|
+
> **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
|
+
|
|
187
|
+
## Connecting a remote MCP client
|
|
188
|
+
|
|
189
|
+
Point your MCP client at `https://<your-host>/mcp`.
|
|
190
|
+
|
|
191
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
192
|
+
```json
|
|
193
|
+
{
|
|
194
|
+
"mcpServers": {
|
|
195
|
+
"ikea-mcp": {
|
|
196
|
+
"type": "http",
|
|
197
|
+
"url": "https://<your-host>/mcp"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**`.mcp.json`** (project-local, Claude Code):
|
|
204
|
+
```json
|
|
205
|
+
{
|
|
206
|
+
"mcpServers": {
|
|
207
|
+
"ikea-mcp": {
|
|
208
|
+
"type": "http",
|
|
209
|
+
"url": "https://<your-host>/mcp"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Manual / curl** (for debugging):
|
|
216
|
+
```bash
|
|
217
|
+
curl -X POST https://<your-host>/mcp \
|
|
218
|
+
-H "Content-Type: application/json" \
|
|
219
|
+
-H "Accept: application/json, text/event-stream" \
|
|
220
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The `Accept: application/json, text/event-stream` header is required by the MCP SDK — requests without it will be rejected with a `-32000` error.
|
|
224
|
+
|
|
225
|
+
## Store IDs
|
|
226
|
+
|
|
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.
|
|
228
|
+
|
|
229
|
+
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)
|
|
233
|
+
|
|
234
|
+
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
|
+
|
|
236
|
+
An invalid or unsupported `storeId` returns a 405 error in the `errors` array.
|
|
237
|
+
|
|
238
|
+
## Limitations
|
|
239
|
+
|
|
240
|
+
- Uses unofficial public IKEA APIs — no SLA, no auth required, may break without notice.
|
|
241
|
+
- Read-only: no cart, no order, no account operations.
|
|
242
|
+
- `compare_store_stock` fires all store requests in parallel — large `storeIds` arrays may hit rate limits.
|
|
243
|
+
- Click-and-collect and home-delivery availability are not exposed (cash-and-carry only).
|
|
244
|
+
- `size` in `search_products` is capped by IKEA's API (observed max ~24 per page; `total` reflects the full catalogue count).
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// US store labels confirmed from ikea.com/us/en/stores/<slug>/ pages (store_id= survey param).
|
|
2
|
+
// IDs match the sto/{storeId} path used by the IKEA stock API.
|
|
3
|
+
// 4-digit IDs (921, 1099, 1129) are newer-format stores — confirmed working with stock API.
|
|
4
|
+
// 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).
|
|
7
|
+
export const STORE_LABELS = {
|
|
8
|
+
"026": "026 (Canton, MI)",
|
|
9
|
+
"027": "027 (Round Rock, TX)",
|
|
10
|
+
"028": "028 (Portland, OR)",
|
|
11
|
+
"042": "042 (Tampa, FL)",
|
|
12
|
+
"064": "064 (Centennial, CO)",
|
|
13
|
+
"067": "067 (Charlotte, NC)",
|
|
14
|
+
"103": "103 (Draper, UT)",
|
|
15
|
+
"145": "145 (Orlando, FL)",
|
|
16
|
+
"152": "152 (Baltimore, MD)",
|
|
17
|
+
"153": "153 (Pittsburgh, PA)",
|
|
18
|
+
"154": "154 (Elizabeth, NJ)",
|
|
19
|
+
"156": "156 (Long Island, NY)",
|
|
20
|
+
"157": "157 (West Sacramento, CA)",
|
|
21
|
+
"158": "158 (Stoughton, MA)",
|
|
22
|
+
"162": "162 (Carson, CA)",
|
|
23
|
+
"165": "165 (Emeryville, CA)",
|
|
24
|
+
"166": "166 (San Diego, CA)",
|
|
25
|
+
"167": "167 (Costa Mesa, CA)",
|
|
26
|
+
"168": "168 (Woodbridge, VA)",
|
|
27
|
+
"170": "170 (Bolingbrook, IL)",
|
|
28
|
+
"175": "175 (West Chester, OH)",
|
|
29
|
+
"183": "183 (Frisco, TX)",
|
|
30
|
+
"207": "207 (Sunrise, FL)",
|
|
31
|
+
"209": "209 (Tempe, AZ)",
|
|
32
|
+
"210": "210 (Schaumburg, IL)",
|
|
33
|
+
"211": "211 (Conshohocken, PA)",
|
|
34
|
+
"212": "212 (Minneapolis, MN)",
|
|
35
|
+
"213": "213 (New Haven, CT)",
|
|
36
|
+
"215": "215 (South Philadelphia, PA)",
|
|
37
|
+
"257": "257 (Atlanta, GA)",
|
|
38
|
+
"327": "327 (Miami, FL)",
|
|
39
|
+
"347": "347 (East Palo Alto, CA)",
|
|
40
|
+
"374": "374 (Merriam, KS)",
|
|
41
|
+
"379": "379 (Houston, TX)",
|
|
42
|
+
"399": "399 (Burbank, CA)",
|
|
43
|
+
"409": "409 (Paramus, NJ)",
|
|
44
|
+
"410": "410 (St. Louis, MO)",
|
|
45
|
+
"411": "411 (College Park, MD)",
|
|
46
|
+
"413": "413 (Covina, CA)",
|
|
47
|
+
"462": "462 (Las Vegas, NV)",
|
|
48
|
+
"488": "488 (Renton, WA)",
|
|
49
|
+
"508": "508 (Memphis, TN)",
|
|
50
|
+
"511": "511 (Columbus, OH)",
|
|
51
|
+
"535": "535 (Grand Prairie, TX)",
|
|
52
|
+
"536": "536 (Fishers, IN)",
|
|
53
|
+
"537": "537 (Jacksonville, FL)",
|
|
54
|
+
"560": "560 (Oak Creek, WI)",
|
|
55
|
+
"569": "569 (Norfolk, VA)",
|
|
56
|
+
"570": "570 (Live Oak, TX)",
|
|
57
|
+
"921": "921 (Brooklyn, NY)",
|
|
58
|
+
"1099": "1099 (University Park, TX)",
|
|
59
|
+
"1129": "1129 (Syracuse, NY)",
|
|
60
|
+
};
|
|
61
|
+
export function storeLabel(storeId) {
|
|
62
|
+
return STORE_LABELS[storeId];
|
|
63
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import { createServer } from "./server.js";
|
|
4
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
5
|
+
const BODY_LIMIT = 1024 * 1024; // 1 MB
|
|
6
|
+
function readBody(req) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let raw = "";
|
|
9
|
+
let size = 0;
|
|
10
|
+
req.on("data", (chunk) => {
|
|
11
|
+
size += chunk.length;
|
|
12
|
+
if (size > BODY_LIMIT) {
|
|
13
|
+
reject(new Error("request body too large"));
|
|
14
|
+
req.destroy();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
raw += chunk.toString();
|
|
18
|
+
});
|
|
19
|
+
req.on("end", () => {
|
|
20
|
+
try {
|
|
21
|
+
resolve(raw ? JSON.parse(raw) : undefined);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
reject(new Error("invalid JSON body"));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
req.on("error", reject);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function sendJson(res, status, body) {
|
|
31
|
+
const payload = JSON.stringify(body);
|
|
32
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(payload);
|
|
33
|
+
}
|
|
34
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
35
|
+
const method = req.method ?? "";
|
|
36
|
+
const url = req.url ?? "";
|
|
37
|
+
console.error(`${new Date().toISOString()} ${method} ${url}`);
|
|
38
|
+
try {
|
|
39
|
+
if (url === "/health") {
|
|
40
|
+
sendJson(res, 200, { status: "ok" });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (method === "POST" && url === "/mcp") {
|
|
44
|
+
const body = await readBody(req);
|
|
45
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
46
|
+
await createServer().connect(transport);
|
|
47
|
+
await transport.handleRequest(req, res, body);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
res.writeHead(404).end("Not found");
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
const message = err instanceof Error ? err.message : "internal error";
|
|
54
|
+
console.error(`ERROR ${method} ${url}:`, message);
|
|
55
|
+
if (!res.headersSent) {
|
|
56
|
+
sendJson(res, 500, { error: message });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
httpServer.listen(PORT, () => {
|
|
61
|
+
console.error(`ikea-mcp HTTP listening on http://localhost:${PORT}/mcp`);
|
|
62
|
+
console.error(`health: http://localhost:${PORT}/health`);
|
|
63
|
+
});
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const SearchProductsInput = z.object({
|
|
3
|
+
query: z.string().min(1),
|
|
4
|
+
countryCode: z.string().length(2).default("us"),
|
|
5
|
+
langCode: z.string().length(2).default("en"),
|
|
6
|
+
size: z.number().int().min(1).max(50).default(10),
|
|
7
|
+
});
|
|
8
|
+
export const CheckStoreStockInput = z.object({
|
|
9
|
+
itemNo: z.string().min(1),
|
|
10
|
+
storeId: z.string().min(1),
|
|
11
|
+
countryCode: z.string().length(2).default("us"),
|
|
12
|
+
});
|
|
13
|
+
export const CompareStoreStockInput = z.object({
|
|
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
|
+
});
|
|
18
|
+
export const FindBestStoreInput = z.object({
|
|
19
|
+
itemNo: z.string().min(1),
|
|
20
|
+
storeIds: z.array(z.string().min(1)).optional(),
|
|
21
|
+
maxResults: z.number().int().min(1).max(10).default(3),
|
|
22
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { searchProductsTool } from "./tools/search_products.js";
|
|
4
|
+
import { checkStoreStockTool } from "./tools/check_store_stock.js";
|
|
5
|
+
import { compareStoreStockTool } from "./tools/compare_store_stock.js";
|
|
6
|
+
import { findBestStoreForItemTool } from "./tools/find_best_store_for_item.js";
|
|
7
|
+
const tools = [searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool];
|
|
8
|
+
export function createServer() {
|
|
9
|
+
const server = new Server({ name: "ikea-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
|
|
10
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
11
|
+
tools: tools.map(({ name, description, inputSchema }) => ({
|
|
12
|
+
name,
|
|
13
|
+
description,
|
|
14
|
+
inputSchema,
|
|
15
|
+
})),
|
|
16
|
+
}));
|
|
17
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
18
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
19
|
+
if (!tool) {
|
|
20
|
+
throw new Error(`Unknown tool: ${req.params.name}`);
|
|
21
|
+
}
|
|
22
|
+
return tool.handler(req.params.arguments);
|
|
23
|
+
});
|
|
24
|
+
return server;
|
|
25
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fetchJson } from "../utils/http.js";
|
|
2
|
+
const SEARCH_BASE = "https://sik.search.blue.cdtapps.com";
|
|
3
|
+
const STOCK_BASE = "https://api.ingka.ikea.com/cia/availabilities";
|
|
4
|
+
// Confirmed public client ID (no secret required)
|
|
5
|
+
const STOCK_CLIENT_ID = "b6c117e5-ae61-4ef5-b4cc-e0b1e37f0631";
|
|
6
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
7
|
+
function annotateStockErrors(errors) {
|
|
8
|
+
return errors?.map((e) => ({
|
|
9
|
+
...e,
|
|
10
|
+
meaning: e.code === 404 ? "item not stocked at this store" : e.code === 405 ? "store ID does not exist" : "unknown",
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
export function projectStock(result) {
|
|
14
|
+
const a = result.availabilities?.[0];
|
|
15
|
+
return {
|
|
16
|
+
availableForCashCarry: a?.availableForCashCarry ?? false,
|
|
17
|
+
quantity: a?.buyingOption.cashCarry.availability.quantity ?? null,
|
|
18
|
+
messageType: a?.buyingOption.cashCarry.availability.probability.thisDay.messageType ?? null,
|
|
19
|
+
errors: annotateStockErrors(result.errors) ?? [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// ── Service functions ─────────────────────────────────────────────────────────
|
|
23
|
+
export async function searchProducts(query, countryCode, langCode, size) {
|
|
24
|
+
// Correct endpoint: /search-result-page (not /search — returns 404)
|
|
25
|
+
const url = `${SEARCH_BASE}/${countryCode}/${langCode}/search-result-page` +
|
|
26
|
+
`?q=${encodeURIComponent(query)}&size=${size}`;
|
|
27
|
+
return fetchJson(url, {
|
|
28
|
+
"User-Agent": "Mozilla/5.0",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
export async function getStoreStock(itemNo, storeId,
|
|
32
|
+
// countryCode unused: endpoint path is /sto/{storeId}, country is implicit in storeId
|
|
33
|
+
_countryCode) {
|
|
34
|
+
// classUnitType must be "sto" (not country code); "ru" exists but storeId maps to "sto"
|
|
35
|
+
const url = `${STOCK_BASE}/sto/${storeId}` +
|
|
36
|
+
`?itemNos=${encodeURIComponent(itemNo)}&expand=StoresList,Restocks`;
|
|
37
|
+
return fetchJson(url, {
|
|
38
|
+
"X-Client-Id": STOCK_CLIENT_ID,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CheckStoreStockInput } from "../schemas/index.js";
|
|
2
|
+
import { getStoreStock, projectStock } from "../services/ikea.js";
|
|
3
|
+
import { storeLabel } from "../data/stores.js";
|
|
4
|
+
export const checkStoreStockTool = {
|
|
5
|
+
name: "check_store_stock",
|
|
6
|
+
description: "Check stock availability for an item at a specific IKEA store.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
itemNo: { type: "string" },
|
|
11
|
+
storeId: { type: "string" },
|
|
12
|
+
countryCode: { type: "string", default: "us" },
|
|
13
|
+
},
|
|
14
|
+
required: ["itemNo", "storeId"],
|
|
15
|
+
},
|
|
16
|
+
async handler(rawInput) {
|
|
17
|
+
const input = CheckStoreStockInput.parse(rawInput);
|
|
18
|
+
const result = await getStoreStock(input.itemNo, input.storeId, input.countryCode);
|
|
19
|
+
const label = storeLabel(input.storeId);
|
|
20
|
+
const output = {
|
|
21
|
+
storeId: input.storeId,
|
|
22
|
+
...(label ? { storeLabel: label } : {}),
|
|
23
|
+
...projectStock(result),
|
|
24
|
+
};
|
|
25
|
+
return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CompareStoreStockInput } from "../schemas/index.js";
|
|
2
|
+
import { getStoreStock, projectStock } from "../services/ikea.js";
|
|
3
|
+
import { storeLabel } from "../data/stores.js";
|
|
4
|
+
export const compareStoreStockTool = {
|
|
5
|
+
name: "compare_store_stock",
|
|
6
|
+
description: "Compare stock availability for an item across multiple IKEA stores.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
itemNo: { type: "string" },
|
|
11
|
+
storeIds: { type: "array", items: { type: "string" }, minItems: 2 },
|
|
12
|
+
countryCode: { type: "string", default: "us" },
|
|
13
|
+
},
|
|
14
|
+
required: ["itemNo", "storeIds"],
|
|
15
|
+
},
|
|
16
|
+
async handler(rawInput) {
|
|
17
|
+
const input = CompareStoreStockInput.parse(rawInput);
|
|
18
|
+
const results = await Promise.all(input.storeIds.map((storeId) => getStoreStock(input.itemNo, storeId, input.countryCode).then((data) => {
|
|
19
|
+
const label = storeLabel(storeId);
|
|
20
|
+
return {
|
|
21
|
+
storeId,
|
|
22
|
+
...(label ? { storeLabel: label } : {}),
|
|
23
|
+
...projectStock(data),
|
|
24
|
+
};
|
|
25
|
+
})));
|
|
26
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { FindBestStoreInput } from "../schemas/index.js";
|
|
2
|
+
import { getStoreStock, projectStock } from "../services/ikea.js";
|
|
3
|
+
import { STORE_LABELS, storeLabel } from "../data/stores.js";
|
|
4
|
+
export const findBestStoreForItemTool = {
|
|
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.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
itemNo: { type: "string" },
|
|
11
|
+
storeIds: { type: "array", items: { type: "string" } },
|
|
12
|
+
maxResults: { type: "number", default: 3 },
|
|
13
|
+
},
|
|
14
|
+
required: ["itemNo"],
|
|
15
|
+
},
|
|
16
|
+
async handler(rawInput) {
|
|
17
|
+
const input = FindBestStoreInput.parse(rawInput);
|
|
18
|
+
const targetIds = input.storeIds ?? Object.keys(STORE_LABELS);
|
|
19
|
+
const results = await Promise.all(targetIds.map(async (storeId) => {
|
|
20
|
+
const data = await getStoreStock(input.itemNo, storeId, "us");
|
|
21
|
+
const stock = projectStock(data);
|
|
22
|
+
return { storeId, stock };
|
|
23
|
+
}));
|
|
24
|
+
const ranked = results
|
|
25
|
+
.filter((r) => r.stock.availableForCashCarry && r.stock.quantity !== null)
|
|
26
|
+
.sort((a, b) => {
|
|
27
|
+
const diff = (b.stock.quantity ?? 0) - (a.stock.quantity ?? 0);
|
|
28
|
+
return diff !== 0 ? diff : a.storeId.localeCompare(b.storeId);
|
|
29
|
+
})
|
|
30
|
+
.slice(0, input.maxResults)
|
|
31
|
+
.map(({ storeId, stock }) => {
|
|
32
|
+
const label = storeLabel(storeId);
|
|
33
|
+
return {
|
|
34
|
+
storeId,
|
|
35
|
+
...(label ? { storeLabel: label } : {}),
|
|
36
|
+
availableForCashCarry: stock.availableForCashCarry,
|
|
37
|
+
quantity: stock.quantity,
|
|
38
|
+
messageType: stock.messageType,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return { content: [{ type: "text", text: JSON.stringify(ranked, null, 2) }] };
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { SearchProductsInput } from "../schemas/index.js";
|
|
2
|
+
import { searchProducts } from "../services/ikea.js";
|
|
3
|
+
export const searchProductsTool = {
|
|
4
|
+
name: "search_products",
|
|
5
|
+
description: "Search IKEA products by keyword.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
query: { type: "string" },
|
|
10
|
+
countryCode: { type: "string", default: "us" },
|
|
11
|
+
langCode: { type: "string", default: "en" },
|
|
12
|
+
size: { type: "number", default: 10 },
|
|
13
|
+
},
|
|
14
|
+
required: ["query"],
|
|
15
|
+
},
|
|
16
|
+
async handler(rawInput) {
|
|
17
|
+
const input = SearchProductsInput.parse(rawInput);
|
|
18
|
+
const result = await searchProducts(input.query, input.countryCode, input.langCode, input.size);
|
|
19
|
+
const main = result.searchResultPage.products.main;
|
|
20
|
+
const output = {
|
|
21
|
+
total: main.max,
|
|
22
|
+
items: main.items.filter((i) => i.product != null).map(({ product: p }) => ({
|
|
23
|
+
itemNo: p.itemNo,
|
|
24
|
+
name: p.name,
|
|
25
|
+
typeName: p.typeName,
|
|
26
|
+
salesPrice: { amount: p.salesPrice.numeral, currencyCode: p.salesPrice.currencyCode },
|
|
27
|
+
pipUrl: p.pipUrl,
|
|
28
|
+
ratingValue: p.ratingValue,
|
|
29
|
+
ratingCount: p.ratingCount,
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function fetchJson(url, headers) {
|
|
2
|
+
const res = await fetch(url, {
|
|
3
|
+
headers: {
|
|
4
|
+
Accept: "application/json",
|
|
5
|
+
...headers,
|
|
6
|
+
},
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok) {
|
|
9
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
|
|
10
|
+
}
|
|
11
|
+
return res.json();
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ikea-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Read-only MCP server for IKEA product search and in-store stock lookup. Supports stdio and Streamable HTTP transports.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/",
|
|
10
|
+
"Procfile"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"mcp",
|
|
14
|
+
"ikea",
|
|
15
|
+
"model-context-protocol",
|
|
16
|
+
"stock",
|
|
17
|
+
"inventory"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/taejin5314/ikea-mcp.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/taejin5314/ikea-mcp#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/taejin5314/ikea-mcp/issues"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsc",
|
|
29
|
+
"dev": "tsx src/index.ts",
|
|
30
|
+
"dev:http": "tsx src/http.ts",
|
|
31
|
+
"start": "node dist/index.js",
|
|
32
|
+
"start:http": "node dist/http.js",
|
|
33
|
+
"smoke": "node smoke.mjs",
|
|
34
|
+
"smoke:http": "node smoke-http.mjs",
|
|
35
|
+
"test": "node --import tsx/esm --test test/unit.test.ts",
|
|
36
|
+
"typecheck": "tsc -p tsconfig.test.json"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
43
|
+
"zod": "^3.22.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.0.0",
|
|
47
|
+
"tsx": "^4.0.0",
|
|
48
|
+
"typescript": "^5.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|