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 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,4 @@
1
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
+ import { createServer } from "./server.js";
3
+ const transport = new StdioServerTransport();
4
+ await createServer().connect(transport);
@@ -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
+ }