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.
@@ -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
+ }
@@ -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
+ };
@@ -1,93 +1,122 @@
1
- // US store labels confirmed from ikea.com/us/en/stores/<slug>/ pages (store_id= survey param).
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": "026 (Canton, MI)",
13
- "027": "027 (Round Rock, TX)",
14
- "028": "028 (Portland, OR)",
15
- "042": "042 (Tampa, FL)",
16
- "064": "064 (Centennial, CO)",
17
- "067": "067 (Charlotte, NC)",
18
- "103": "103 (Draper, UT)",
19
- "145": "145 (Orlando, FL)",
20
- "152": "152 (Baltimore, MD)",
21
- "153": "153 (Pittsburgh, PA)",
22
- "154": "154 (Elizabeth, NJ)",
23
- "156": "156 (Long Island, NY)",
24
- "157": "157 (West Sacramento, CA)",
25
- "158": "158 (Stoughton, MA)",
26
- "162": "162 (Carson, CA)",
27
- "165": "165 (Emeryville, CA)",
28
- "166": "166 (San Diego, CA)",
29
- "167": "167 (Costa Mesa, CA)",
30
- "168": "168 (Woodbridge, VA)",
31
- "170": "170 (Bolingbrook, IL)",
32
- "175": "175 (West Chester, OH)",
33
- "183": "183 (Frisco, TX)",
34
- "207": "207 (Sunrise, FL)",
35
- "209": "209 (Tempe, AZ)",
36
- "210": "210 (Schaumburg, IL)",
37
- "211": "211 (Conshohocken, PA)",
38
- "212": "212 (Minneapolis, MN)",
39
- "213": "213 (New Haven, CT)",
40
- "215": "215 (South Philadelphia, PA)",
41
- "257": "257 (Atlanta, GA)",
42
- "327": "327 (Miami, FL)",
43
- "347": "347 (East Palo Alto, CA)",
44
- "374": "374 (Merriam, KS)",
45
- "379": "379 (Houston, TX)",
46
- "399": "399 (Burbank, CA)",
47
- "409": "409 (Paramus, NJ)",
48
- "410": "410 (St. Louis, MO)",
49
- "411": "411 (College Park, MD)",
50
- "413": "413 (Covina, CA)",
51
- "462": "462 (Las Vegas, NV)",
52
- "488": "488 (Renton, WA)",
53
- "508": "508 (Memphis, TN)",
54
- "511": "511 (Columbus, OH)",
55
- "535": "535 (Grand Prairie, TX)",
56
- "536": "536 (Fishers, IN)",
57
- "537": "537 (Jacksonville, FL)",
58
- "560": "560 (Oak Creek, WI)",
59
- "569": "569 (Norfolk, VA)",
60
- "570": "570 (Live Oak, TX)",
61
- "921": "921 (Brooklyn, NY)",
62
- "1099": "1099 (University Park, TX)",
63
- "1129": "1129 (Syracuse, NY)",
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": "003 (Richmond, BC, CA)",
68
- "004": "004 (Ottawa, ON, CA)",
69
- "039": "039 (Montreal, QC, CA)",
70
- "040": "040 (Burlington, ON, CA)",
71
- "149": "149 (North York, ON, CA)",
72
- "216": "216 (Calgary, AB, CA)",
73
- "249": "249 (Winnipeg, MB, CA)",
74
- "256": "256 (Etobicoke, ON, CA)",
75
- "313": "313 (Coquitlam, BC, CA)",
76
- "349": "349 (Edmonton, AB, CA)",
77
- "372": "372 (Vaughan, ON, CA)",
78
- "414": "414 (Boucherville, QC, CA)",
79
- "529": "529 (Halifax, NS, CA)",
80
- "559": "559 (Quebec City, QC, CA)",
81
- "659": "659 (Toronto Downtown, ON, CA)",
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
- return STORE_LABELS[storeId];
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 Object.entries(STORE_LABELS)
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
  }
@@ -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 two countries supported by the
31
- // stock-lookup API. Accepts "us"/"ca" and normalises to "US"/"CA".
32
- const CountryCodeEnum = z.preprocess(toUpper, z.enum(["US", "CA"]));
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.object({
38
+ export const SearchProductsInput = z
39
+ .object({
35
40
  query: z.string().min(1),
36
41
  countryCode: CountryCode2.default("US"),
37
- langCode: z.string().length(2).default("en"),
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
- export const FindBestStoreInput = z.object({
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
- export const GetProductDetailsInput = z.object({
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: z.string().length(2).default("en"),
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.object({
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
- const tools = [listStoresTool, searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool, checkCartAvailabilityTool, findBestStoreForCartTool];
13
- export function createServer() {
14
- const server = new Server({ name: "ikea-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
15
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
16
- tools: tools.map(({ name, description, inputSchema }) => ({
17
- name,
18
- description,
19
- inputSchema,
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
- server.setRequestHandler(CallToolRequestSchema, async (req) => {
23
- const tool = tools.find((t) => t.name === req.params.name);
24
- if (!tool) {
25
- throw new Error(`Unknown tool: ${req.params.name}`);
26
- }
27
- return tool.handler(req.params.arguments);
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
  }
@@ -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.cashCarry.availability.quantity;
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
- sufficient: inStock >= item.quantity,
64
- eligibleForStockNotification: a.buyingOption.cashCarry.eligibleForStockNotification ?? null,
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