ikea-mcp 1.3.0 → 1.4.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/README.md CHANGED
@@ -8,6 +8,7 @@ Read-only MCP server for IKEA product search and in-store stock lookup.
8
8
  ## Capabilities
9
9
  | Tool | What it does |
10
10
  |---|---|
11
+ | `list_stores` | List known store IDs and labels, optionally filtered by country |
11
12
  | `search_products` | Search IKEA products by keyword |
12
13
  | `get_product_details` | Get details for a single product by item number |
13
14
  | `check_store_stock` | Check cash-and-carry stock at one store |
@@ -32,7 +33,7 @@ Search IKEA products by keyword.
32
33
  | param | type | default | required |
33
34
  |---|---|---|---|
34
35
  | `query` | string | — | yes |
35
- | `countryCode` | string | `"us"` | no |
36
+ | `countryCode` | string | `"US"` | no |
36
37
  | `langCode` | string | `"en"` | no |
37
38
  | `size` | number | `10` | no |
38
39
 
@@ -64,7 +65,7 @@ Get details for a single IKEA product by item number.
64
65
  | param | type | default | required |
65
66
  |---|---|---|---|
66
67
  | `itemNo` | string | — | yes |
67
- | `countryCode` | string | `"us"` | no |
68
+ | `countryCode` | string | `"US"` | no |
68
69
  | `langCode` | string | `"en"` | no |
69
70
 
70
71
  **Output**
@@ -95,7 +96,7 @@ Check stock at a single IKEA store.
95
96
  |---|---|---|---|
96
97
  | `itemNo` | string | — | yes |
97
98
  | `storeId` | string | — | yes |
98
- | `countryCode` | string | `"us"` | no |
99
+ | `countryCode` | string | `"US"` | no |
99
100
 
100
101
  **Output**
101
102
  ```json
@@ -131,8 +132,10 @@ Compare stock for one item across multiple stores. Provide explicit `storeIds`,
131
132
  | `itemNo` | string | — | yes |
132
133
  | `storeIds` | string[] (min 2) | — | one of `storeIds`/`countryCode` |
133
134
  | `countryCode` | `"US"` \| `"CA"` | — | one of `storeIds`/`countryCode` |
135
+ | `sortBy` | `"quantity"` \| `"storeId"` | — | no |
134
136
 
135
137
  `storeIds` takes precedence — if both are provided, `countryCode` only sets the IKEA API locale.
138
+ `sortBy: "quantity"` sorts descending, null quantities last, `storeId` as tie-breaker. `sortBy: "storeId"` sorts ascending. Omitting `sortBy` preserves input order.
136
139
 
137
140
  **Examples**
138
141
  ```json
@@ -144,6 +147,8 @@ Compare stock for one item across multiple stores. Provide explicit `storeIds`,
144
147
 
145
148
  **Output** — array of the same shape as `check_store_stock` (one entry per store).
146
149
 
150
+ **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.
151
+
147
152
  ---
148
153
 
149
154
  ### `check_multi_item_stock`
@@ -185,10 +190,11 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
185
190
  |---|---|---|---|
186
191
  | `itemNo` | string | — | yes |
187
192
  | `storeIds` | string[] | all known stores | no |
188
- | `maxResults` | number | `3` (max 10) | no |
193
+ | `maxResults` | number | `3` (max 50) | no |
189
194
  | `countryCode` | `"US"` \| `"CA"` | — | no |
195
+ | `minQuantity` | number (int ≥ 1) | — | no |
190
196
 
191
- `storeIds` takes precedence. If only `countryCode` is given, searches all catalog stores for that country. If neither is given, searches all ~65 known stores.
197
+ `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.
192
198
 
193
199
  **Output** — array of matching stores, up to `maxResults`:
194
200
  ```json
@@ -205,6 +211,8 @@ Find stores with the highest in-stock quantity for an item. Queries stores in pa
205
211
 
