# ikea-mcp

Read-only MCP server for IKEA product search and in-store stock lookup.

**Transports:** stdio (Claude Desktop / MCP CLI) · Streamable HTTP (remote clients)
**License:** MIT · **No auth required to run locally**

## Capabilities
| Tool | What it does |
|---|---|
| `list_stores` | List known store IDs and labels, optionally filtered by country |
| `search_products` | Search IKEA products by keyword |
| `get_product_details` | Get details for a single product by item number |
| `check_store_stock` | Check cash-and-carry stock at one store |
| `check_multi_item_stock` | Check stock for multiple items at one store |
| `compare_store_stock` | Compare stock across explicit stores or a country catalog |
| `find_best_store_for_item` | Rank stores by in-stock quantity (optionally filter by country) |
| `check_cart_availability` | Check whether all items in a shopping list are available at one store |
| `find_best_store_for_cart` | Rank stores by cart fulfillment across multiple items |

## MVP limitations
- Uses unofficial public IKEA APIs — no SLA, may break without notice
- Canada store coverage is complete (15 stores)
- US coverage is incomplete — 4 small-format stores have unknown API IDs (Queens, Alpharetta, Indianapolis, Arlington)
- San Francisco small-format store is intentionally excluded (known ID 3136 returns 405)
- No extra stores are included
- Cash-and-carry availability only — click-and-collect and home delivery not exposed
- HTTP transport is open by default — set `API_KEY` env var to require `x-api-key` header on `/mcp`
- Read-only — no cart, order, or account operations

## Tools

### `search_products`

Search IKEA products by keyword.

**Input**
| param | type | default | required |
|---|---|---|---|
| `query` | string | — | yes |
| `countryCode` | string | `"US"` | no |
| `langCode` | string | `"en"` | no |
| `size` | number | `10` | no |

**Output**
```json
{
  "total": 97,
  "items": [
    {
      "itemNo": "20522046",
      "name": "BILLY",
      "typeName": "Bookcase",
      "salesPrice": { "amount": 69.99, "currencyCode": "USD" },
      "pipUrl": "https://www.ikea.com/us/en/p/...",
      "ratingValue": 4.8,
      "ratingCount": 1234
    }
  ]
}
```

---

### `get_product_details`

Get details for a single IKEA product by item number.

**Input**
| param | type | default | required |
|---|---|---|---|
| `itemNo` | string | — | yes |
| `countryCode` | string | `"US"` | no |
| `langCode` | string | `"en"` | no |

**Output**
```json
{
  "itemNo": "20522046",
  "name": "BILLY",
  "typeName": "Bookcase",
  "salesPrice": { "amount": 79, "currencyCode": "USD" },
  "pipUrl": "https://www.ikea.com/us/en/p/billy-bookcase-white-20522046/",
  "designText": "white",
  "measureText": "31 1/2x11x79 1/2 \"",
  "ratingValue": 4.6,
  "ratingCount": 2620
}
```

> `shortDescription` and `materials` are not available from the underlying API.

---

### `check_store_stock`

Check stock at a single IKEA store.

**Input**
| param | type | default | required |
|---|---|---|---|
| `itemNo` | string | — | yes |
| `storeId` | string | — | yes |
| `countryCode` | string | `"US"` | no |

**Output**
```json
{
  "storeId": "399",
  "availableForCashCarry": true,
  "quantity": 110,
  "messageType": "HIGH_IN_STOCK",
  "errors": null
}
```

On error (e.g. item not carried):
```json
{
  "storeId": "026",
  "availableForCashCarry": false,
  "quantity": null,
  "messageType": null,
  "errors": [{ "code": 404, "message": "Not found", "meaning": "item not stocked at this store" }]
}
```

---

### `compare_store_stock`

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.

**Input**
| param | type | default | required |
|---|---|---|---|
| `itemNo` | string | — | yes |
| `storeIds` | string[] (min 2) | — | one of `storeIds`/`countryCode` |
| `countryCode` | `"US"` \| `"CA"` | — | one of `storeIds`/`countryCode` |
| `sortBy` | `"quantity"` \| `"storeId"` | — | no |

