@binaryandthread/mcp 0.3.2 → 0.4.1

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
@@ -104,3 +104,10 @@ Claude renders MCP `ImageContent` natively on claude.ai / the API, but Claude Co
104
104
  bug ([#31208](https://github.com/anthropics/claude-code/issues/31208), ~15–25k tokens/image) and can
105
105
  fail to render it. So `get_product` always emits the **Blob URL as a text block too**, the reliable
106
106
  floor. The buy is never blocked on rendering.
107
+
108
+ ## Fits
109
+
110
+ Some designs are sold in two fits, **regular** and **oversize**, priced apart. `list_products` marks them
111
+ (`fits`, `price_from`), `get_product` lists sizes and prices per fit (pass `fit` to narrow), and
112
+ `add_to_cart` **requires** `fit` for them: without it the tool returns an error naming both fits, and
113
+ nothing is added. Colours follow the ship-to region (khaki is available on the oversize fit in the US only).
package/dist/bin.js CHANGED
@@ -545,6 +545,116 @@ function isAllowedImageHost(url) {
545
545
  (allowed) => host === allowed || host.endsWith("." + allowed)
546
546
  );
547
547
  }
548
+ var FITS = ["regular", "oversize"];
549
+ function parseFit(v) {
550
+ const s = typeof v === "string" ? v.trim().toLowerCase() : "";
551
+ return s === "regular" || s === "oversize" ? s : null;
552
+ }
553
+ function fitLabel(f) {
554
+ return f === "oversize" ? "Oversize" : "Regular";
555
+ }
556
+ function variantFit(v) {
557
+ const opt = (v.options ?? []).find((o) => (o.option?.title ?? "").toLowerCase() === "fit");
558
+ return opt ? parseFit(opt.value) : null;
559
+ }
560
+ function productFits(p) {
561
+ const seen = /* @__PURE__ */ new Set();
562
+ for (const v of p.variants ?? []) {
563
+ const f = variantFit(v);
564
+ if (f) seen.add(f);
565
+ }
566
+ return FITS.filter((f) => seen.has(f));
567
+ }
568
+ var BLANK_COLOURS = {
569
+ "gildan-5000": ["black", "white"],
570
+ "sols-03996": ["black", "white"],
571
+ "shaka-shmhss": ["black", "white", "khaki"]
572
+ };
573
+ function blankFor(fit, countryCode) {
574
+ if (fit === "regular") return "gildan-5000";
575
+ return (countryCode ?? "").trim().toLowerCase() === "us" ? "shaka-shmhss" : "sols-03996";
576
+ }
577
+ function colourBuyable(fit, colour, countryCode) {
578
+ return BLANK_COLOURS[blankFor(fit, countryCode)].includes(colour.trim().toLowerCase());
579
+ }
580
+ function variantsOfFit(p, fit) {
581
+ const all = p.variants ?? [];
582
+ return fit === null ? all : all.filter((v) => variantFit(v) === fit);
583
+ }
584
+ function coloursFor(p, fit, countryCode) {
585
+ const out = [];
586
+ for (const v of variantsOfFit(p, fit)) {
587
+ const c = variantColour(v);
588
+ if (!c || out.includes(c)) continue;
589
+ if (fit && countryCode !== void 0 && !colourBuyable(fit, c, countryCode)) continue;
590
+ out.push(c);
591
+ }
592
+ return out;
593
+ }
594
+ function sizesFor(p, fit, colour) {
595
+ const target = colour.trim().toLowerCase();
596
+ const out = [];
597
+ for (const v of variantsOfFit(p, fit)) {
598
+ if (variantColour(v) !== target) continue;
599
+ const s = sizeOfVariant(v);
600
+ if (s && !out.includes(s)) out.push(s);
601
+ }
602
+ return out;
603
+ }
604
+ function cheapest(vs) {
605
+ const priced = vs.filter((v2) => typeof v2.calculated_price?.calculated_amount === "number").sort(
606
+ (a, b) => a.calculated_price.calculated_amount - b.calculated_price.calculated_amount
607
+ );
608
+ const v = priced[0];
609
+ return {
610
+ amount: v?.calculated_price?.calculated_amount ?? null,
611
+ currency: v?.calculated_price?.currency_code ?? null,
612
+ taxInclusive: v?.calculated_price?.is_calculated_price_tax_inclusive ?? null
613
+ };
614
+ }
615
+ function priceFrom(p) {
616
+ const fits = productFits(p);
617
+ const base = cheapest(p.variants ?? []);
618
+ const amounts = new Set(fits.map((f) => cheapest(variantsOfFit(p, f)).amount));
619
+ return { ...base, from: fits.length > 1 && amounts.size > 1 };
620
+ }
621
+ function priceFor(p, fit) {
622
+ return cheapest(variantsOfFit(p, fit));
623
+ }
624
+ function resolveVariant(p, opts) {
625
+ const fits = productFits(p);
626
+ const requested = parseFit(opts.fit);
627
+ const askedFor = typeof opts.fit === "string" ? opts.fit.trim() : "";
628
+ let fit = null;
629
+ if (fits.length > 0) {
630
+ if (requested && fits.includes(requested)) fit = requested;
631
+ else if (askedFor) return { ok: false, reason: "fit_not_offered", requested: askedFor, fits };
632
+ else if (fits.length === 1) fit = fits[0];
633
+ else return { ok: false, reason: "ambiguous_fit", fits: fits.map((f) => ({ fit: f, ...priceFor(p, f) })) };
634
+ }
635
+ const pool = variantsOfFit(p, fit);
636
+ const colours = coloursFor(p, fit, opts.countryCode);
637
+ const colour = (opts.colour ?? "").trim().toLowerCase() || (colours[0] ?? null);
638
+ if (fit && colour && opts.countryCode !== void 0 && !colourBuyable(fit, colour, opts.countryCode)) {
639
+ return { ok: false, reason: "fit_unavailable", fit, colour };
640
+ }
641
+ const size = (opts.size ?? "").trim() || null;
642
+ const sizeLc = size?.toLowerCase();
643
+ const v = pool.find((x) => {
644
+ const colourOk = !colour || variantColour(x) === colour;
645
+ const sizeOk = !sizeLc || (sizeOfVariant(x) ?? "").toLowerCase() === sizeLc || (x.title ?? "").toLowerCase() === sizeLc;
646
+ return colourOk && sizeOk;
647
+ });
648
+ if (!v) {
649
+ return { ok: false, reason: "not_found", fit, colour, size, colours, sizes: colour ? sizesFor(p, fit, colour) : [] };
650
+ }
651
+ return { ok: true, variant: v, fit, colour: variantColour(v), size: sizeOfVariant(v) };
652
+ }
653
+ var SIZE_TOKEN = /^(xxs|xs|s|m|l|xl|xxl|2xl|3xl|xxxl|4xl|5xl)$/i;
654
+ var FIT_TOKEN = /^(regular|oversize)$/i;
655
+ function titleSegments(title) {
656
+ return (title ?? "").split("/").map((s) => s.trim()).filter(Boolean);
657
+ }
548
658
  function variantColour(v) {
549
659
  const opt = (v.options ?? []).find(
550
660
  (o) => (o.option?.title ?? "").toLowerCase() === "colour" || (o.option?.title ?? "").toLowerCase() === "color"
@@ -552,11 +662,10 @@ function variantColour(v) {
552
662
  if (opt && typeof opt.value === "string" && opt.value.trim()) {
553
663
  return opt.value.trim().toLowerCase();
554
664
  }
555
- const title = (v.title ?? "").trim();
556
- if (title.includes("/")) {
557
- const parts = title.split("/").map((p) => p.trim());
558
- const tail = parts[parts.length - 1];
559
- if (tail) return tail.toLowerCase();
665
+ const segs = titleSegments(v.title);
666
+ if (segs.length >= 2) {
667
+ const seg = segs.find((s) => !SIZE_TOKEN.test(s) && !FIT_TOKEN.test(s));
668
+ if (seg) return seg.toLowerCase();
560
669
  }
561
670
  return null;
562
671
  }
@@ -587,35 +696,76 @@ function sizeOfVariant(v) {
587
696
  if (opt && typeof opt.value === "string" && opt.value.trim()) {
588
697
  return opt.value.trim();
589
698
  }
590
- const title = (v.title ?? "").trim();
591
- if (title.includes("/")) {
592
- const head = title.split("/")[0]?.trim();
593
- if (head) return head;
699
+ const segs = titleSegments(v.title);
700
+ const token = segs.find((s) => SIZE_TOKEN.test(s));
701
+ if (token) return token;
702
+ return segs.length === 1 ? segs[0] : segs[0] ?? null;
703
+ }
704
+ var PREFERRED_SCENE = "plp";
705
+ function sceneImage(images, scene, colour) {
706
+ for (const img of images ?? []) {
707
+ const u = (img?.url ?? "").trim();
708
+ if (!u || !u.toLowerCase().includes(`-${scene}-`)) continue;
709
+ if (colour === null || new RegExp(`(^|[^a-z])${escapeRegex(colour)}([^a-z]|$)`, "i").test(u.toLowerCase())) {
710
+ return u;
711
+ }
594
712
  }
595
- return title || null;
713
+ return null;
596
714
  }
597
- function findVariant(p, opts = {}) {
598
- const variants = p.variants ?? [];
599
- const size = opts.size?.trim().toLowerCase();
600
- const colour = opts.colour?.trim().toLowerCase();
601
- const sizeMatches = (v) => {
602
- if (!size) return true;
603
- return (sizeOfVariant(v) ?? "").toLowerCase() === size || (v.title ?? "").toLowerCase() === size;
604
- };
605
- const colourMatches = (v) => {
606
- if (!colour) return true;
607
- return variantColour(v) === colour;
608
- };
609
- if (size && colour) {
610
- return variants.find((v) => sizeMatches(v) && colourMatches(v));
715
+ function imageForColour(p, colour) {
716
+ const target = colour.trim().toLowerCase();
717
+ if (!target) {
718
+ return sceneImage(p.images, PREFERRED_SCENE, null) ?? p.thumbnail ?? firstImageUrl(p.images) ?? null;
611
719
  }
612
- if (colour && !size) {
613
- return variants.find((v) => colourMatches(v));
720
+ const plp = sceneImage(p.images, PREFERRED_SCENE, target);
721
+ if (plp) return plp;
722
+ for (const v of p.variants ?? []) {
723
+ if (variantColour(v) !== target) continue;
724
+ const url = firstImageUrl(v.images);
725
+ if (url) return url;
726
+ }
727
+ for (const img of p.images ?? []) {
728
+ const tag = (img.metadata?.["colour"] ?? img.metadata?.["color"] ?? "").toString().toLowerCase();
729
+ if (tag === target) {
730
+ const u = (img.url ?? "").trim();
731
+ if (u) return u;
732
+ }
614
733
  }
615
- if (size && !colour) {
616
- return variants.find((v) => sizeMatches(v));
734
+ for (const img of p.images ?? []) {
735
+ const u = (img.url ?? "").trim();
736
+ if (u && new RegExp(`(^|[^a-z])${escapeRegex(target)}([^a-z]|$)`, "i").test(u)) {
737
+ return u;
738
+ }
617
739
  }
618
- return variants[0];
740
+ return null;
741
+ }
742
+ function imageFor(p, sel) {
743
+ const colour = sel.colour.trim().toLowerCase();
744
+ if (!colour) return null;
745
+ const metas = (p.images ?? []).map((img) => ({
746
+ url: (img?.url ?? "").trim(),
747
+ scene: typeof img?.metadata?.["scene"] === "string" ? String(img.metadata["scene"]) : null,
748
+ fit: parseFit(img?.metadata?.["fit"]),
749
+ colour: typeof img?.metadata?.["colour"] === "string" ? String(img.metadata["colour"]).toLowerCase() : null
750
+ }));
751
+ const tagged = metas.filter((m) => m.url && m.colour === colour && m.scene === PREFERRED_SCENE);
752
+ const exact = tagged.find((m) => sel.fit !== null && m.fit === sel.fit);
753
+ if (exact) return exact.url;
754
+ const neutral = tagged.find((m) => m.fit === null);
755
+ if (neutral) return neutral.url;
756
+ const otherFitOnly = tagged.length > 0 && sel.fit !== null;
757
+ if (otherFitOnly) return null;
758
+ return imageForColour(p, colour);
759
+ }
760
+ function firstImageUrl(images) {
761
+ for (const img of images ?? []) {
762
+ const u = (img?.url ?? "").trim();
763
+ if (u) return u;
764
+ }
765
+ return null;
766
+ }
767
+ function escapeRegex(s) {
768
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
619
769
  }
620
770
  function str(v, fallback) {
621
771
  return typeof v === "string" && v.length > 0 ? v : fallback;
@@ -623,15 +773,19 @@ function str(v, fallback) {
623
773
  function productReadme(p) {
624
774
  return typeof p.description === "string" ? p.description : "";
625
775
  }
626
- function productSpecs(p) {
776
+ function productSpecs(p, fit) {
627
777
  const md = p.metadata ?? {};
778
+ const byFit = md.specs_by_fit ?? null;
779
+ const fitKey = fit ?? (byFit && Object.keys(byFit).length === 1 ? Object.keys(byFit)[0] : void 0);
780
+ const src = fitKey && byFit?.[fitKey] || md;
781
+ const pick = (k, fallback) => str(src[k] ?? md[k], fallback);
628
782
  return [
629
- { key: "material", value: str(md.material, "100% cotton") },
630
- { key: "weight", value: str(md.weight, "180 g/m\xB2") },
631
- { key: "fit", value: str(md.fit, "Unisex heavyweight crewneck") },
632
- { key: "print", value: str(md.print, "Front print") },
633
- { key: "delivery", value: str(md.delivery, "Ships in 2\u20135 business days + transit") },
634
- { key: "care", value: str(md.care, "Cold wash, line dry") }
783
+ { key: "material", value: pick("material", "100% cotton") },
784
+ { key: "weight", value: pick("weight", "180 g/m\xB2") },
785
+ { key: "fit", value: pick("fit", "Unisex heavyweight crewneck") },
786
+ { key: "print", value: pick("print", "Front print") },
787
+ { key: "delivery", value: pick("delivery", "Ships in 2\u20135 business days + transit") },
788
+ { key: "care", value: pick("care", "Cold wash, line dry") }
635
789
  ];
636
790
  }
637
791
  function productLore(p) {
@@ -652,7 +806,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
652
806
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
653
807
 
654
808
  // src/version.ts
655
- var VERSION = "0.3.2";
809
+ var VERSION = "0.4.1";
656
810
 
657
811
  // src/onboarding.ts
658
812
  var GETTING_STARTED_URI = "bt://getting-started";
@@ -869,14 +1023,6 @@ function fmtPrice(amount, currency) {
869
1023
  function taxSuffix(isTaxInclusive) {
870
1024
  return isTaxInclusive ? " incl. VAT" : "";
871
1025
  }
872
- function firstPrice(p) {
873
- const v = (p.variants ?? [])[0];
874
- return {
875
- amount: v?.calculated_price?.calculated_amount ?? null,
876
- currency: v?.calculated_price?.currency_code ?? null,
877
- taxInclusive: v?.calculated_price?.is_calculated_price_tax_inclusive ?? null
878
- };
879
- }
880
1026
  function cartItems(cart) {
881
1027
  return (cart.items ?? []).map((li) => ({
882
1028
  variant_id: li.variant_id ?? null,
@@ -921,14 +1067,25 @@ function makeTools(client, opts = {}) {
921
1067
  return result("nothing in stock on this branch yet.", { products: [], currency: region.currency_code, region: rr.requested ?? null });
922
1068
  }
923
1069
  const lines = rows.map(({ p, stage: stage2 }) => {
924
- const { amount, currency, taxInclusive } = firstPrice(p);
1070
+ const { amount, currency, taxInclusive, from } = priceFrom(p);
1071
+ const fits = productFits(p);
925
1072
  const handle = sanitize(p.handle ?? p.id, 60);
926
1073
  const title = sanitize(p.title ?? "", 80);
927
- return `${handle} ${fmtPrice(amount, currency)}${taxSuffix(taxInclusive)} [${stage2}] ${title}`.trimEnd();
1074
+ const fitsNote = fits.length > 1 ? ` fits: ${fits.join("/")}` : "";
1075
+ return `${handle} ${from ? "from " : ""}${fmtPrice(amount, currency)}${taxSuffix(taxInclusive)} [${stage2}] ${title}${fitsNote}`.trimEnd();
928
1076
  });
929
1077
  const productList = rows.map(({ p, stage: stage2 }) => {
930
- const { amount, currency, taxInclusive } = firstPrice(p);
931
- return { handle: p.handle ?? p.id, title: sanitize(p.title ?? "", 80), price: amount, currency, tax_inclusive: taxInclusive, stage: stage2 };
1078
+ const { amount, currency, taxInclusive, from } = priceFrom(p);
1079
+ return {
1080
+ handle: p.handle ?? p.id,
1081
+ title: sanitize(p.title ?? "", 80),
1082
+ price: amount,
1083
+ price_from: from,
1084
+ currency,
1085
+ tax_inclusive: taxInclusive,
1086
+ stage: stage2,
1087
+ fits: productFits(p)
1088
+ };
932
1089
  });
933
1090
  return result(lines.join("\n"), { products: productList, currency: region.currency_code, region: rr.requested ?? null });
934
1091
  }
@@ -937,9 +1094,10 @@ function makeTools(client, opts = {}) {
937
1094
  name: "get_product",
938
1095
  // M3 ★ — a hidden line for the schema-readers (devs read tool descriptions). Rewards the
939
1096
  // contributor instinct + hints at a real feature (the next branch / drops). Never alters behaviour.
940
- description: "Get one piece in full: its readme, the spec sheet (material/weight/fit/print/delivery/care), sizes + price (VAT included where applicable), the lore (the story behind it, with sources), and its image (rendered + the Blob URL as a text floor). Prices are in the region's currency \u2014 pass `region` (ISO-2) to price for a market. // you read the schema. that's the contributor instinct. ask me about the next branch.",
1097
+ description: "Get one piece in full: its readme, the spec sheet (material/weight/fit/print/delivery/care), sizes + price (VAT included where applicable), the lore (the story behind it, with sources), and its image (rendered + the Blob URL as a text floor). Prices are in the region's currency \u2014 pass `region` (ISO-2) to price for a market. Designs sold in two fits (regular / oversize) are priced per fit: pass `fit` to narrow, and ALWAYS pass `fit` to add_to_cart for them. // you read the schema. that's the contributor instinct. ask me about the next branch.",
941
1098
  inputSchema: {
942
1099
  handle: z.string().describe("the product handle/slug (from list_products)"),
1100
+ fit: z.string().optional().describe("regular or oversize: narrow sizes, price and image to that fit (omit to see both)"),
943
1101
  region: z.string().optional().describe("ISO-2 ship-to country to price for (e.g. se / us / de); overrides the server default")
944
1102
  },
945
1103
  handler: async (args) => {
@@ -960,22 +1118,52 @@ function makeTools(client, opts = {}) {
960
1118
  if (typeof fabric === "string") {
961
1119
  lines.push(`fabric: ${sanitize(fabric, 80)}`);
962
1120
  }
1121
+ const fits = productFits(product);
1122
+ const wantedFit = parseFit(args.fit);
1123
+ if (args.fit && (!wantedFit || !fits.includes(wantedFit))) {
1124
+ return errorText(
1125
+ fits.length ? `no fit called "${sanitize(String(args.fit), 20)}" for "${sanitize(handle, 60)}". fits: ${fits.join(" / ")}.` : `"${sanitize(handle, 60)}" comes in one fit; omit \`fit\`.`
1126
+ );
1127
+ }
1128
+ const shipCC = (rr.requested ?? region.countries?.[0]?.iso_2 ?? "").toLowerCase() || null;
1129
+ const shownFits = fits.length ? wantedFit ? [wantedFit] : fits : [null];
1130
+ if (fits.length > 1) {
1131
+ lines.push(`fit: ${fits.map((f) => `${f} (${fmtPrice(priceFor(product, f).amount, priceFor(product, f).currency)})`).join(" \xB7 ")}${wantedFit ? ` (showing ${wantedFit})` : ""}`);
1132
+ }
963
1133
  const colours = productColours(product);
1134
+ const availability = {};
1135
+ const unavailableColours = {};
964
1136
  if (colours.length > 0) {
965
- lines.push(`available colours: ${colours.map((c) => sanitize(c, 20)).join(", ")}`);
966
- for (const c of colours) {
967
- const cs = sizesForColour(product, c).map((s) => sanitize(s, 20));
968
- lines.push(` ${sanitize(c, 20)}: ${cs.join(" ")}`);
1137
+ for (const f of shownFits) {
1138
+ const buyable = coloursFor(product, f, shipCC);
1139
+ const notBuyable = coloursFor(product, f).filter((c) => !buyable.includes(c));
1140
+ const indent = f ? " " : "";
1141
+ if (f) lines.push(`${fitLabel(f).toLowerCase()}:`);
1142
+ if (!f) lines.push(`available colours: ${buyable.map((c) => sanitize(c, 20)).join(", ")}`);
1143
+ const perColour = {};
1144
+ for (const c of buyable) {
1145
+ const cs = sizesFor(product, f, c);
1146
+ perColour[c] = cs;
1147
+ lines.push(`${indent} ${sanitize(c, 20)}: ${cs.map((x) => sanitize(x, 20)).join(" ")}`);
1148
+ }
1149
+ for (const c of notBuyable) {
1150
+ lines.push(`${indent} ${sanitize(c, 20)}: not available where you ship`);
1151
+ }
1152
+ if (f) {
1153
+ availability[f] = perColour;
1154
+ if (notBuyable.length) unavailableColours[f] = notBuyable;
1155
+ }
969
1156
  }
970
1157
  } else {
971
1158
  const sizes = (product.variants ?? []).map((v) => sanitize(v.title ?? "?", 20));
972
1159
  lines.push(`sizes: ${sizes.join(" ")}`);
973
1160
  }
974
1161
  const v0 = (product.variants ?? [])[0];
975
- if (v0?.calculated_price) {
976
- lines.push(`price: ${fmtPrice(v0.calculated_price.calculated_amount, v0.calculated_price.currency_code)}${taxSuffix(v0.calculated_price.is_calculated_price_tax_inclusive)}`);
1162
+ const shownPrice = wantedFit ? { ...priceFor(product, wantedFit), from: false } : priceFrom(product);
1163
+ if (shownPrice.amount != null) {
1164
+ lines.push(`price: ${shownPrice.from ? "from " : ""}${fmtPrice(shownPrice.amount, shownPrice.currency)}${taxSuffix(shownPrice.taxInclusive)}`);
977
1165
  }
978
- const specs = productSpecs(product);
1166
+ const specs = productSpecs(product, wantedFit ?? (fits.length ? fits[0] : null));
979
1167
  lines.push("specs:");
980
1168
  for (const s of specs) {
981
1169
  lines.push(` ${sanitize(s.key, 20)}: ${sanitize(s.value, 120)}`);
@@ -988,16 +1176,28 @@ function makeTools(client, opts = {}) {
988
1176
  }
989
1177
  }
990
1178
  const content = [{ type: "text", text: lines.join("\n") }];
991
- if (product.thumbnail) {
992
- const img = await buildImageContent(product.thumbnail);
1179
+ const imgFit = wantedFit ?? (fits.length ? fits[0] : null);
1180
+ const imgColour = colours.length ? coloursFor(product, imgFit, shipCC)[0] ?? null : null;
1181
+ const imageUrl = (imgColour ? imageFor(product, { fit: imgFit, colour: imgColour }) : null) ?? product.thumbnail ?? null;
1182
+ if (imageUrl) {
1183
+ const img = await buildImageContent(imageUrl);
993
1184
  content.push(...img);
994
1185
  }
995
- const variantMap = (product.variants ?? []).map((v) => ({
996
- variant_id: v.id,
997
- size: v.title ?? null,
998
- colour: variantColour(v) ?? null,
999
- price: v.calculated_price?.calculated_amount ?? null
1000
- }));
1186
+ const variantMap = (product.variants ?? []).map((v) => {
1187
+ const vFit = variantFit(v);
1188
+ const vColour = variantColour(v) ?? null;
1189
+ return {
1190
+ variant_id: v.id,
1191
+ fit: vFit,
1192
+ size: sizeOfVariant(v),
1193
+ colour: vColour,
1194
+ price: v.calculated_price?.calculated_amount ?? null,
1195
+ // Whether add_to_cart will ACCEPT this id for the priced region (money-path review 2026-09-04):
1196
+ // the map used to hand out ids for colours the ship-to blank can't print, with the caveat buried
1197
+ // in a separate unavailable_colours field. Same derived answer, on the row an agent actually uses.
1198
+ buyable: !vFit || !vColour || colourBuyable(vFit, vColour, shipCC)
1199
+ };
1200
+ });
1001
1201
  const sizeList = colours.length > 0 ? Object.fromEntries(colours.map((c) => [c, sizesForColour(product, c)])) : (product.variants ?? []).map((v) => v.title ?? "?");
1002
1202
  return {
1003
1203
  content,
@@ -1007,14 +1207,20 @@ function makeTools(client, opts = {}) {
1007
1207
  structuredContent: redactForOutput({
1008
1208
  handle: product.handle ?? handle,
1009
1209
  title: sanitize(product.title ?? "", 120),
1010
- price: v0?.calculated_price?.calculated_amount ?? null,
1011
- currency: v0?.calculated_price?.currency_code ?? null,
1012
- tax_inclusive: v0?.calculated_price?.is_calculated_price_tax_inclusive ?? null,
1210
+ price: shownPrice.amount ?? v0?.calculated_price?.calculated_amount ?? null,
1211
+ price_from: shownPrice.from,
1212
+ prices_by_fit: Object.fromEntries(fits.map((f) => [f, priceFor(product, f).amount])),
1213
+ currency: shownPrice.currency ?? v0?.calculated_price?.currency_code ?? null,
1214
+ tax_inclusive: shownPrice.taxInclusive ?? v0?.calculated_price?.is_calculated_price_tax_inclusive ?? null,
1013
1215
  region: rr.requested ?? null,
1216
+ fits,
1217
+ fit: wantedFit ?? null,
1014
1218
  colours,
1015
1219
  sizes: sizeList,
1220
+ ...fits.length ? { availability } : {},
1221
+ ...Object.keys(unavailableColours).length ? { unavailable_colours: unavailableColours } : {},
1016
1222
  variants: variantMap,
1017
- image_url: product.thumbnail ?? null,
1223
+ image_url: imageUrl,
1018
1224
  // the PDP content, machine-readable: readme + the spec sheet + the lore (+ sources).
1019
1225
  readme: sanitize(productReadme(product), 2e3),
1020
1226
  specs: specs.map((s) => ({ key: sanitize(s.key, 40), value: sanitize(s.value, 200) })),
@@ -1028,12 +1234,13 @@ function makeTools(client, opts = {}) {
1028
1234
  };
1029
1235
  const add_to_cart = {
1030
1236
  name: "add_to_cart",
1031
- description: "Add a piece to a cart. Pass cart_id to reuse an existing cart (the durable handle), or omit it to start a fresh one. Call get_product first to get valid sizes/colours (or an exact variant_id). qty is capped at 20. Returns the cart_id and the server-side cart summary (VAT included where applicable). `region` (ISO-2) sets the currency for a NEW cart only \u2014 a reused cart keeps its original region.",
1237
+ description: "Add a piece to a cart. Pass cart_id to reuse an existing cart (the durable handle), or omit it to start a fresh one. Call get_product first to get valid sizes/colours (or an exact variant_id); for a design sold in two fits (regular / oversize, priced apart) you MUST pass `fit`. qty is capped at 20. Returns the cart_id and the server-side cart summary (VAT included where applicable). `region` (ISO-2) sets the currency for a NEW cart only \u2014 a reused cart keeps its original region.",
1032
1238
  inputSchema: {
1033
1239
  handle: z.string().optional().describe("product handle (resolves a variant by size + colour; call get_product for valid values)"),
1034
1240
  variant_id: z.string().optional().describe("a specific variant id from get_product (overrides handle/size/colour)"),
1035
- size: z.string().optional().describe("size label from get_product, e.g. S / M / L / XL / 2XL"),
1036
- colour: z.string().optional().describe("piece colour from get_product, e.g. white / black (default: the product's first colour)"),
1241
+ fit: z.string().optional().describe("regular or oversize; REQUIRED when the design comes in both (get_product lists them)"),
1242
+ size: z.string().optional().describe("size label from get_product (Regular S / M / L / XL / 2XL; Oversize XS \u2026 3XL)"),
1243
+ colour: z.string().optional().describe("piece colour from get_product, e.g. black / white / khaki (khaki: US oversize only; default: the first colour you can buy)"),
1037
1244
  qty: z.number().int().positive().max(20).optional().describe("quantity, 1\u201320 (default 1)"),
1038
1245
  cart_id: z.string().optional().describe("reuse this cart; omit to create a new one"),
1039
1246
  region: z.string().optional().describe("ISO-2 ship-to country for a NEW cart (e.g. se / us / de); ignored when reusing a cart_id")
@@ -1047,6 +1254,23 @@ function makeTools(client, opts = {}) {
1047
1254
  const region = rr.region;
1048
1255
  const qty = typeof args.qty === "number" ? args.qty : 1;
1049
1256
  let variantId = typeof args.variant_id === "string" ? args.variant_id : void 0;
1257
+ if (variantId) {
1258
+ const owner = (await client.listProducts({ region })).find(
1259
+ (p) => (p.variants ?? []).some((v2) => v2.id === variantId)
1260
+ );
1261
+ const v = owner?.variants?.find((x) => x.id === variantId);
1262
+ if (!owner || !v) {
1263
+ return errorText(`no such variant "${sanitize(variantId, 60)}" in the catalog. call get_product for a current variant_id. nothing was added.`);
1264
+ }
1265
+ const vFit = variantFit(v);
1266
+ const vColour = variantColour(v);
1267
+ const shipCC2 = (rr.requested ?? region.countries?.[0]?.iso_2 ?? "").toLowerCase() || null;
1268
+ if (vFit && vColour && !colourBuyable(vFit, vColour, shipCC2)) {
1269
+ return errorText(
1270
+ `${sanitize(vColour, 20)} is not available in ${vFit} for ${sanitize(shipCC2 ?? "this region", 8)}. nothing was added.`
1271
+ );
1272
+ }
1273
+ }
1050
1274
  if (!variantId) {
1051
1275
  const handle = typeof args.handle === "string" ? args.handle : "";
1052
1276
  if (!handle) {
@@ -1057,16 +1281,37 @@ function makeTools(client, opts = {}) {
1057
1281
  return errorText(`no product with handle "${sanitize(handle, 60)}".`);
1058
1282
  }
1059
1283
  const size = typeof args.size === "string" ? args.size : void 0;
1060
- const colours = productColours(product);
1061
- const colour = typeof args.colour === "string" ? args.colour : colours.length > 0 ? colours[0] : void 0;
1062
- const variant = findVariant(product, { size, colour });
1063
- if (!variant) {
1064
- const have = colours.length ? ` available colours: ${colours.map((c) => sanitize(c, 20)).join(", ")}.` : "";
1284
+ const shipCC = (rr.requested ?? region.countries?.[0]?.iso_2 ?? "").toLowerCase() || null;
1285
+ const r = resolveVariant(product, {
1286
+ fit: typeof args.fit === "string" ? args.fit : void 0,
1287
+ size,
1288
+ colour: typeof args.colour === "string" ? args.colour : void 0,
1289
+ countryCode: shipCC
1290
+ });
1291
+ if (!r.ok) {
1292
+ if (r.reason === "ambiguous_fit") {
1293
+ const fitList = r.fits.map((f) => `${f.fit} (${fmtPrice(f.amount, f.currency)})`).join(" \xB7 ");
1294
+ return errorText(
1295
+ `fit required for "${sanitize(handle, 60)}": ${fitList}. pass fit: "regular" or fit: "oversize". nothing was added.`
1296
+ );
1297
+ }
1298
+ if (r.reason === "fit_not_offered") {
1299
+ return errorText(
1300
+ `"${sanitize(handle, 60)}" isn't sold in fit "${sanitize(r.requested, 20)}"${r.fits.length ? ` \u2014 it comes in ${r.fits.join(" / ")}` : ""}. nothing was added.`
1301
+ );
1302
+ }
1303
+ if (r.reason === "fit_unavailable") {
1304
+ return errorText(
1305
+ `${sanitize(r.colour ?? "", 20)} is not available in ${r.fit} for ${sanitize(shipCC ?? "this region", 8)}. nothing was added.`
1306
+ );
1307
+ }
1308
+ const have = r.colours.length ? ` available colours: ${r.colours.map((c) => sanitize(c, 20)).join(", ")}.` : "";
1309
+ const haveSizes = r.sizes.length ? ` sizes: ${r.sizes.map((x) => sanitize(x, 20)).join(" ")}.` : "";
1065
1310
  return errorText(
1066
- `no variant for "${sanitize(handle, 60)}"${size ? ` size ${sanitize(size, 20)}` : ""}${colour ? ` (${sanitize(colour, 20)})` : ""}.${have}`
1311
+ `no variant for "${sanitize(handle, 60)}"${r.fit ? ` fit ${r.fit}` : ""}${size ? ` size ${sanitize(size, 20)}` : ""}${r.colour ? ` (${sanitize(r.colour, 20)})` : ""}.${have}${haveSizes}`
1067
1312
  );
1068
1313
  }
1069
- variantId = variant.id;
1314
+ variantId = r.variant.id;
1070
1315
  }
1071
1316
  const reuse = typeof args.cart_id === "string" ? args.cart_id : void 0;
1072
1317
  const addTo = async (cartId) => client.addLineItem(cartId, variantId, qty);