206
212
  Returns `[]` if no store has the item in stock. "All known stores" means the ~65 US and Canada entries in `src/data/stores.ts`.
207
213
 
214
+ **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.
215
+
208
216
  ---
209
217
 
210
218
  ## Example workflows
@@ -378,6 +386,46 @@ An invalid or unsupported `storeId` returns a 405 error in the `errors` array.
378
386
 
379
387
  - Uses unofficial public IKEA APIs — no SLA, no auth required, may break without notice.
380
388
  - Read-only: no cart, no order, no account operations.
381
- - `compare_store_stock` fires all store requests in parallel — large `storeIds` arrays or a full `countryCode` expansion may hit rate limits.
389
+ - Country-wide fan-out (`countryCode: "US"` ≈ 52 stores, `"CA"` ≈ 15) is capped at 10 concurrent requests and retries once on transient 5xx/network errors.
382
390
  - Click-and-collect and home-delivery availability are not exposed (cash-and-carry only).
383
391
  - `size` in `search_products` is capped by IKEA's API (observed max ~24 per page; `total` reflects the full catalogue count).
392
+ - US and Canada only — no other countries supported.
393
+
394
+ ## Item numbers
395
+
396
+ `itemNo` fields accept several formats — all are normalised to 8 digits internally:
397
+
398
+ | Input | Normalised |
399
+ |---|---|
400
+ | `"20522046"` | `"20522046"` |
401
+ | `"522132"` | `"00522132"` |
402
+ | `"005.221.32"` | `"00522132"` |
403
+ | `"5-221-32"` | `"00522132"` |
404
+
405
+ 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.
406
+
407
+ ## Supported countries
408
+
409
+ | Country | Code | Store count |
410
+ |---|---|---|
411
+ | United States | `US` | ~52 |
412
+ | Canada | `CA` | ~15 |
413
+
414
+ 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.
415
+
416
+ ## Rate limits & reliability
417
+
418
+ - Fan-out requests (country-wide `compare_store_stock` / `find_best_store_for_item`) are capped at **10 concurrent** outbound requests.
419
+ - `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).
420
+ - `Retry-After` header is respected for 429 responses.
421
+ - Do not use in high-frequency loops — the upstream IKEA API has no published rate limit but will block repeated bursts.
422
+
423
+ ## Troubleshooting
424
+
425
+ | Symptom | Likely cause |
426
+ |---|---|
427
+ | `405` in `errors` | Invalid `storeId` — use `list_stores` to find valid IDs |
428
+ | `404` in `errors` | Item not stocked at that store |
429
+ | Empty `find_best_store_for_item` result | No store has the item in stock, or `minQuantity` is too high |
430
+ | Slow `countryCode` query | Normal — fan-out to all country stores (capped at 10 concurrent) |
431
+ | `itemNo` validation error | Input must resolve to 6–9 digits; see Item numbers above |
@@ -1,32 +1,71 @@
1
1
  import { z } from "zod";
2
+ // ── normalizeItemNo ───────────────────────────────────────────────────────────
3
+ /**
4
+ * Strips non-digit characters, then left-pads to 8 digits when the result is
5
+ * 6 or 7 digits long. 8- and 9-digit results are kept as-is. All other lengths
6
+ * are left unchanged and will be rejected by the downstream Zod regex.
7
+ *
8
+ * Exported so it can be unit-tested independently.
9
+ */
10
+ export function normalizeItemNo(raw) {
11
+ const digits = raw.replace(/\D/g, "");
12
+ return digits.length === 6 || digits.length === 7
13
+ ? digits.padStart(8, "0")
14
+ : digits;
15
+ }
16
+ // Zod schema for a single IKEA item number.
17
+ // Accepts dotted ("005.221.32"), hyphenated ("5-221-32"), and plain ("522132") inputs.
18
+ // Rejects empty strings, <6-digit results, and >9-digit results.
19
+ const ItemNo = z
20
+ .string()
21
+ .transform(normalizeItemNo)
22
+ .pipe(z
23
+ .string()
24
+ .regex(/^\d{8,9}$/, "itemNo must resolve to 8 or 9 digits after stripping non-digit characters"));
25
+ // ── countryCode helpers ───────────────────────────────────────────────────────
26
+ const toUpper = (v) => typeof v === "string" ? v.toUpperCase() : v;
27
+ // Free-form 2-letter ISO country code normalised to uppercase.
28
+ // Used by search and product-detail tools that accept any country code.
29
+ 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"]));
33
+ // ── Input schemas ─────────────────────────────────────────────────────────────
2
34
  export const SearchProductsInput = z.object({
3
35
  query: z.string().min(1),
4
- countryCode: z.string().length(2).default("us"),
36
+ countryCode: CountryCode2.default("US"),
5
37
  langCode: z.string().length(2).default("en"),
6
38
  size: z.number().int().min(1).max(50).default(10),
7
39
  });
8
40
  export const CheckStoreStockInput = z.object({
9
- itemNo: z.string().min(1),
41
+ itemNo: ItemNo,
10
42
  storeId: z.string().min(1),
11
- countryCode: z.string().length(2).default("us"),
43
+ countryCode: CountryCode2.default("US"),
12
44
  });
13
- export const CompareStoreStockInput = z.object({
14
- itemNo: z.string().min(1),
45
+ export const CompareStoreStockInput = z
46
+ .object({
47
+ itemNo: ItemNo,
15
48
  storeIds: z.array(z.string().min(1)).min(2).max(10).optional(),
16
- countryCode: z.enum(["US", "CA"]).optional(),
17
- }).refine((d) => d.storeIds !== undefined || d.countryCode !== undefined, { message: "provide storeIds or countryCode" });
49
+ countryCode: CountryCodeEnum.optional(),
50
+ sortBy: z.enum(["quantity", "storeId"]).optional(),
51
+ })
52
+ .refine((d) => d.storeIds !== undefined || d.countryCode !== undefined, { message: "provide storeIds or countryCode" });
53
+ export const ListStoresInput = z.object({
54
+ countryCode: CountryCodeEnum.optional(),
55
+ });
18
56
  export const FindBestStoreInput = z.object({
19
- itemNo: z.string().min(1),
57
+ itemNo: ItemNo,
20
58
  storeIds: z.array(z.string().min(1)).optional(),
21
- maxResults: z.number().int().min(1).max(10).default(3),
22
- countryCode: z.enum(["US", "CA"]).optional(),
59
+ maxResults: z.number().int().min(1).max(50).default(3),
60
+ countryCode: CountryCodeEnum.optional(),
61
+ minQuantity: z.number().int().min(1).optional(),
23
62
  });
24
63
  export const GetProductDetailsInput = z.object({
25
- itemNo: z.string().min(1),
26
- countryCode: z.string().length(2).default("us"),
64
+ itemNo: ItemNo,
65
+ countryCode: CountryCode2.default("US"),
27
66
  langCode: z.string().length(2).default("en"),
28
67
  });
29
68
  export const CheckMultiItemStockInput = z.object({
30
69
  storeId: z.string().min(1),
31
- itemNos: z.array(z.string().min(1)).min(1).max(20),
70
+ itemNos: z.array(ItemNo).min(1).max(20),
32
71
  });
package/dist/server.js CHANGED
@@ -6,7 +6,8 @@ import { compareStoreStockTool } from "./tools/compare_store_stock.js";
6
6
  import { findBestStoreForItemTool } from "./tools/find_best_store_for_item.js";
7
7
  import { getProductDetailsTool } from "./tools/get_product_details.js";
8
8
  import { checkMultiItemStockTool } from "./tools/check_multi_item_stock.js";
9
- const tools = [searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool];
9
+ import { listStoresTool } from "./tools/list_stores.js";
10
+ const tools = [listStoresTool, searchProductsTool, checkStoreStockTool, compareStoreStockTool, findBestStoreForItemTool, getProductDetailsTool, checkMultiItemStockTool];
10
11
  export function createServer() {
11
12
  const server = new Server({ name: "ikea-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
12
13
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -16,6 +16,7 @@ export function projectStock(result) {
16
16
  availableForCashCarry: a?.availableForCashCarry ?? false,
17
17
  quantity: a?.buyingOption.cashCarry.availability.quantity ?? null,
18
18
  messageType: a?.buyingOption.cashCarry.availability.probability.thisDay.messageType ?? null,
19
+ eligibleForStockNotification: a?.buyingOption.cashCarry.eligibleForStockNotification ?? null,
19
20
  errors: annotateStockErrors(result.errors) ?? [],
20
21
  };
21
22
  }
@@ -15,11 +15,10 @@ export const checkMultiItemStockTool = {
15
15
  async handler(rawInput) {
16
16
  const input = CheckMultiItemStockInput.parse(rawInput);
17
17
  const result = await getMultiItemStock(input.itemNos, input.storeId);
18
- const label = storeLabel(input.storeId);
19
18
  const rows = projectMultiItemStock(result, input.itemNos).map((row) => ({
20
19
  itemNo: row.itemNo,
21
20
  storeId: input.storeId,
22
- ...(label ? { storeLabel: label } : {}),
21
+ storeLabel: storeLabel(input.storeId) ?? input.storeId,
23
22
  availableForCashCarry: row.availableForCashCarry,
24
23
  quantity: row.quantity,
25
24
  messageType: row.messageType,
@@ -16,10 +16,9 @@ export const checkStoreStockTool = {
16
16
  async handler(rawInput) {
17
17
  const input = CheckStoreStockInput.parse(rawInput);
18
18
  const result = await getStoreStock(input.itemNo, input.storeId, input.countryCode);
19
- const label = storeLabel(input.storeId);
20
19
  const output = {
21
20
  storeId: input.storeId,
22
- ...(label ? { storeLabel: label } : {}),
21
+ storeLabel: storeLabel(input.storeId) ?? input.storeId,
23
22
  ...projectStock(result),
24
23
  };
25
24
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
@@ -1,6 +1,7 @@
1
1
  import { CompareStoreStockInput } from "../schemas/index.js";
2
2
  import { getStoreStock, projectStock } from "../services/ikea.js";
3
3
  import { storeLabel, storeIdsByCountry } from "../data/stores.js";
4
+ import { pMap } from "../utils/http.js";
4
5
  export const compareStoreStockTool = {
5
6
  name: "compare_store_stock",
6
7
  description: "Compare stock availability for an item across multiple IKEA stores. Provide explicit storeIds, or use countryCode ('US' or 'CA') to compare all catalog stores for that country.",
@@ -10,6 +11,7 @@ export const compareStoreStockTool = {
10
11
  itemNo: { type: "string" },
11
12
  storeIds: { type: "array", items: { type: "string" }, minItems: 2 },
12
13
  countryCode: { type: "string", enum: ["US", "CA"] },
14
+ sortBy: { type: "string", enum: ["quantity", "storeId"] },
13
15
  },
14
16
  required: ["itemNo"],
15
17
  },
@@ -18,14 +20,20 @@ export const compareStoreStockTool = {
18
20
  const targetIds = input.storeIds
19
21
  ?? storeIdsByCountry(input.countryCode);
20
22
  const apiCountry = input.countryCode?.toLowerCase() ?? "us";
21
- const results = await Promise.all(targetIds.map((storeId) => getStoreStock(input.itemNo, storeId, apiCountry).then((data) => {
22
- const label = storeLabel(storeId);
23
- return {
24
- storeId,
25
- ...(label ? { storeLabel: label } : {}),
26
- ...projectStock(data),
27
- };
28
- })));
23
+ const results = await pMap(targetIds, (storeId) => getStoreStock(input.itemNo, storeId, apiCountry).then((data) => ({
24
+ storeId,
25
+ storeLabel: storeLabel(storeId) ?? storeId,
26
+ ...projectStock(data),
27
+ })), 10);
28
+ if (input.sortBy === "quantity") {
29
+ results.sort((a, b) => {
30
+ const diff = (b.quantity ?? -Infinity) - (a.quantity ?? -Infinity);
31
+ return diff !== 0 ? diff : a.storeId.localeCompare(b.storeId);
32
+ });
33
+ }
34
+ else if (input.sortBy === "storeId") {
35
+ results.sort((a, b) => a.storeId.localeCompare(b.storeId));
36
+ }
29
37
  return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
30
38
  },
31
39
  };
@@ -1,6 +1,7 @@
1
1
  import { FindBestStoreInput } from "../schemas/index.js";
2
2
  import { getStoreStock, projectStock } from "../services/ikea.js";
3
3
  import { STORE_LABELS, storeLabel, storeIdsByCountry } from "../data/stores.js";
4
+ import { pMap } from "../utils/http.js";
4
5
  export const findBestStoreForItemTool = {
5
6
  name: "find_best_store_for_item",
6
7
  description: "Find stores with the highest in-stock quantity for an item. Returns up to maxResults stores sorted by quantity descending. Optionally filter by countryCode ('US' or 'CA'). Explicit storeIds take precedence over countryCode.",
@@ -11,6 +12,7 @@ export const findBestStoreForItemTool = {
11
12
  storeIds: { type: "array", items: { type: "string" } },
12
13
  maxResults: { type: "number", default: 3 },
13
14
  countryCode: { type: "string", enum: ["US", "CA"] },
15
+ minQuantity: { type: "number" },
14
16
  },
15
17
  required: ["itemNo"],
16
18
  },
@@ -18,28 +20,27 @@ export const findBestStoreForItemTool = {
18
20
  const input = FindBestStoreInput.parse(rawInput);
19
21
  const targetIds = input.storeIds
20
22
  ?? (input.countryCode ? storeIdsByCountry(input.countryCode) : Object.keys(STORE_LABELS));
21
- const results = await Promise.all(targetIds.map(async (storeId) => {
23
+ const results = await pMap(targetIds, async (storeId) => {
22
24
  const data = await getStoreStock(input.itemNo, storeId, "us");
23
25
  const stock = projectStock(data);
24
26
  return { storeId, stock };
25
- }));
27
+ }, 10);
26
28
  const ranked = results
27
- .filter((r) => r.stock.availableForCashCarry && r.stock.quantity !== null)
29
+ .filter((r) => r.stock.availableForCashCarry &&
30
+ r.stock.quantity !== null &&
31
+ (input.minQuantity === undefined || r.stock.quantity >= input.minQuantity))
28
32
  .sort((a, b) => {
29
33
  const diff = (b.stock.quantity ?? 0) - (a.stock.quantity ?? 0);
30
34
  return diff !== 0 ? diff : a.storeId.localeCompare(b.storeId);
31
35
  })
32
36
  .slice(0, input.maxResults)
33
- .map(({ storeId, stock }) => {
34
- const label = storeLabel(storeId);
35
- return {
36
- storeId,
37
- ...(label ? { storeLabel: label } : {}),
38
- availableForCashCarry: stock.availableForCashCarry,
39
- quantity: stock.quantity,
40
- messageType: stock.messageType,
41
- };
42
- });
37
+ .map(({ storeId, stock }) => ({
38
+ storeId,
39
+ storeLabel: storeLabel(storeId) ?? storeId,
40
+ availableForCashCarry: stock.availableForCashCarry,
41
+ quantity: stock.quantity,
42
+ messageType: stock.messageType,
43
+ }));
43
44
  return { content: [{ type: "text", text: JSON.stringify(ranked, null, 2) }] };
44
45
  },
45
46
  };
@@ -0,0 +1,30 @@
1
+ import { ListStoresInput } from "../schemas/index.js";
2
+ import { STORE_LABELS, storeLabel, storeIdsByCountry } from "../data/stores.js";
3
+ export const listStoresTool = {
4
+ name: "list_stores",
5
+ description: "List known IKEA store IDs and display names. Use countryCode ('US' or 'CA') to filter by country, or omit to return all stores.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ countryCode: { type: "string", enum: ["US", "CA"] },
10
+ },
11
+ },
12
+ async handler(rawInput) {
13
+ const input = ListStoresInput.parse(rawInput);
14
+ const storeIds = input.countryCode
15
+ ? storeIdsByCountry(input.countryCode)
16
+ : Object.keys(STORE_LABELS);
17
+ // When returning all stores, build a CA set for tagging.
18
+ const caSet = input.countryCode === "CA"
19
+ ? new Set(storeIds)
20
+ : input.countryCode === "US"
21
+ ? new Set()
22
+ : new Set(storeIdsByCountry("CA"));
23
+ const stores = storeIds.map((storeId) => ({
24
+ storeId,
25
+ storeLabel: storeLabel(storeId) ?? storeId,
26
+ countryCode: caSet.has(storeId) ? "CA" : "US",
27
+ }));
28
+ return { content: [{ type: "text", text: JSON.stringify({ stores }, null, 2) }] };
29
+ },
30
+ };
@@ -1,12 +1,52 @@
1
- export async function fetchJson(url, headers) {
2
- const res = await fetch(url, {
3
- headers: {
4
- Accept: "application/json",
5
- ...headers,
6
- },
7
- });
1
+ const RETRY_DELAY_MS = 500;
2
+ function shouldRetry(status) {
3
+ return status === 429 || status >= 500;
4
+ }
5
+ async function attempt(url, headers) {
6
+ const res = await fetch(url, { headers: { Accept: "application/json", ...headers } });
8
7
  if (!res.ok) {
8
+ if (shouldRetry(res.status)) {
9
+ const retryAfter = Number(res.headers.get("Retry-After") ?? 0) * 1000;
10
+ throw Object.assign(new Error(`HTTP ${res.status} ${res.statusText}: ${url}`), {
11
+ retryable: true,
12
+ delay: retryAfter > 0 ? retryAfter : RETRY_DELAY_MS,
13
+ });
14
+ }
9
15
  throw new Error(`HTTP ${res.status} ${res.statusText}: ${url}`);
10
16
  }
11
17
  return res.json();
12
18
  }
19
+ export async function fetchJson(url, headers = {}) {
20
+ try {
21
+ return await attempt(url, headers);
22
+ }
23
+ catch (err) {
24
+ const e = err;
25
+ if (e.retryable) {
26
+ await new Promise((r) => setTimeout(r, e.delay ?? RETRY_DELAY_MS));
27
+ return attempt(url, headers);
28
+ }
29
+ // Network-level failures (TypeError from fetch) also get one retry.
30
+ if (err instanceof TypeError) {
31
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
32
+ return attempt(url, headers);
33
+ }
34
+ throw err;
35
+ }
36
+ }
37
+ /**
38
+ * Runs tasks with at most `limit` concurrent promises, preserving input order.
39
+ */
40
+ export async function pMap(items, fn, limit) {
41
+ const results = new Array(items.length);
42
+ let next = 0;
43
+ async function worker() {
44
+ while (next < items.length) {
45
+ const i = next++;
46
+ results[i] = await fn(items[i]);
47
+ }
48
+ }
49
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
50
+ await Promise.all(workers);
51
+ return results;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikea-mcp",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Read-only MCP server for IKEA product search and in-store stock lookup. Supports stdio and Streamable HTTP transports.",
5
5
  "license": "MIT",
6
6
  "type": "module",