ikea-mcp 1.6.2 → 1.10.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/CHANGELOG.md +72 -0
- package/README.md +662 -526
- package/dist/data/countries.js +16 -0
- package/dist/data/geo.js +26 -0
- package/dist/data/store-coordinates.js +88 -0
- package/dist/data/stores.js +115 -86
- package/dist/schemas/index.js +51 -13
- package/dist/server.js +53 -16
- package/dist/services/ikea.js +4 -0
- package/dist/tools/check_cart_availability.js +7 -4
- package/dist/tools/check_multi_item_stock.js +2 -1
- package/dist/tools/check_store_stock.js +2 -2
- package/dist/tools/compare_store_stock.js +3 -2
- package/dist/tools/find_best_store_for_cart.js +19 -5
- package/dist/tools/find_best_store_for_item.js +19 -5
- package/dist/tools/find_nearby_stores.js +29 -0
- package/dist/tools/get_product_details.js +2 -2
- package/dist/tools/list_stores.js +11 -16
- package/dist/tools/search_products.js +2 -2
- package/dist/tools/targets.js +16 -0
- package/dist/utils/errors.js +27 -0
- package/dist/utils/http.js +6 -1
- package/package.json +59 -53
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Supported countries for the stock tools and the store catalog.
|
|
2
|
+
// Adding a country: add it here, add its stores to ./stores.ts, add README rows.
|
|
3
|
+
export const COUNTRY_CODES = ["US", "CA", "NO"];
|
|
4
|
+
/** Default language for the IKEA search API when the client does not pass langCode. */
|
|
5
|
+
export const DEFAULT_LANG_CODE = {
|
|
6
|
+
US: "en",
|
|
7
|
+
CA: "en",
|
|
8
|
+
NO: "no",
|
|
9
|
+
};
|
|
10
|
+
export function isCountryCode(value) {
|
|
11
|
+
return COUNTRY_CODES.includes(value);
|
|
12
|
+
}
|
|
13
|
+
/** Default search language for a country; "en" for countries outside the catalog. */
|
|
14
|
+
export function defaultLangCode(countryCode) {
|
|
15
|
+
return isCountryCode(countryCode) ? DEFAULT_LANG_CODE[countryCode] : "en";
|
|
16
|
+
}
|
package/dist/data/geo.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { STORES } from "./stores.js";
|
|
2
|
+
import { STORE_COORDINATES } from "./store-coordinates.js";
|
|
3
|
+
const EARTH_RADIUS_KM = 6371;
|
|
4
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
5
|
+
/** Great-circle distance in kilometres (haversine). */
|
|
6
|
+
export function distanceKm(a, b) {
|
|
7
|
+
const dLat = toRad(b.lat - a.lat);
|
|
8
|
+
const dLon = toRad(b.lon - a.lon);
|
|
9
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
|
10
|
+
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Catalog stores closest to a point, nearest first; ties break on storeId.
|
|
14
|
+
* Stores without a known coordinate are left out rather than placed by guess.
|
|
15
|
+
*/
|
|
16
|
+
export function nearestStores(from, options) {
|
|
17
|
+
return STORES.filter((s) => !options.countryCode || s.countryCode === options.countryCode)
|
|
18
|
+
.flatMap((s) => {
|
|
19
|
+
const at = STORE_COORDINATES[s.storeId];
|
|
20
|
+
return at ? [{ storeId: s.storeId, countryCode: s.countryCode, km: distanceKm(from, at) }] : [];
|
|
21
|
+
})
|
|
22
|
+
.filter((s) => options.maxDistanceKm === undefined || s.km <= options.maxDistanceKm)
|
|
23
|
+
.sort((a, b) => a.km - b.km || a.storeId.localeCompare(b.storeId))
|
|
24
|
+
.slice(0, options.limit)
|
|
25
|
+
.map(({ storeId, countryCode, km }) => ({ storeId, countryCode, distanceKm: Math.round(km * 10) / 10 }));
|
|
26
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Generated by scripts/update-store-coordinates.mjs from IKEA's public store lists
|
|
2
|
+
// (ikea.com/<country>/<lang>/meta-data/informera/stores-detailed.json). Do not edit by hand.
|
|
3
|
+
// A catalog store that is missing here is left out of distance searches, not guessed.
|
|
4
|
+
export const STORE_COORDINATES = {
|
|
5
|
+
"026": { lat: 42.3252, lon: -83.4516 }, // IKEA Detroit - Canton
|
|
6
|
+
"027": { lat: 30.5569, lon: -97.6897 }, // IKEA Austin - Round Rock
|
|
7
|
+
"028": { lat: 45.5712, lon: -122.5537 }, // IKEA Oregon - Portland
|
|
8
|
+
"042": { lat: 27.9541, lon: -82.4327 }, // IKEA Tampa
|
|
9
|
+
"064": { lat: 39.5720, lon: -104.8749 }, // IKEA Denver - Centennial
|
|
10
|
+
"067": { lat: 35.2930, lon: -80.7642 }, // IKEA Charlotte
|
|
11
|
+
"103": { lat: 40.5083, lon: -111.8922 }, // IKEA Salt Lake City - Draper
|
|
12
|
+
"145": { lat: 28.4830, lon: -81.4273 }, // IKEA Orlando
|
|
13
|
+
"152": { lat: 39.3747, lon: -76.4623 }, // IKEA Baltimore
|
|
14
|
+
"153": { lat: 40.4522, lon: -80.1676 }, // IKEA Pittsburg
|
|
15
|
+
"154": { lat: 40.6751, lon: -74.1697 }, // IKEA New York - Elizabeth
|
|
16
|
+
"156": { lat: 40.7744, lon: -73.5315 }, // IKEA New York - Long Island
|
|
17
|
+
"157": { lat: 38.5866, lon: -121.5535 }, // IKEA Sacramento - West Sacramento
|
|
18
|
+
"158": { lat: 42.1367, lon: -71.0687 }, // IKEA Boston - Stoughton
|
|
19
|
+
"162": { lat: 33.8419, lon: -118.2601 }, // IKEA Los Angeles - Carson
|
|
20
|
+
"165": { lat: 37.8312, lon: -122.2925 }, // IKEA San Francisco - Emeryville
|
|
21
|
+
"166": { lat: 32.7800, lon: -117.1264 }, // IKEA San Diego
|
|
22
|
+
"167": { lat: 33.6905, lon: -117.9163 }, // IKEA Los Angeles - Costa Mesa
|
|
23
|
+
"168": { lat: 38.6431, lon: -77.2901 }, // IKEA Washington - Woodbridge
|
|
24
|
+
"170": { lat: 41.7274, lon: -88.0379 }, // IKEA Chicago - Bolingbrook
|
|
25
|
+
"175": { lat: 39.3160, lon: -84.4353 }, // IKEA Cincinnati - West Chester
|
|
26
|
+
"183": { lat: 33.0951, lon: -96.8216 }, // IKEA Dallas - Frisco
|
|
27
|
+
"207": { lat: 26.1215, lon: -80.3319 }, // IKEA Fort Lauderdale - Sunrise
|
|
28
|
+
"209": { lat: 33.3406, lon: -111.9702 }, // IKEA Phoenix - Tempe
|
|
29
|
+
"210": { lat: 42.0567, lon: -88.0353 }, // IKEA Chicago - Schaumburg
|
|
30
|
+
"211": { lat: 40.0942, lon: -75.3079 }, // IKEA Philadelphia - Conshohocken
|
|
31
|
+
"212": { lat: 44.8590, lon: -93.2435 }, // IKEA Minneapolis - Bloomington
|
|
32
|
+
"213": { lat: 41.2950, lon: -72.9192 }, // IKEA New York - New Haven
|
|
33
|
+
"215": { lat: 39.9167, lon: -75.1401 }, // IKEA Philadelphia - Penn S Landing
|
|
34
|
+
"257": { lat: 33.7878, lon: -84.4062 }, // IKEA Atlanta
|
|
35
|
+
"327": { lat: 25.7916, lon: -80.3834 }, // IKEA Miami
|
|
36
|
+
"347": { lat: 37.4609, lon: -122.1390 }, // IKEA San Francisco - East Palo Alto
|
|
37
|
+
"374": { lat: 39.0199, lon: -94.6906 }, // IKEA Kansas City - Merriam
|
|
38
|
+
"379": { lat: 29.7855, lon: -95.4723 }, // IKEA Houston
|
|
39
|
+
"399": { lat: 34.1745, lon: -118.3032 }, // IKEA Los Angeles - Burbank
|
|
40
|
+
"409": { lat: 40.9246, lon: -74.0749 }, // IKEA New York - Paramus
|
|
41
|
+
"410": { lat: 38.6337, lon: -90.2449 }, // IKEA Missouri - St. Louis
|
|
42
|
+
"411": { lat: 39.0208, lon: -76.9289 }, // IKEA Washington - College Park
|
|
43
|
+
"413": { lat: 34.0744, lon: -117.8801 }, // IKEA Los Angeles - Covina
|
|
44
|
+
"462": { lat: 36.0688, lon: -115.2811 }, // IKEA Las Vegas
|
|
45
|
+
"488": { lat: 47.4424, lon: -122.2278 }, // IKEA Seattle - Renton
|
|
46
|
+
"511": { lat: 40.1507, lon: -82.9671 }, // IKEA Columbus
|
|
47
|
+
"535": { lat: 32.6908, lon: -97.0227 }, // IKEA Dallas - Grand Prairie
|
|
48
|
+
"536": { lat: 39.9537, lon: -86.0059 }, // IKEA Indianapolis - Fishers
|
|
49
|
+
"537": { lat: 30.2366, lon: -81.5185 }, // IKEA Jacksonville
|
|
50
|
+
"560": { lat: 42.9079, lon: -87.9387 }, // IKEA Milwaukee - Oak Creek
|
|
51
|
+
"569": { lat: 36.8763, lon: -76.2014 }, // IKEA Norfolk
|
|
52
|
+
"570": { lat: 29.5667, lon: -98.3381 }, // IKEA San Antonio - Live Oak
|
|
53
|
+
"658": { lat: 37.7828, lon: -122.4088 }, // IKEA San Francisco - Market Street
|
|
54
|
+
"921": { lat: 40.6720, lon: -74.0115 }, // IKEA New York - Brooklyn
|
|
55
|
+
"1013": { lat: 32.9180, lon: -96.4265 }, // IKEA Dallas - Rockwall
|
|
56
|
+
"1099": { lat: 32.8695, lon: -96.7689 }, // IKEA Dallas - University Park
|
|
57
|
+
"1100": { lat: 26.2042, lon: -98.2016 }, // IKEA McAllen Pharr - Town Center
|
|
58
|
+
"1129": { lat: 43.0686, lon: -76.1743 }, // IKEA Syracuse - Destiny Mall
|
|
59
|
+
"1130": { lat: 38.8881, lon: -77.4399 }, // IKEA Washington - Chantilly
|
|
60
|
+
"1132": { lat: 33.5998, lon: -111.9837 }, // IKEA Phoenix - Scottsdale
|
|
61
|
+
"1232": { lat: 34.7415, lon: -86.6762 }, // IKEA Huntsville - University Place
|
|
62
|
+
"1253": { lat: 29.5385, lon: -95.1360 }, // IKEA Webster - Bay Area
|
|
63
|
+
"1284": { lat: 34.0307, lon: -118.3848 }, // IKEA Los Angeles - Culver City
|
|
64
|
+
"1309": { lat: 36.0574, lon: -96.0012 }, // IKEA Tulsa - Tulsa Hills
|
|
65
|
+
"003": { lat: 49.1899, lon: -123.0797 }, // IKEA Vancouver - Richmond
|
|
66
|
+
"004": { lat: 45.3503, lon: -75.7841 }, // IKEA Ottawa
|
|
67
|
+
"039": { lat: 45.4912, lon: -73.6908 }, // IKEA Montreal - Ville St. Laurent
|
|
68
|
+
"040": { lat: 43.3279, lon: -79.8292 }, // IKEA Toronto - Burlington
|
|
69
|
+
"149": { lat: 43.7676, lon: -79.3693 }, // IKEA Toronto - North York
|
|
70
|
+
"216": { lat: 50.9799, lon: -114.0448 }, // IKEA Calgary
|
|
71
|
+
"249": { lat: 49.8389, lon: -97.2104 }, // IKEA Winnipeg
|
|
72
|
+
"256": { lat: 43.6190, lon: -79.5350 }, // IKEA Toronto - Etobicoke
|
|
73
|
+
"313": { lat: 49.2351, lon: -122.8658 }, // IKEA Vancouver - Coquitlam
|
|
74
|
+
"349": { lat: 53.4432, lon: -113.4897 }, // IKEA South Edmonton
|
|
75
|
+
"372": { lat: 43.7877, lon: -79.5296 }, // IKEA Toronto - Vaughan
|
|
76
|
+
"414": { lat: 45.5716, lon: -73.4007 }, // IKEA Montreal - Boucherville
|
|
77
|
+
"529": { lat: 44.7130, lon: -63.5575 }, // IKEA Halifax - Nova Scotia
|
|
78
|
+
"559": { lat: 46.7766, lon: -71.3495 }, // IKEA Quebec City
|
|
79
|
+
"659": { lat: 43.6597, lon: -79.3830 }, // IKEA Toronto - Downtown
|
|
80
|
+
"1340": { lat: 42.9308, lon: -81.2229 }, // IKEA London - White Oak Mall
|
|
81
|
+
"007": { lat: 58.1873, lon: 8.1584 }, // IKEA Kristiansand - Sørlandet
|
|
82
|
+
"091": { lat: 59.8739, lon: 10.4974 }, // IKEA Oslo - Slependen
|
|
83
|
+
"095": { lat: 59.9338, lon: 10.8731 }, // IKEA Oslo - Furuset
|
|
84
|
+
"126": { lat: 58.8867, lon: 5.7212 }, // IKEA Stavanger - Forus
|
|
85
|
+
"371": { lat: 63.4286, lon: 10.4738 }, // IKEA Trondheim - Leangen
|
|
86
|
+
"390": { lat: 60.8402, lon: 11.0575 }, // IKEA Hamar - Ringsaker
|
|
87
|
+
"441": { lat: 60.4761, lon: 5.3320 }, // IKEA Bergen - Åsane
|
|
88
|
+
};
|
package/dist/data/stores.js
CHANGED
|
@@ -1,93 +1,122 @@
|
|
|
1
|
-
|
|
2
|
-
// CA store labels confirmed from ikea.com/ca/en/stores/<slug>/ pages (storeNo= embedded JSON).
|
|
3
|
-
// IDs match the sto/{storeId} path used by the IKEA stock API.
|
|
4
|
-
// 4-digit IDs (921, 1099, 1129) are newer-format stores — confirmed working with stock API.
|
|
5
|
-
// 3136 (San Francisco) returns 405 from stock API — excluded.
|
|
6
|
-
// 698 (CA — unknown city) returns 422 from stock API — excluded.
|
|
7
|
-
// 659 (Toronto Downtown) returns 200 but null quantity — included conservatively.
|
|
8
|
-
// TODO: huntsville, arcadia, mcallen-pharr, san-marcos (US) — IDs not found on store pages.
|
|
9
|
-
// TODO: 448, 612, 634, 652, 661, 663, 692, 693 — valid API IDs, city/country unknown.
|
|
10
|
-
export const STORE_LABELS = {
|
|
1
|
+
export const STORES = [
|
|
11
2
|
// ── United States ────────────────────────────────────────────────────────────
|
|
12
|
-
"026": "
|
|
13
|
-
"027": "
|
|
14
|
-
"028": "
|
|
15
|
-
"042": "
|
|
16
|
-
"064": "
|
|
17
|
-
"067": "
|
|
18
|
-
"103": "
|
|
19
|
-
"145": "
|
|
20
|
-
"152": "
|
|
21
|
-
"153": "
|
|
22
|
-
"154": "
|
|
23
|
-
"156": "
|
|
24
|
-
"157": "
|
|
25
|
-
"158": "
|
|
26
|
-
"162": "
|
|
27
|
-
"165": "
|
|
28
|
-
"166": "
|
|
29
|
-
"167": "
|
|
30
|
-
"168": "
|
|
31
|
-
"170": "
|
|
32
|
-
"175": "
|
|
33
|
-
"183": "
|
|
34
|
-
"207": "
|
|
35
|
-
"209": "
|
|
36
|
-
"210": "
|
|
37
|
-
"211": "
|
|
38
|
-
"212": "
|
|
39
|
-
"213": "
|
|
40
|
-
"215": "
|
|
41
|
-
"257": "
|
|
42
|
-
"327": "
|
|
43
|
-
"347": "
|
|
44
|
-
"374": "
|
|
45
|
-
"379": "
|
|
46
|
-
"399": "
|
|
47
|
-
"409": "
|
|
48
|
-
"410": "
|
|
49
|
-
"411": "
|
|
50
|
-
"413": "
|
|
51
|
-
"462": "
|
|
52
|
-
"488": "
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"921": "
|
|
62
|
-
"
|
|
63
|
-
"
|
|
3
|
+
{ storeId: "026", city: "Canton", region: "MI", countryCode: "US" },
|
|
4
|
+
{ storeId: "027", city: "Round Rock", region: "TX", countryCode: "US" },
|
|
5
|
+
{ storeId: "028", city: "Portland", region: "OR", countryCode: "US" },
|
|
6
|
+
{ storeId: "042", city: "Tampa", region: "FL", countryCode: "US" },
|
|
7
|
+
{ storeId: "064", city: "Centennial", region: "CO", countryCode: "US" },
|
|
8
|
+
{ storeId: "067", city: "Charlotte", region: "NC", countryCode: "US" },
|
|
9
|
+
{ storeId: "103", city: "Draper", region: "UT", countryCode: "US" },
|
|
10
|
+
{ storeId: "145", city: "Orlando", region: "FL", countryCode: "US" },
|
|
11
|
+
{ storeId: "152", city: "Baltimore", region: "MD", countryCode: "US" },
|
|
12
|
+
{ storeId: "153", city: "Pittsburgh", region: "PA", countryCode: "US" },
|
|
13
|
+
{ storeId: "154", city: "Elizabeth", region: "NJ", countryCode: "US" },
|
|
14
|
+
{ storeId: "156", city: "Long Island", region: "NY", countryCode: "US" },
|
|
15
|
+
{ storeId: "157", city: "West Sacramento", region: "CA", countryCode: "US" },
|
|
16
|
+
{ storeId: "158", city: "Stoughton", region: "MA", countryCode: "US" },
|
|
17
|
+
{ storeId: "162", city: "Carson", region: "CA", countryCode: "US" },
|
|
18
|
+
{ storeId: "165", city: "Emeryville", region: "CA", countryCode: "US" },
|
|
19
|
+
{ storeId: "166", city: "San Diego", region: "CA", countryCode: "US" },
|
|
20
|
+
{ storeId: "167", city: "Costa Mesa", region: "CA", countryCode: "US" },
|
|
21
|
+
{ storeId: "168", city: "Woodbridge", region: "VA", countryCode: "US" },
|
|
22
|
+
{ storeId: "170", city: "Bolingbrook", region: "IL", countryCode: "US" },
|
|
23
|
+
{ storeId: "175", city: "West Chester", region: "OH", countryCode: "US" },
|
|
24
|
+
{ storeId: "183", city: "Frisco", region: "TX", countryCode: "US" },
|
|
25
|
+
{ storeId: "207", city: "Sunrise", region: "FL", countryCode: "US" },
|
|
26
|
+
{ storeId: "209", city: "Tempe", region: "AZ", countryCode: "US" },
|
|
27
|
+
{ storeId: "210", city: "Schaumburg", region: "IL", countryCode: "US" },
|
|
28
|
+
{ storeId: "211", city: "Conshohocken", region: "PA", countryCode: "US" },
|
|
29
|
+
{ storeId: "212", city: "Minneapolis", region: "MN", countryCode: "US" },
|
|
30
|
+
{ storeId: "213", city: "New Haven", region: "CT", countryCode: "US" },
|
|
31
|
+
{ storeId: "215", city: "South Philadelphia", region: "PA", countryCode: "US" },
|
|
32
|
+
{ storeId: "257", city: "Atlanta", region: "GA", countryCode: "US" },
|
|
33
|
+
{ storeId: "327", city: "Miami", region: "FL", countryCode: "US" },
|
|
34
|
+
{ storeId: "347", city: "East Palo Alto", region: "CA", countryCode: "US" },
|
|
35
|
+
{ storeId: "374", city: "Merriam", region: "KS", countryCode: "US" },
|
|
36
|
+
{ storeId: "379", city: "Houston", region: "TX", countryCode: "US" },
|
|
37
|
+
{ storeId: "399", city: "Burbank", region: "CA", countryCode: "US" },
|
|
38
|
+
{ storeId: "409", city: "Paramus", region: "NJ", countryCode: "US" },
|
|
39
|
+
{ storeId: "410", city: "St. Louis", region: "MO", countryCode: "US" },
|
|
40
|
+
{ storeId: "411", city: "College Park", region: "MD", countryCode: "US" },
|
|
41
|
+
{ storeId: "413", city: "Covina", region: "CA", countryCode: "US" },
|
|
42
|
+
{ storeId: "462", city: "Las Vegas", region: "NV", countryCode: "US" },
|
|
43
|
+
{ storeId: "488", city: "Renton", region: "WA", countryCode: "US" },
|
|
44
|
+
{ storeId: "511", city: "Columbus", region: "OH", countryCode: "US" },
|
|
45
|
+
{ storeId: "535", city: "Grand Prairie", region: "TX", countryCode: "US" },
|
|
46
|
+
{ storeId: "536", city: "Fishers", region: "IN", countryCode: "US" },
|
|
47
|
+
{ storeId: "537", city: "Jacksonville", region: "FL", countryCode: "US" },
|
|
48
|
+
{ storeId: "560", city: "Oak Creek", region: "WI", countryCode: "US" },
|
|
49
|
+
{ storeId: "569", city: "Norfolk", region: "VA", countryCode: "US" },
|
|
50
|
+
{ storeId: "570", city: "Live Oak", region: "TX", countryCode: "US" },
|
|
51
|
+
{ storeId: "658", city: "San Francisco", region: "CA", countryCode: "US" },
|
|
52
|
+
{ storeId: "921", city: "Brooklyn", region: "NY", countryCode: "US" },
|
|
53
|
+
{ storeId: "1013", city: "Rockwall", region: "TX", countryCode: "US" },
|
|
54
|
+
{ storeId: "1099", city: "University Park", region: "TX", countryCode: "US" },
|
|
55
|
+
{ storeId: "1100", city: "Pharr", region: "TX", countryCode: "US" },
|
|
56
|
+
{ storeId: "1129", city: "Syracuse", region: "NY", countryCode: "US" },
|
|
57
|
+
{ storeId: "1130", city: "Chantilly", region: "VA", countryCode: "US" },
|
|
58
|
+
{ storeId: "1132", city: "Scottsdale", region: "AZ", countryCode: "US" },
|
|
59
|
+
{ storeId: "1232", city: "Huntsville", region: "AL", countryCode: "US" },
|
|
60
|
+
{ storeId: "1253", city: "Webster", region: "TX", countryCode: "US" },
|
|
61
|
+
{ storeId: "1284", city: "Culver City", region: "CA", countryCode: "US" },
|
|
62
|
+
{ storeId: "1309", city: "Tulsa", region: "OK", countryCode: "US" },
|
|
64
63
|
// ── Canada ───────────────────────────────────────────────────────────────────
|
|
65
|
-
// Confirmed from ikea.com/ca/en/stores/<slug>/ pages (storeNo= embedded JSON).
|
|
66
64
|
// All probed against stock API with item 20522046 — all return 200.
|
|
67
|
-
"003": "
|
|
68
|
-
"004": "
|
|
69
|
-
"039": "
|
|
70
|
-
"040": "
|
|
71
|
-
"149": "
|
|
72
|
-
"216": "
|
|
73
|
-
"249": "
|
|
74
|
-
"256": "
|
|
75
|
-
"313": "
|
|
76
|
-
"349": "
|
|
77
|
-
"372": "
|
|
78
|
-
"414": "
|
|
79
|
-
"529": "
|
|
80
|
-
"559": "
|
|
81
|
-
"659": "
|
|
82
|
-
}
|
|
65
|
+
{ storeId: "003", city: "Richmond", region: "BC", countryCode: "CA" },
|
|
66
|
+
{ storeId: "004", city: "Ottawa", region: "ON", countryCode: "CA" },
|
|
67
|
+
{ storeId: "039", city: "Montreal", region: "QC", countryCode: "CA" },
|
|
68
|
+
{ storeId: "040", city: "Burlington", region: "ON", countryCode: "CA" },
|
|
69
|
+
{ storeId: "149", city: "North York", region: "ON", countryCode: "CA" },
|
|
70
|
+
{ storeId: "216", city: "Calgary", region: "AB", countryCode: "CA" },
|
|
71
|
+
{ storeId: "249", city: "Winnipeg", region: "MB", countryCode: "CA" },
|
|
72
|
+
{ storeId: "256", city: "Etobicoke", region: "ON", countryCode: "CA" },
|
|
73
|
+
{ storeId: "313", city: "Coquitlam", region: "BC", countryCode: "CA" },
|
|
74
|
+
{ storeId: "349", city: "Edmonton", region: "AB", countryCode: "CA" },
|
|
75
|
+
{ storeId: "372", city: "Vaughan", region: "ON", countryCode: "CA" },
|
|
76
|
+
{ storeId: "414", city: "Boucherville", region: "QC", countryCode: "CA" },
|
|
77
|
+
{ storeId: "529", city: "Halifax", region: "NS", countryCode: "CA" },
|
|
78
|
+
{ storeId: "559", city: "Quebec City", region: "QC", countryCode: "CA" },
|
|
79
|
+
{ storeId: "659", city: "Toronto Downtown", region: "ON", countryCode: "CA" },
|
|
80
|
+
{ storeId: "1340", city: "London", region: "ON", countryCode: "CA" },
|
|
81
|
+
// ── Norway ───────────────────────────────────────────────────────────────────
|
|
82
|
+
// Full-size stores only. All probed against stock API with item 00263850 — all return 200
|
|
83
|
+
// with a cash-and-carry quantity. City is the metropolitan area the store is known by.
|
|
84
|
+
{ storeId: "007", name: "Sørlandet", city: "Kristiansand", countryCode: "NO" },
|
|
85
|
+
{ storeId: "091", name: "Slependen", city: "Asker", countryCode: "NO" },
|
|
86
|
+
{ storeId: "095", name: "Furuset", city: "Oslo", countryCode: "NO" },
|
|
87
|
+
{ storeId: "126", name: "Forus", city: "Stavanger", countryCode: "NO" },
|
|
88
|
+
{ storeId: "371", name: "Leangen", city: "Trondheim", countryCode: "NO" },
|
|
89
|
+
{ storeId: "390", name: "Ringsaker", city: "Hamar", countryCode: "NO" },
|
|
90
|
+
{ storeId: "441", name: "Åsane", city: "Bergen", countryCode: "NO" },
|
|
91
|
+
];
|
|
92
|
+
/**
|
|
93
|
+
* Display label: "<id> (<name>, <city>, <region>, <country>)" with absent parts omitted.
|
|
94
|
+
* US labels historically carry no country suffix; that quirk is preserved so existing
|
|
95
|
+
* labels stay byte-identical ("026 (Canton, MI)", "003 (Richmond, BC, CA)", "095 (Furuset, Oslo, NO)").
|
|
96
|
+
*/
|
|
97
|
+
export function storeLabelFor(store) {
|
|
98
|
+
const parts = [
|
|
99
|
+
store.name,
|
|
100
|
+
store.city,
|
|
101
|
+
store.region,
|
|
102
|
+
store.countryCode === "US" ? undefined : store.countryCode,
|
|
103
|
+
].filter((p) => typeof p === "string" && p.length > 0);
|
|
104
|
+
return `${store.storeId} (${parts.join(", ")})`;
|
|
105
|
+
}
|
|
106
|
+
const BY_ID = new Map(STORES.map((s) => [s.storeId, s]));
|
|
107
|
+
/** storeId → label. Kept for backward compatibility; prefer storeLabel() / STORES. */
|
|
108
|
+
export const STORE_LABELS = Object.fromEntries(STORES.map((s) => [s.storeId, storeLabelFor(s)]));
|
|
83
109
|
export function storeLabel(storeId) {
|
|
84
|
-
|
|
110
|
+
const store = BY_ID.get(storeId);
|
|
111
|
+
return store ? storeLabelFor(store) : undefined;
|
|
112
|
+
}
|
|
113
|
+
export function storeCountry(storeId) {
|
|
114
|
+
return BY_ID.get(storeId)?.countryCode;
|
|
115
|
+
}
|
|
116
|
+
/** All catalog store IDs in declaration order (US, CA, NO). */
|
|
117
|
+
export function allStoreIds() {
|
|
118
|
+
return STORES.map((s) => s.storeId);
|
|
85
119
|
}
|
|
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
120
|
export function storeIdsByCountry(countryCode) {
|
|
90
|
-
return
|
|
91
|
-
.filter(([, label]) => countryCode === "CA" ? CA_LABEL_RE.test(label) : !CA_LABEL_RE.test(label))
|
|
92
|
-
.map(([id]) => id);
|
|
121
|
+
return STORES.filter((s) => s.countryCode === countryCode).map((s) => s.storeId);
|
|
93
122
|
}
|
package/dist/schemas/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { COUNTRY_CODES, defaultLangCode } from "../data/countries.js";
|
|
2
3
|
// ── normalizeItemNo ───────────────────────────────────────────────────────────
|
|
3
4
|
/**
|
|
4
5
|
* Strips non-digit characters, then left-pads to 8 digits when the result is
|
|
@@ -27,16 +28,21 @@ const toUpper = (v) => typeof v === "string" ? v.toUpperCase() : v;
|
|
|
27
28
|
// Free-form 2-letter ISO country code normalised to uppercase.
|
|
28
29
|
// Used by search and product-detail tools that accept any country code.
|
|
29
30
|
const CountryCode2 = z.preprocess(toUpper, z.string().length(2));
|
|
30
|
-
// Uppercase-normalised enum restricted to the
|
|
31
|
-
//
|
|
32
|
-
const CountryCodeEnum = z.preprocess(toUpper, z.enum(
|
|
31
|
+
// Uppercase-normalised enum restricted to the countries in the store catalog.
|
|
32
|
+
// Accepts "us"/"ca"/"no" and normalises to "US"/"CA"/"NO".
|
|
33
|
+
const CountryCodeEnum = z.preprocess(toUpper, z.enum(COUNTRY_CODES));
|
|
34
|
+
// Two-letter language code for the search API. Optional on input; filled from the
|
|
35
|
+
// country's default (see defaultLangCode) when omitted.
|
|
36
|
+
const LangCode = z.string().length(2);
|
|
33
37
|
// ── Input schemas ─────────────────────────────────────────────────────────────
|
|
34
|
-
export const SearchProductsInput = z
|
|
38
|
+
export const SearchProductsInput = z
|
|
39
|
+
.object({
|
|
35
40
|
query: z.string().min(1),
|
|
36
41
|
countryCode: CountryCode2.default("US"),
|
|
37
|
-
langCode:
|
|
42
|
+
langCode: LangCode.optional(),
|
|
38
43
|
size: z.number().int().min(1).max(50).default(10),
|
|
39
|
-
})
|
|
44
|
+
})
|
|
45
|
+
.transform((d) => ({ ...d, langCode: d.langCode ?? defaultLangCode(d.countryCode) }));
|
|
40
46
|
export const CheckStoreStockInput = z.object({
|
|
41
47
|
itemNo: ItemNo,
|
|
42
48
|
storeId: z.string().min(1),
|
|
@@ -53,18 +59,45 @@ export const CompareStoreStockInput = z
|
|
|
53
59
|
export const ListStoresInput = z.object({
|
|
54
60
|
countryCode: CountryCodeEnum.optional(),
|
|
55
61
|
});
|
|
56
|
-
|
|
62
|
+
// A point on the map: the user's city or address, in decimal degrees.
|
|
63
|
+
const Near = z.object({
|
|
64
|
+
latitude: z.number().min(-90).max(90),
|
|
65
|
+
longitude: z.number().min(-180).max(180),
|
|
66
|
+
});
|
|
67
|
+
const NearestStores = z.number().int().min(1).max(20).default(5);
|
|
68
|
+
// Same rule as FindNearbyStoresInput.maxDistanceKm.
|
|
69
|
+
const MaxDistanceKm = z.number().positive().optional();
|
|
70
|
+
const MAX_DISTANCE_NEEDS_NEAR = "maxDistanceKm needs near";
|
|
71
|
+
// A distance limit only means something for a `near` search; explicit `storeIds` are searched as given, so the limit is ignored there.
|
|
72
|
+
function maxDistanceHasPlace(d) {
|
|
73
|
+
return d.maxDistanceKm === undefined || d.near !== undefined || d.storeIds !== undefined;
|
|
74
|
+
}
|
|
75
|
+
export const FindNearbyStoresInput = z.object({
|
|
76
|
+
latitude: z.number().min(-90).max(90),
|
|
77
|
+
longitude: z.number().min(-180).max(180),
|
|
78
|
+
countryCode: CountryCodeEnum.optional(),
|
|
79
|
+
maxResults: z.number().int().min(1).max(20).default(5),
|
|
80
|
+
maxDistanceKm: z.number().positive().optional(),
|
|
81
|
+
});
|
|
82
|
+
export const FindBestStoreInput = z
|
|
83
|
+
.object({
|
|
57
84
|
itemNo: ItemNo,
|
|
58
85
|
storeIds: z.array(z.string().min(1)).optional(),
|
|
59
86
|
maxResults: z.number().int().min(1).max(50).default(3),
|
|
60
87
|
countryCode: CountryCodeEnum.optional(),
|
|
88
|
+
near: Near.optional(),
|
|
89
|
+
nearestStores: NearestStores,
|
|
90
|
+
maxDistanceKm: MaxDistanceKm,
|
|
61
91
|
minQuantity: z.number().int().min(1).optional(),
|
|
62
|
-
})
|
|
63
|
-
|
|
92
|
+
})
|
|
93
|
+
.refine(maxDistanceHasPlace, { message: MAX_DISTANCE_NEEDS_NEAR, path: ["maxDistanceKm"] });
|
|
94
|
+
export const GetProductDetailsInput = z
|
|
95
|
+
.object({
|
|
64
96
|
itemNo: ItemNo,
|
|
65
97
|
countryCode: CountryCode2.default("US"),
|
|
66
|
-
langCode:
|
|
67
|
-
})
|
|
98
|
+
langCode: LangCode.optional(),
|
|
99
|
+
})
|
|
100
|
+
.transform((d) => ({ ...d, langCode: d.langCode ?? defaultLangCode(d.countryCode) }));
|
|
68
101
|
export const CheckMultiItemStockInput = z.object({
|
|
69
102
|
storeId: z.string().min(1),
|
|
70
103
|
itemNos: z.array(ItemNo).min(1).max(20),
|
|
@@ -76,12 +109,17 @@ export const CheckCartAvailabilityInput = z.object({
|
|
|
76
109
|
quantity: z.number().int().min(1).max(99).default(1),
|
|
77
110
|
})).min(1).max(20),
|
|
78
111
|
});
|
|
79
|
-
export const FindBestStoreForCartInput = z
|
|
112
|
+
export const FindBestStoreForCartInput = z
|
|
113
|
+
.object({
|
|
80
114
|
items: z.array(z.object({
|
|
81
115
|
itemNo: ItemNo,
|
|
82
116
|
quantity: z.number().int().min(1).max(99).default(1),
|
|
83
117
|
})).min(1).max(20),
|
|
84
118
|
storeIds: z.array(z.string().min(1)).optional(),
|
|
85
119
|
countryCode: CountryCodeEnum.optional(),
|
|
120
|
+
near: Near.optional(),
|
|
121
|
+
nearestStores: NearestStores,
|
|
122
|
+
maxDistanceKm: MaxDistanceKm,
|
|
86
123
|
maxResults: z.number().int().min(1).max(50).default(3),
|
|
87
|
-
})
|
|
124
|
+
})
|
|
125
|
+
.refine(maxDistanceHasPlace, { message: MAX_DISTANCE_NEEDS_NEAR, path: ["maxDistanceKm"] });
|
package/dist/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
4
|
import { searchProductsTool } from "./tools/search_products.js";
|
|
@@ -9,22 +10,58 @@ import { checkMultiItemStockTool } from "./tools/check_multi_item_stock.js";
|
|
|
9
10
|
import { listStoresTool } from "./tools/list_stores.js";
|
|
10
11
|
import { checkCartAvailabilityTool } from "./tools/check_cart_availability.js";
|
|
11
12
|
import { findBestStoreForCartTool } from "./tools/find_best_store_for_cart.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
import { findNearbyStoresTool } from "./tools/find_nearby_stores.js";
|
|
14
|
+
import { toToolError } from "./utils/errors.js";
|
|
15
|
+
// The version the server reports is the package version, read from package.json next to dist/ (or src/ in dev).
|
|
16
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
17
|
+
const tools = [listStoresTool, findNearbyStoresTool, searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool, checkCartAvailabilityTool, findBestStoreForCartTool];
|
|
18
|
+
// Every tool only reads. All but the two catalog tools call the live IKEA API, so their answers can change between calls.
|
|
19
|
+
const CATALOG_ONLY = new Set(["list_stores", "find_nearby_stores"]);
|
|
20
|
+
const TITLES = {
|
|
21
|
+
list_stores: "List IKEA stores",
|
|
22
|
+
find_nearby_stores: "Find IKEA stores near a place",
|
|
23
|
+
search_products: "Search IKEA products",
|
|
24
|
+
get_product_details: "Get IKEA product details",
|
|
25
|
+
check_store_stock: "Check stock at one store",
|
|
26
|
+
check_multi_item_stock: "Check several items at one store",
|
|
27
|
+
compare_store_stock: "Compare stock across stores",
|
|
28
|
+
find_best_store_for_item: "Find the best store for an item",
|
|
29
|
+
check_cart_availability: "Check a shopping list at one store",
|
|
30
|
+
find_best_store_for_cart: "Find the best store for a shopping list",
|
|
31
|
+
};
|
|
32
|
+
export function listTools() {
|
|
33
|
+
return tools.map(({ name, description, inputSchema }) => ({
|
|
34
|
+
name,
|
|
35
|
+
description,
|
|
36
|
+
inputSchema,
|
|
37
|
+
annotations: {
|
|
38
|
+
title: TITLES[name],
|
|
39
|
+
readOnlyHint: true,
|
|
40
|
+
destructiveHint: false,
|
|
41
|
+
idempotentHint: true,
|
|
42
|
+
openWorldHint: !CATALOG_ONLY.has(name),
|
|
43
|
+
},
|
|
21
44
|
}));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Runs one tool. An unknown tool name is a protocol error; anything a known tool throws (bad input, an upstream
|
|
48
|
+
* failure) comes back as a tool result with isError set, so the model can read it and correct the call.
|
|
49
|
+
*/
|
|
50
|
+
export async function callTool(name, args) {
|
|
51
|
+
const tool = tools.find((t) => t.name === name);
|
|
52
|
+
if (!tool) {
|
|
53
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return await tool.handler(args);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return toToolError(name, err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function createServer() {
|
|
63
|
+
const server = new Server({ name: "ikea-mcp", version }, { capabilities: { tools: {} } });
|
|
64
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools() }));
|
|
65
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => callTool(req.params.name, req.params.arguments));
|
|
29
66
|
return server;
|
|
30
67
|
}
|
package/dist/services/ikea.js
CHANGED
|
@@ -14,6 +14,7 @@ export function projectStock(result) {
|
|
|
14
14
|
const a = result.availabilities?.[0];
|
|
15
15
|
return {
|
|
16
16
|
availableForCashCarry: a?.availableForCashCarry ?? false,
|
|
17
|
+
inRange: a?.buyingOption?.cashCarry?.range?.inRange ?? null,
|
|
17
18
|
quantity: a?.buyingOption?.cashCarry?.availability?.quantity ?? null,
|
|
18
19
|
messageType: a?.buyingOption?.cashCarry?.availability?.probability?.thisDay?.messageType ?? null,
|
|
19
20
|
eligibleForStockNotification: a?.buyingOption?.cashCarry?.eligibleForStockNotification ?? null,
|
|
@@ -38,6 +39,7 @@ export function projectMultiItemStock(result, itemNos) {
|
|
|
38
39
|
return {
|
|
39
40
|
itemNo,
|
|
40
41
|
availableForCashCarry: false,
|
|
42
|
+
inRange: null,
|
|
41
43
|
quantity: null,
|
|
42
44
|
messageType: null,
|
|
43
45
|
eligibleForStockNotification: null,
|
|
@@ -49,6 +51,7 @@ export function projectMultiItemStock(result, itemNos) {
|
|
|
49
51
|
return {
|
|
50
52
|
itemNo,
|
|
51
53
|
availableForCashCarry: a.availableForCashCarry,
|
|
54
|
+
inRange: a.buyingOption?.cashCarry?.range?.inRange ?? null,
|
|
52
55
|
quantity: a.buyingOption?.cashCarry?.availability?.quantity ?? null,
|
|
53
56
|
messageType: a.buyingOption?.cashCarry?.availability?.probability?.thisDay?.messageType ?? null,
|
|
54
57
|
eligibleForStockNotification: a.buyingOption?.cashCarry?.eligibleForStockNotification ?? null,
|
|
@@ -64,6 +67,7 @@ export function projectMultiItemStock(result, itemNos) {
|
|
|
64
67
|
return {
|
|
65
68
|
itemNo,
|
|
66
69
|
availableForCashCarry: false,
|
|
70
|
+
inRange: null,
|
|
67
71
|
quantity: null,
|
|
68
72
|
messageType: null,
|
|
69
73
|
eligibleForStockNotification: null,
|
|
@@ -3,7 +3,7 @@ import { getMultiItemStock } from "../services/ikea.js";
|
|
|
3
3
|
import { storeLabel } from "../data/stores.js";
|
|
4
4
|
export const checkCartAvailabilityTool = {
|
|
5
5
|
name: "check_cart_availability",
|
|
6
|
-
description: "Check whether all items in a shopping list are available in sufficient quantity at a single IKEA store.",
|
|
6
|
+
description: "Check whether all items in a shopping list are available in sufficient quantity at a single IKEA store. inRange: false on an item means the item is not sold at that store or in that country's market; null means the upstream response carried no range information.",
|
|
7
7
|
inputSchema: {
|
|
8
8
|
type: "object",
|
|
9
9
|
properties: {
|
|
@@ -48,6 +48,7 @@ export const checkCartAvailabilityTool = {
|
|
|
48
48
|
itemNo: item.itemNo,
|
|
49
49
|
quantity: item.quantity,
|
|
50
50
|
inStock: null,
|
|
51
|
+
inRange: null,
|
|
51
52
|
sufficient: false,
|
|
52
53
|
eligibleForStockNotification: null,
|
|
53
54
|
errors: [{ code: storeError.code, message: storeError.message, meaning: "store ID does not exist" }],
|
|
@@ -55,13 +56,14 @@ export const checkCartAvailabilityTool = {
|
|
|
55
56
|
}
|
|
56
57
|
const a = byItemNo.get(item.itemNo);
|
|
57
58
|
if (a) {
|
|
58
|
-
const inStock = a.buyingOption
|
|
59
|
+
const inStock = a.buyingOption?.cashCarry?.availability?.quantity ?? null;
|
|
59
60
|
return {
|
|
60
61
|
itemNo: item.itemNo,
|
|
61
62
|
quantity: item.quantity,
|
|
62
63
|
inStock,
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
inRange: a.buyingOption?.cashCarry?.range?.inRange ?? null,
|
|
65
|
+
sufficient: inStock !== null && inStock >= item.quantity,
|
|
66
|
+
eligibleForStockNotification: a.buyingOption?.cashCarry?.eligibleForStockNotification ?? null,
|
|
65
67
|
errors: [],
|
|
66
68
|
};
|
|
67
69
|
}
|
|
@@ -70,6 +72,7 @@ export const checkCartAvailabilityTool = {
|
|
|
70
72
|
itemNo: item.itemNo,
|
|
71
73
|
quantity: item.quantity,
|
|
72
74
|
inStock: null,
|
|
75
|
+
inRange: null,
|
|
73
76
|
sufficient: false,
|
|
74
77
|
eligibleForStockNotification: null,
|
|
75
78
|
errors: err
|