`storeIds` takes precedence — if both are provided, `countryCode` only sets the IKEA API locale.
`sortBy: "quantity"` sorts descending, null quantities last, `storeId` as tie-breaker. `sortBy: "storeId"` sorts ascending. Omitting `sortBy` preserves input order.

**Examples**
```json
{ "itemNo": "20522046", "storeIds": ["399", "026", "921"] }
```
```json
{ "itemNo": "20522046", "countryCode": "CA" }
```

**Output** — array of the same shape as `check_store_stock` (one entry per store).

**Detecting partial failures:** rows with `errors` containing any code other than `404` indicate a store-level or API failure (e.g. `405` = invalid store ID). Rows with only `404` errors mean the item is simply not stocked at that store — this is expected, not a failure.

---

### `check_multi_item_stock`

Check cash-and-carry stock for multiple items at a single store in one call.

**Input**
| param | type | default | required |
|---|---|---|---|
| `storeId` | string | — | yes |
| `itemNos` | string[] (min 1, max 20) | — | yes |

**Output** — array of per-item stock entries in the same order as `itemNos`:

```json
[
  {
    "itemNo": "20522046",
    "storeId": "399",
    "storeLabel": "399 (Burbank, CA)",
    "availableForCashCarry": true,
    "quantity": 104,
    "messageType": "HIGH_IN_STOCK",
    "errors": []
  }
]
```

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.

---

### `find_best_store_for_item`

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.

**Input**
| param | type | default | required |
|---|---|---|---|
| `itemNo` | string | — | yes |
| `storeIds` | string[] | all known stores | no |
| `maxResults` | number | `3` (max 50) | no |
| `countryCode` | `"US"` \| `"CA"` | — | no |
| `minQuantity` | number (int ≥ 1) | — | no |

`storeIds` takes precedence. If only `countryCode` is given, searches all catalog stores for that country. If neither is given, searches all ~65 known stores. `minQuantity` excludes stores with quantity below the threshold.

**Output** — array of matching stores, up to `maxResults`:
```json
[
  {
    "storeId": "399",
    "storeLabel": "399 (Burbank, CA)",
    "availableForCashCarry": true,
    "quantity": 104,
    "messageType": "HIGH_IN_STOCK"
  }
]
```

Returns `[]` if no store has the item in stock. "All known stores" means the ~65 US and Canada entries in `src/data/stores.ts`.

**Note on failures:** stores that return a store-level error (405 invalid store ID) are silently excluded from results rather than appearing as rows. Use `compare_store_stock` with the same `storeIds` to inspect per-store `errors` directly.

---

### `check_cart_availability`

Check whether all items in a shopping list are available in sufficient quantity at a single IKEA store.

**Input**
| param | type | default | required |
|---|---|---|---|
| `storeId` | string | — | yes |
| `items` | array of `{ itemNo, quantity }` | — | yes |
| `items[].itemNo` | string | — | yes |
| `items[].quantity` | number | `1` | no |

**Output**
```json
{
  "storeId": "399",
  "storeLabel": "399 (Burbank, CA)",
  "allSufficient": true,
  "items": [
    {
      "itemNo": "20522046",
      "quantity": 2,
      "inStock": 42,
      "sufficient": true,
      "eligibleForStockNotification": false,
      "errors": []
    }
  ]
}
```

`allSufficient` is `true` only when every item has `sufficient: true`. Items not stocked appear with `inStock: null` and a 404 error. An invalid `storeId` (405) propagates to all items.

---

### `find_best_store_for_cart`

Find the best store to buy multiple items in one trip. Ranks stores by how many cart items are available in sufficient quantity, then by total in-stock sum. Optionally filter by `countryCode` or provide explicit `storeIds`.

**Input**
| param | type | default | required |
|---|---|---|---|
| `items` | array of `{ itemNo, quantity }` | — | yes |
| `items[].itemNo` | string | — | yes |
| `items[].quantity` | number | `1` | no |
| `storeIds` | string[] | — | no |
| `countryCode` | `"US"` \| `"CA"` | — | no |
| `maxResults` | number | `3` (max 50) | no |

`storeIds` takes precedence. If only `countryCode` is given, searches all catalog stores for that country. If neither is given, searches all ~65 known stores.

**Output** — array of stores ranked by cart fulfillment, up to `maxResults`:
```json
[
  {
    "storeId": "399",
    "storeLabel": "399 (Burbank, CA)",
    "allSufficient": true,
    "fulfilledCount": 3,
    "totalCount": 3,
    "items": [
      { "itemNo": "20522046", "quantity": 2, "inStock": 42, "sufficient": true },
      { "itemNo": "40477340", "quantity": 1, "inStock": 5, "sufficient": true },
      { "itemNo": "89268919", "quantity": 1, "inStock": 12, "sufficient": true }
    ]
  }
]
```

`fulfilledCount` = number of items with `sufficient: true`. Sorting: `fulfilledCount` desc → total stock desc → `storeId` asc. Stores with invalid IDs (405) are excluded.

---

## Example workflows

### 1. Search → inspect → check one store

```
1. search_products       { "query": "BILLY bookcase" }
   → pick itemNo from results, e.g. "20522046"

2. get_product_details   { "itemNo": "20522046" }
   → confirms name, price, dimensions before checking stock

3. check_store_stock     { "itemNo": "20522046", "storeId": "399" }
   → { "availableForCashCarry": true, "quantity": 95, "messageType": "HIGH_IN_STOCK" }
```

### 2. Shopping list at one store

Check whether several items are available in a single trip:

```json
{
  "tool": "check_multi_item_stock",
  "storeId": "399",
  "itemNos": ["20522046", "40477340", "89268919"]
}
```

Returns one entry per item in the same order — items not stocked appear with `availableForCashCarry: false` and a 404 error.

### 3. Best store from a mixed US + Canada subset

```json
{
  "tool": "find_best_store_for_item",
  "itemNo": "20522046",
  "storeIds": ["399", "039", "216", "149", "026"],
  "maxResults": 3
}
```

Returns the top 3 stores by in-stock quantity across the mixed US/Canada subset. Omit `storeIds` to search all ~65 known stores.

### 4. Best store for a shopping list

Find which store can fulfill the most items from a multi-item cart:

```json
{
  "tool": "find_best_store_for_cart",
  "items": [
    { "itemNo": "20522046", "quantity": 2 },
    { "itemNo": "40477340", "quantity": 1 },
    { "itemNo": "89268919", "quantity": 1 }
  ],
  "countryCode": "CA",
  "maxResults": 3
}
```

Returns the top 3 Canada stores ranked by how many items they can fully supply. Use `check_cart_availability` to then verify exact quantities at the chosen store.

---

## Build and test

```bash
npm install
npm run build        # tsc → dist/
npm run typecheck    # type-check without emit
npm test             # unit tests
node smoke.mjs       # end-to-end stdio smoke test
```

`smoke.mjs` exercises all 4 tools against the live IKEA API and prints pass/fail lines to stdout.

## Transports

**stdio** (default — for Claude Desktop / MCP CLI):
```bash
npx ikea-mcp          # after npm install (uses bin entry)
node dist/index.js    # after local build
npm run dev           # dev (tsx, no build needed)
```

**Streamable HTTP** (for remote / network clients):
```bash
node dist/http.js          # listens on http://localhost:3000/mcp
PORT=8080 node dist/http.js
# or during dev:
npm run dev:http
```

Requests must include `Accept: application/json, text/event-stream`. Stateless — no session management.

## Deploy (HTTP transport)

Tested target: **Railway** (also works on Render, Heroku, or any Procfile-aware host).

```bash
# 1. build
npm install && npm run build

# 2. run (Procfile: web: node dist/http.js)
#    PORT is set automatically by the host
node dist/http.js
```

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.

Endpoints after deploy:
- `POST /mcp` — MCP Streamable HTTP (requires `Accept: application/json, text/event-stream`)
- `GET /health` — returns `{"status":"ok"}`

> **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.
>
> ```bash
> API_KEY=your-secret node dist/http.js
> ```

## Connecting a local MCP client (stdio)

**Claude Desktop** (`claude_desktop_config.json`):
```json
{
  "mcpServers": {
    "ikea-mcp": {
      "command": "npx",
      "args": ["-y", "ikea-mcp"]
    }
  }
}
```

---

## Connecting a remote MCP client (HTTP)

Point your MCP client at `https://<your-host>/mcp`.

**Claude Desktop** (`claude_desktop_config.json`):
```json
{
  "mcpServers": {
    "ikea-mcp": {
      "type": "http",
      "url": "https://<your-host>/mcp"
    }
  }
}
```

**`.mcp.json`** (project-local, Claude Code):
```json
{
  "mcpServers": {
    "ikea-mcp": {
      "type": "http",
      "url": "https://<your-host>/mcp"
    }
  }
}
```

**Manual / curl** (for debugging):
```bash
curl -X POST https://<your-host>/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```

The `Accept: application/json, text/event-stream` header is required by the MCP SDK — requests without it will be rejected with a `-32000` error.

## Store IDs

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).

Confirmed compatible `storeId` formats:
- Standard 3-digit: `"399"` (Burbank, CA, US), `"216"` (Calgary, AB, CA)
- Leading-zero 3-digit: `"026"` (Canton, MI, US), `"039"` (Montreal, QC, CA)
- 4-digit: `"921"` (Brooklyn, NY, US), `"1129"` (Syracuse, NY, US)

An invalid or unsupported `storeId` returns a 405 error in the `errors` array.

## Limitations

- Uses unofficial public IKEA APIs — no SLA, no auth required, may break without notice.
- Read-only: no cart, no order, no account operations.
- Country-wide fan-out (`countryCode: "US"` ≈ 52 stores, `"CA"` ≈ 15) is capped at 10 concurrent requests and retries once on transient 5xx/network errors.
- Click-and-collect and home-delivery availability are not exposed (cash-and-carry only).
- `size` in `search_products` is capped by IKEA's API (observed max ~24 per page; `total` reflects the full catalogue count).
- US and Canada only — no other countries supported.

## Item numbers

`itemNo` fields accept several formats — all are normalised to 8 digits internally:

| Input | Normalised |
|---|---|
| `"20522046"` | `"20522046"` |
| `"522132"` | `"00522132"` |
| `"005.221.32"` | `"00522132"` |
| `"5-221-32"` | `"00522132"` |

6- and 7-digit inputs are left-padded to 8 digits. 8- and 9-digit inputs are kept as-is. Values outside 6–9 digits after stripping are rejected.

## Supported countries

| Country | Code | Store count |
|---|---|---|
| United States | `US` | ~52 |
| Canada | `CA` | ~15 |

Use `list_stores` to get the current catalog. Some store IDs in the catalog are unverified — they are listed but may return 405 from the stock API.

## Rate limits & reliability

- Fan-out requests (country-wide `compare_store_stock` / `find_best_store_for_item`) are capped at **10 concurrent** outbound requests.
- `fetchJson` retries **once** after 500 ms on 5xx, 429, or network errors. 404 and 405 are not retried (they are semantic responses, not transient failures).
- `Retry-After` header is respected for 429 responses.
- Do not use in high-frequency loops — the upstream IKEA API has no published rate limit but will block repeated bursts.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| `405` in `errors` | Invalid `storeId` — use `list_stores` to find valid IDs |
| `404` in `errors` | Item not stocked at that store |
| Empty `find_best_store_for_item` result | No store has the item in stock, or `minQuantity` is too high |
| Slow `countryCode` query | Normal — fan-out to all country stores (capped at 10 concurrent) |
| `itemNo` validation error | Input must resolve to 6–9 digits; see Item numbers above |
