mcp-marmiton 1.5.0 → 1.6.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
@@ -81,9 +81,35 @@ landed somewhere the arithmetic did not put it, a half egg taken to a whole one
81
81
  or 133.4 g written as 135 g.
82
82
 
83
83
  Two rules are enforced. Scaling a recipe **down never asks for more** than the
84
- original: half a sachet at factor 0.667 becomes a third of a sachet, never a whole
85
- one. And scaling never **silently drops** an ingredient by rounding it to zero,
86
- which is why small amounts come back as fractions.
84
+ original: half a sachet at factor 0.667 stays half a sachet, never a whole one.
85
+ And scaling never **silently drops** an ingredient by rounding it to zero, which
86
+ is why small amounts come back as fractions.
87
+
88
+ **A counted thing is divided by what it holds, not by what holds it.** A boîte de
89
+ tomates is poured and the rest kept, a sachet de sucre vanillé is split by eye, a
90
+ feuille de gélatine is cut with scissors, a branche de thym is broken in two: all
91
+ of those land on a half. Half an oeuf would have to be
92
+ beaten and weighed, which is not an amount a recipe asks for, so a count of oeufs,
93
+ jaunes or blancs d'oeufs lands on a whole number. A few things are decided by what
94
+ they are: a clou de girofle and a zeste are counted whole, a pot and a bouteille
95
+ hold enough for a quarter to be a portion, a gousse and a tranche are cut off
96
+ something larger and take a corner off in the same gesture, a blanc de poulet is
97
+ meat and halves, and a douzaine states a number of things rather than a measure
98
+ of them, so `2 douzaines d'escargots` at three quarters comes back as
99
+ `18 escargots`.
100
+
101
+ **A thing counted on its own is divided by the size of one against what a recipe
102
+ puts in.** Une crevette, une moule, une noisette, un grain de poivre, une baie de
103
+ genièvre, un anis étoilé is already a portion: a recipe counts twelve of them
104
+ and a smaller recipe puts one fewer in the pan, so they land on a whole number.
105
+ Un gigot, une baguette, un camembert, un ananas, un oignon, une pastèque, une
106
+ pintade, un poulet, un poireau sits at
107
+ the other end of that comparison, asked for by the one or the two and shared out
108
+ with a knife, so they go as far as the quarter. A cut carved off one of them
109
+ stops at the half, une cuisse and une aile being the portion the knife already
110
+ produced. Un jus stops at the half: half
111
+ the jus of a citron is taken by squeezing half the fruit, and a quarter of one
112
+ has to be poured out and measured back.
87
113
 
88
114
  Approximate measures are quantities too. A pinch, a handful, a bouchon or a
89
115
  ramequin has the size the cook gives it, and the recipe's proportion lives in how
@@ -107,8 +133,10 @@ the line as published.
107
133
  Each ingredient also carries `adjusted`, which says whether rounding moved the
108
134
  number away from the exact product.
109
135
 
110
- Below a quarter there is nothing a kitchen can measure, so the amount is clamped
111
- up and **the line stops holding its share of the recipe**. Half a sachet of
136
+ A shrinking line keeps the smallest share still worth measuring: a knife takes an
137
+ oignon to a quarter, a boîte or a sachet goes to a half, an oeuf stops at one.
138
+ Under that floor the amount is clamped up and **the line stops holding its share
139
+ of the recipe**. Half a sachet of
112
140
  baking powder against three pots of flour comes out four times too strong that
113
141
  way, so the ingredient and the response both say it happened rather than leaving
114
142
  you to find out in the oven.
@@ -293,10 +321,24 @@ ailleurs que là où le calcul la mettait, un demi-œuf ramené à un œuf entie
293
321
  133,4 g écrits 135 g.
294
322
 
295
323
  Deux règles sont garanties. Réduire une recette **ne demande jamais davantage** que
296
- l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais un
324
+ l'originale : un demi-sachet au facteur 0,667 reste un demi-sachet, jamais un
297
325
  sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
298
326
  à zéro, d'où les fractions pour les petites quantités.
299
327
 
328
+ **Un dénombrable se divise selon son contenu, pas selon son emballage.** Une boîte
329
+ de tomates se verse et le reste se garde, un sachet de sucre vanillé se partage à
330
+ l'œil, une feuille de gélatine se coupe aux ciseaux, une branche de thym se casse
331
+ en deux : tous tombent sur une demie. Un demi-œuf, lui,
332
+ demanderait de le battre et de le peser, ce qu'aucune recette ne demande : un
333
+ nombre d'œufs, de jaunes ou de blancs d'œufs tombe sur un entier. Quelques cas se
334
+ décident sur la nature de la chose : un clou de girofle et un zeste se comptent
335
+ entiers, un pot et une bouteille en contiennent assez pour qu'un quart soit une
336
+ portion, une gousse et une tranche sont déjà taillées dans plus grand et la
337
+ planche leur reprend un coin du même geste, un blanc de poulet est une viande et
338
+ se coupe en deux, et une douzaine
339
+ énonce un nombre de choses plutôt qu'une mesure, si bien que
340
+ « 2 douzaines d'escargots » réduites d'un quart reviennent en « 18 escargots ».
341
+
300
342
  Les mesures approximatives sont des quantités elles aussi. Une pincée, une
301
343
  poignée, un bouchon ou un ramequin ont la taille que le cuisinier leur donne,
302
344
  et la proportion de la recette tient au nombre demandé : ce nombre est donc
@@ -321,8 +363,10 @@ publiée.
321
363
  Chaque ingrédient porte aussi `adjusted`, qui dit si l'arrondi a déplacé le
322
364
  nombre par rapport au produit exact.
323
365
 
324
- Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
325
- alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
366
+ Une ligne qui rétrécit garde la plus petite part qui vaille encore la peine : le
367
+ couteau mène un oignon au quart, une boîte ou un sachet s'arrêtent à la demie, un
368
+ œuf s'arrête à l'unité. Sous ce plancher, la quantité est remontée et **la ligne
369
+ cesse de tenir sa part de la recette**. Un demi-sachet
326
370
  de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
327
371
  l'ingrédient et la réponse le disent, plutôt que de vous le laisser découvrir au
328
372
  four.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/version.ts
7
- var PKG_VERSION = "1.5.0";
7
+ var PKG_VERSION = "1.6.0";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -111,7 +111,7 @@ function createLogger(level) {
111
111
 
112
112
  // src/server.ts
113
113
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
114
- import { z as z5 } from "zod";
114
+ import { z as z6 } from "zod";
115
115
 
116
116
  // src/marmiton/cache.ts
117
117
  var TtlLruCache = class {
@@ -681,7 +681,7 @@ var MarmitonClient = class {
681
681
  };
682
682
 
683
683
  // src/tools/getRecipe.ts
684
- import { z as z2 } from "zod";
684
+ import { z as z3 } from "zod";
685
685
 
686
686
  // src/recipe/units.ts
687
687
  var UNITS = {
@@ -868,6 +868,14 @@ function readableUnitStep(unit, amount) {
868
868
  }
869
869
  return { unit, ratio: 1 };
870
870
  }
871
+ function unitDivisibility(unit) {
872
+ if (unit.kind === "vague") return "whole";
873
+ return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
874
+ }
875
+ var QUARTERED_MEASURE = /\b(pots?|bouteilles?|gousses?|tranches?)\b/;
876
+ function isSpoonMeasure(unit) {
877
+ return /^(cuillère à soupe|cuillère à café|verre|tasse|bol)$/.test(unit.canonical);
878
+ }
871
879
  function formatUnit(unit, amount) {
872
880
  if (unit.symbol) return unit.canonical;
873
881
  if (amount < 2) return unit.canonical;
@@ -927,7 +935,7 @@ function parseLeadingArticle(text) {
927
935
  const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
928
936
  if (!match) return null;
929
937
  const rest = text.slice(match[0].length);
930
- if (!matchLeadingUnit(rest, true)) return null;
938
+ if (!matchLeadingUnit(rest, true) && !readCountMultiplier(rest)) return null;
931
939
  const word = match[1];
932
940
  return {
933
941
  amount: ARTICLE_AMOUNTS[word.toLowerCase()],
@@ -935,6 +943,17 @@ function parseLeadingArticle(text) {
935
943
  word
936
944
  };
937
945
  }
946
+ var COUNT_MULTIPLIERS = {
947
+ douzaine: 12,
948
+ douzaines: 12
949
+ };
950
+ function readCountMultiplier(text) {
951
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text);
952
+ if (!match) return null;
953
+ const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
954
+ if (times === void 0) return null;
955
+ return { times, rest: text.slice(match[0].length) };
956
+ }
938
957
  function matchLeadingUnit(text, partitive = false) {
939
958
  const normalized = normalizeUnitKey(text);
940
959
  for (const key of UNIT_KEYS) {
@@ -974,10 +993,14 @@ function parseIngredient(line) {
974
993
  unit: null,
975
994
  unitText: null,
976
995
  item: text,
977
- articleWord: null
996
+ articleWord: null,
997
+ countMultiplier: null
978
998
  };
979
999
  }
980
1000
  let rest = text.slice(quantity.length).trimStart();
1001
+ const multiplier = readCountMultiplier(rest);
1002
+ if (multiplier) rest = multiplier.rest;
1003
+ const times = multiplier?.times ?? 1;
981
1004
  const matched = matchLeadingUnit(rest, quantity === article);
982
1005
  const unit = matched?.unit ?? null;
983
1006
  const unitText = matched?.unitText ?? null;
@@ -985,13 +1008,14 @@ function parseIngredient(line) {
985
1008
  const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
986
1009
  return {
987
1010
  original,
988
- amount: quantity.amount,
989
- amountMax: range?.max ?? null,
1011
+ amount: quantity.amount * times,
1012
+ amountMax: range === null ? null : range.max * times,
990
1013
  rangeSeparator: range?.separator ?? null,
991
1014
  unit,
992
1015
  unitText,
993
1016
  item,
994
- articleWord: quantity === article ? article?.word ?? null : null
1017
+ articleWord: quantity === article ? article?.word ?? null : null,
1018
+ countMultiplier: multiplier?.times ?? null
995
1019
  };
996
1020
  }
997
1021
  function parseLeadingRange(text) {
@@ -1044,8 +1068,64 @@ function roundMeasured(value) {
1044
1068
  return Math.round(value * 100) / 100;
1045
1069
  }
1046
1070
  var SMALLEST_USABLE_FRACTION = 0.25;
1071
+ var SMALLEST_USABLE = {
1072
+ whole: 1,
1073
+ half: 0.5,
1074
+ quarter: 0.25
1075
+ };
1076
+ function isHalfStep(value) {
1077
+ return Math.abs(value * 2 - Math.round(value * 2)) < 1e-9;
1078
+ }
1079
+ var PORTION_SIZED_ITEM = /\b(crevettes?|gambas|langoustines?|moules?|noisettes?|grains?|genievres?|anis)\b/;
1080
+ var QUARTERED_ITEM = /\b(oignons?|echalotes?|pommes? de terre|pommes?|poires?|carottes?|citrons?|oranges?|tomates?|concombres?|courgettes?|aubergines?|courges?|potirons?|choux?|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|pasteques?|gigots?|baguettes?|camemberts?|fromages?|chevres?|chorizos?|reblochons?|buches?|ananas|peches?|abricots?|laits?|poulets?|pintades?|rotis?)\b/;
1081
+ var HALVED_CUT = /\b(cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/;
1082
+ var HALVED_ITEM = /\bjus\b/;
1083
+ var WHOLE_ITEM = /\b(oeufs?|jaunes?|clous?|zestes?)\b/;
1084
+ function blancDivisibility(key) {
1085
+ if (!/\bblancs? de? /.test(key)) return null;
1086
+ return /\bblancs? de? oeufs?\b/.test(key) ? "whole" : "half";
1087
+ }
1088
+ function articleValue(parsed) {
1089
+ return parsed.amount === null ? 0 : parsed.amount / (parsed.countMultiplier ?? 1);
1090
+ }
1091
+ function divisibilityOf(unit, item) {
1092
+ if (unit) return unitDivisibility(unit);
1093
+ const key = item.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").replace(/['’]/g, " ");
1094
+ const blanc = blancDivisibility(key);
1095
+ if (blanc) return blanc;
1096
+ if (WHOLE_ITEM.test(key)) return "whole";
1097
+ if (PORTION_SIZED_ITEM.test(key)) return "whole";
1098
+ if (HALVED_ITEM.test(key)) return "half";
1099
+ if (HALVED_CUT.test(key)) return "half";
1100
+ if (QUARTERED_MEASURE.test(key)) return "quarter";
1101
+ return QUARTERED_ITEM.test(key) ? "quarter" : "half";
1102
+ }
1047
1103
  var EXACT_WITHIN = 0.01;
1048
- function roundCountable(value, allowHalves, ceiling) {
1104
+ function roundCountable(value, divisibility, ceiling) {
1105
+ if (value <= 0) return { value: 0, clamped: false };
1106
+ const floor = SMALLEST_USABLE[divisibility];
1107
+ if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
1108
+ return { value, clamped: false };
1109
+ }
1110
+ if (divisibility === "whole") {
1111
+ if (value < 0.5) return { value: floor, clamped: true };
1112
+ return { value: Math.round(value), clamped: false };
1113
+ }
1114
+ if (value < floor) return { value: floor, clamped: true };
1115
+ if (value < 1) {
1116
+ const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
1117
+ const candidates = steps.filter(
1118
+ (candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
1119
+ );
1120
+ let closest = candidates[0];
1121
+ for (const candidate of candidates) {
1122
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
1123
+ }
1124
+ return { value: Math.round(closest * 100) / 100, clamped: false };
1125
+ }
1126
+ return { value: Math.round(value), clamped: false };
1127
+ }
1128
+ function roundSpoon(value, ceiling) {
1049
1129
  if (value <= 0) return { value: 0, clamped: false };
1050
1130
  if (value < 1) {
1051
1131
  const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
@@ -1060,7 +1140,7 @@ function roundCountable(value, allowHalves, ceiling) {
1060
1140
  clamped: value < SMALLEST_USABLE_FRACTION
1061
1141
  };
1062
1142
  }
1063
- return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
1143
+ return { value: roundTo(value, 0.5), clamped: false };
1064
1144
  }
1065
1145
  function scaleApproximateCount(source, raw, factor) {
1066
1146
  if (raw <= 0) return 0;
@@ -1094,15 +1174,15 @@ function agreeWithAmount(item, amount) {
1094
1174
  const wantsPlural = amount >= 2;
1095
1175
  const isPlural = /s$|eaux$|aux$/i.test(head);
1096
1176
  if (wantsPlural && !isPlural) {
1097
- if (/[sxz]$/i.test(head)) return item;
1098
- if (/eau$/i.test(head)) words[0] = `${head}x`;
1177
+ if (/[sxz]$/i.test(head)) {
1178
+ } else if (/eau$/i.test(head)) words[0] = `${head}x`;
1099
1179
  else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1100
1180
  else words[0] = `${head}s`;
1101
1181
  } else if (!wantsPlural && isPlural) {
1102
1182
  if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1103
1183
  else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1104
- else if (/[aiou]s$/i.test(head)) return item;
1105
- else words[0] = head.slice(0, -1);
1184
+ else if (INVARIABLE_NOUN.has(foldWord(head))) {
1185
+ } else words[0] = head.slice(0, -1);
1106
1186
  }
1107
1187
  const last = words.length - 1;
1108
1188
  if (last > 0) {
@@ -1111,9 +1191,31 @@ function agreeWithAmount(item, amount) {
1111
1191
  }
1112
1192
  return words.join(" ");
1113
1193
  }
1194
+ var INVARIABLE_NOUN = /* @__PURE__ */ new Set([
1195
+ "ananas",
1196
+ "anis",
1197
+ "brebis",
1198
+ "cassis",
1199
+ "colis",
1200
+ "coulis",
1201
+ "couscous",
1202
+ "gambas",
1203
+ "houmous",
1204
+ "jus",
1205
+ "mais",
1206
+ "pastis",
1207
+ "pois",
1208
+ "radis",
1209
+ "ris",
1210
+ "souris",
1211
+ "tamis",
1212
+ "tapas"
1213
+ ]);
1114
1214
  var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1115
1215
  "entier",
1116
1216
  "entiere",
1217
+ "etoile",
1218
+ "etoilee",
1117
1219
  "moyen",
1118
1220
  "moyenne",
1119
1221
  "petit",
@@ -1200,9 +1302,8 @@ function scaleIngredient(line, options) {
1200
1302
  clamped: false
1201
1303
  };
1202
1304
  }
1203
- const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
1204
1305
  const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
1205
- const rounded = roundCountable(raw, allowHalves, ceiling);
1306
+ const rounded = parsed.unit && isSpoonMeasure(parsed.unit) ? roundSpoon(raw, ceiling) : roundCountable(raw, divisibilityOf(parsed.unit, parsed.item), ceiling);
1206
1307
  return {
1207
1308
  amount: rounded.value,
1208
1309
  unit: parsed.unit,
@@ -1238,20 +1339,21 @@ function scaleIngredient(line, options) {
1238
1339
  const many = unit ? formatUnit(unit, 2) : "measures";
1239
1340
  const sentences = [];
1240
1341
  if (parsed.articleWord) {
1241
- sentences.push(`"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`);
1342
+ sentences.push(`"${parsed.articleWord}" read as ${formatAmount(articleValue(parsed))}.`);
1242
1343
  }
1243
1344
  sentences.push(
1244
1345
  `${APPROXIMATE_MEASURE_MARKER} the number of ${many} was multiplied, and one ${one} keeps the size the cook gives it.`
1245
1346
  );
1246
1347
  if (adjusted) sentences.push(`Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`);
1247
1348
  result.note = sentences.join(" ");
1248
- } else if (bounds.some((bound) => bound.clamped)) {
1249
- result.note = `Clamped up to ${formatAmount(SMALLEST_USABLE_FRACTION)} from ${formatAmount(Math.round(low.raw * 1e3) / 1e3)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`;
1349
+ } else if (clamped) {
1350
+ const floored = bounds.find((bound) => bound.clamped);
1351
+ result.note = `Clamped up to ${formatAmount(floored.amount)} from ${formatAmount(Math.round(floored.raw * 1e3) / 1e3)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`;
1250
1352
  } else if (adjusted) {
1251
1353
  result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
1252
1354
  }
1253
1355
  if (kind !== "vague" && parsed.articleWord) {
1254
- const read = `"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`;
1356
+ const read = `"${parsed.articleWord}" read as ${formatAmount(articleValue(parsed))}.`;
1255
1357
  result.note = result.note ? `${read} ${result.note}` : read;
1256
1358
  }
1257
1359
  return result;
@@ -1277,23 +1379,74 @@ function passthroughIngredients(lines) {
1277
1379
  });
1278
1380
  }
1279
1381
 
1280
- // src/tools/shared.ts
1382
+ // src/tools/arguments.ts
1281
1383
  import { z } from "zod";
1384
+ var INVALID_INPUT = "invalid_input";
1385
+ function strictInput(shape) {
1386
+ const declared = Object.keys(shape);
1387
+ const errorMap = (issue, ctx) => issue.code === z.ZodIssueCode.unrecognized_keys ? { message: unknownArgumentMessage(issue.keys, declared) } : { message: ctx.defaultError };
1388
+ return z.strictObject(shape, { errorMap });
1389
+ }
1390
+ function unknownArgumentMessage(keys, declared) {
1391
+ const named = keys.map((key) => {
1392
+ const near = nearestArgument(key, declared);
1393
+ return near ? `'${key}' (did you mean '${near}'?)` : `'${key}'`;
1394
+ }).join(", ");
1395
+ return `[${INVALID_INPUT}] Unknown ${keys.length > 1 ? "arguments" : "argument"} ${named}. This tool takes: ${declared.join(", ")}.`;
1396
+ }
1397
+ function nearestArgument(key, declared) {
1398
+ const flatten2 = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
1399
+ const flat = flatten2(key);
1400
+ if (flat.length === 0) return void 0;
1401
+ const sameName = declared.find((name) => flatten2(name) === flat);
1402
+ if (sameName) return sameName;
1403
+ const overlapping = declared.find((name) => {
1404
+ const other = flatten2(name);
1405
+ const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
1406
+ return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
1407
+ });
1408
+ if (overlapping) return overlapping;
1409
+ let closest;
1410
+ let shortest = Number.POSITIVE_INFINITY;
1411
+ for (const name of declared) {
1412
+ const distance = editDistance(flat, flatten2(name));
1413
+ if (distance < shortest) {
1414
+ shortest = distance;
1415
+ closest = name;
1416
+ }
1417
+ }
1418
+ return shortest <= Math.max(1, Math.floor(flat.length / 3)) ? closest : void 0;
1419
+ }
1420
+ function editDistance(left, right) {
1421
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
1422
+ for (let row = 1; row <= left.length; row += 1) {
1423
+ const current = [row];
1424
+ for (let column = 1; column <= right.length; column += 1) {
1425
+ const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
1426
+ current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
1427
+ }
1428
+ previous = current;
1429
+ }
1430
+ return previous[right.length];
1431
+ }
1432
+
1433
+ // src/tools/shared.ts
1434
+ import { z as z2 } from "zod";
1282
1435
  var MAX_TEXT_MIRROR_CHARS = 2e3;
1283
- var scaledIngredientSchema = z.object({
1284
- original: z.string().describe("The ingredient line exactly as Marmiton publishes it."),
1285
- text: z.string().describe("The line after scaling, identical to 'original' when unscaled."),
1286
- amount: z.number().nullable().describe(
1436
+ var scaledIngredientSchema = z2.object({
1437
+ original: z2.string().describe("The ingredient line exactly as Marmiton publishes it."),
1438
+ text: z2.string().describe("The line after scaling, identical to 'original' when unscaled."),
1439
+ amount: z2.number().nullable().describe(
1287
1440
  "The scaled quantity, expressed in 'unit'. Read the two together: a large result is moved to a bigger unit, so 200 g scaled tenfold reads as 2 kg, and the bare number shrinks while the quantity grows."
1288
1441
  ),
1289
- unit: z.string().nullable().describe("The unit 'amount' is in, which may differ from the one the recipe used."),
1290
- scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
1442
+ unit: z2.string().nullable().describe("The unit 'amount' is in, which may differ from the one the recipe used."),
1443
+ scaling: z2.enum(["scaled", "rounded", "unscaled"]).describe(
1291
1444
  "'scaled' means the arithmetic came out exact, which a count reaches as readily as a mass: one pinch multiplied by six is six pinches. 'rounded' means the value had to be moved to stay usable, to a whole or half unit or to a step a scale can show. 'unscaled' was left alone, because the line carries no quantity."
1292
1445
  ),
1293
- adjusted: z.boolean().describe(
1446
+ adjusted: z2.boolean().describe(
1294
1447
  "True when rounding moved the value away from the exact product, which is what makes a line 'rounded' rather than 'scaled'."
1295
1448
  ),
1296
- note: z.string().optional().describe(
1449
+ note: z2.string().optional().describe(
1297
1450
  "Why the line was rounded, clamped or left alone, including when the measure is approximate and only its count was multiplied."
1298
1451
  )
1299
1452
  });
@@ -1333,46 +1486,46 @@ var getRecipeDescription = [
1333
1486
  "Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.",
1334
1487
  "Always cite 'attribution' when showing a recipe to a user."
1335
1488
  ].join(" ");
1336
- var getRecipeInputShape = {
1337
- id: z2.string().regex(/^\d+$/, "Recipe ids are digits only.").optional().describe("Marmiton recipe id, as returned by search_recipes. Preferred over 'url'."),
1338
- url: z2.string().url().optional().describe(
1489
+ var getRecipeInput = strictInput({
1490
+ id: z3.string().regex(/^\d+$/, "Recipe ids are digits only.").optional().describe("Marmiton recipe id, as returned by search_recipes. Preferred over 'url'."),
1491
+ url: z3.string().url().optional().describe(
1339
1492
  "Full marmiton.org recipe URL. Only marmiton.org is accepted. Ignored when 'id' is given."
1340
1493
  ),
1341
- servings: z2.number().positive().max(500).optional().describe(
1494
+ servings: z3.number().positive().max(500).optional().describe(
1342
1495
  "Rescale the ingredients to this many servings. Omit to get the recipe exactly as published."
1343
1496
  )
1344
- };
1497
+ });
1345
1498
  var getRecipeOutputShape = {
1346
- id: z2.string(),
1347
- title: z2.string(),
1348
- url: z2.string(),
1349
- yield: z2.object({
1350
- original_count: z2.number().nullable(),
1351
- original_text: z2.string(),
1352
- requested: z2.number().nullable(),
1353
- unit: z2.string().nullable().describe("What is being counted: 'personnes', 'pi\xE8ces'."),
1354
- factor: z2.number().nullable().describe("Multiplier applied to the quantities.")
1499
+ id: z3.string(),
1500
+ title: z3.string(),
1501
+ url: z3.string(),
1502
+ yield: z3.object({
1503
+ original_count: z3.number().nullable(),
1504
+ original_text: z3.string(),
1505
+ requested: z3.number().nullable(),
1506
+ unit: z3.string().nullable().describe("What is being counted: 'personnes', 'pi\xE8ces'."),
1507
+ factor: z3.number().nullable().describe("Multiplier applied to the quantities.")
1355
1508
  }),
1356
- ingredients: z2.array(scaledIngredientSchema),
1357
- steps: z2.array(z2.string()),
1358
- prep_minutes: z2.number().int().nullable(),
1359
- cook_minutes: z2.number().int().nullable(),
1360
- total_minutes: z2.number().int().nullable(),
1361
- category: z2.string().nullable(),
1362
- author: z2.string().nullable(),
1363
- rating: z2.object({ value: z2.number(), count: z2.number().nullable(), best: z2.number().nullable() }).nullable(),
1364
- nutrition: z2.object({
1365
- calories: z2.string().nullable(),
1366
- protein: z2.string().nullable(),
1367
- fat: z2.string().nullable(),
1368
- carbohydrate: z2.string().nullable(),
1369
- fiber: z2.string().nullable(),
1370
- sodium: z2.string().nullable(),
1371
- serving_size: z2.string().nullable()
1509
+ ingredients: z3.array(scaledIngredientSchema),
1510
+ steps: z3.array(z3.string()),
1511
+ prep_minutes: z3.number().int().nullable(),
1512
+ cook_minutes: z3.number().int().nullable(),
1513
+ total_minutes: z3.number().int().nullable(),
1514
+ category: z3.string().nullable(),
1515
+ author: z3.string().nullable(),
1516
+ rating: z3.object({ value: z3.number(), count: z3.number().nullable(), best: z3.number().nullable() }).nullable(),
1517
+ nutrition: z3.object({
1518
+ calories: z3.string().nullable(),
1519
+ protein: z3.string().nullable(),
1520
+ fat: z3.string().nullable(),
1521
+ carbohydrate: z3.string().nullable(),
1522
+ fiber: z3.string().nullable(),
1523
+ sodium: z3.string().nullable(),
1524
+ serving_size: z3.string().nullable()
1372
1525
  }).nullable().describe("Nutrition is given for the recipe as published, not rescaled."),
1373
- attribution: z2.string(),
1374
- source: z2.literal("marmiton.org"),
1375
- notes: z2.array(z2.string())
1526
+ attribution: z3.string(),
1527
+ source: z3.literal("marmiton.org"),
1528
+ notes: z3.array(z3.string())
1376
1529
  };
1377
1530
  async function runGetRecipe(client, args) {
1378
1531
  try {
@@ -1491,32 +1644,34 @@ ${stepLines}` : ""
1491
1644
  }
1492
1645
 
1493
1646
  // src/tools/scaleIngredients.ts
1494
- import { z as z3 } from "zod";
1647
+ import { z as z4 } from "zod";
1495
1648
  var scaleIngredientsDescription = [
1496
1649
  "Rescale a list of ingredient lines to a different number of servings, without contacting any website.",
1497
1650
  "Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.",
1498
1651
  "Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.",
1499
- "Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as",
1500
- "eggs or spoons are rounded to whole or half units; approximate measures such as a pinch or a handful have",
1652
+ "Quantities in grams or millilitres are multiplied and rounded to readable values; a countable thing lands on",
1653
+ "a half when half of one can be poured, weighed or cut, as a bo\xEEte, a sachet, a feuille de g\xE9latine or a",
1654
+ "cuill\xE8re can, and on a whole one when it cannot, as an oeuf, a jaune or a blanc; approximate measures such",
1655
+ "as a pinch or a handful have",
1501
1656
  "their count multiplied in whole units and stay in their own vocabulary. A line writing an article where a",
1502
1657
  "digit would go, as in 'un bouchon de rhum' or 'une pinc\xE9e de sel', is read as one of that measure. Lines",
1503
1658
  "carrying no quantity are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
1504
1659
  ].join(" ");
1505
- var scaleIngredientsInputShape = {
1506
- ingredients: z3.array(z3.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
1507
- factor: z3.number().positive().max(100).optional().describe("Multiplier to apply. Use this or the from/to pair."),
1508
- from_servings: z3.number().positive().max(500).optional().describe("How many servings the list is written for."),
1509
- to_servings: z3.number().positive().max(500).optional().describe("How many servings are wanted.")
1510
- };
1660
+ var scaleIngredientsInput = strictInput({
1661
+ ingredients: z4.array(z4.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
1662
+ factor: z4.number().positive().max(100).optional().describe("Multiplier to apply. Use this or the from/to pair."),
1663
+ from_servings: z4.number().positive().max(500).optional().describe("How many servings the list is written for."),
1664
+ to_servings: z4.number().positive().max(500).optional().describe("How many servings are wanted.")
1665
+ });
1511
1666
  var scaleIngredientsOutputShape = {
1512
- factor: z3.number(),
1513
- ingredients: z3.array(scaledIngredientSchema),
1514
- scaled_count: z3.number().int(),
1515
- rounded_count: z3.number().int().describe(
1667
+ factor: z4.number(),
1668
+ ingredients: z4.array(scaledIngredientSchema),
1669
+ scaled_count: z4.number().int(),
1670
+ rounded_count: z4.number().int().describe(
1516
1671
  "Lines whose value rounding moved away from the exact product, not lines that could have been rounded."
1517
1672
  ),
1518
- unscaled_count: z3.number().int(),
1519
- notes: z3.array(z3.string())
1673
+ unscaled_count: z4.number().int(),
1674
+ notes: z4.array(z4.string())
1520
1675
  };
1521
1676
  function formatFactor(factor) {
1522
1677
  return String(Number(factor.toPrecision(3)));
@@ -1590,7 +1745,7 @@ ${lines}`, notes);
1590
1745
  }
1591
1746
 
1592
1747
  // src/tools/searchRecipes.ts
1593
- import { z as z4 } from "zod";
1748
+ import { z as z5 } from "zod";
1594
1749
  var searchRecipesDescription = [
1595
1750
  "Search Marmiton for recipes by free text: a dish, an ingredient, or both, for example 'tarte aux pommes'",
1596
1751
  "or 'poulet curry coco'. Marmiton is a French site, so French terms work best.",
@@ -1598,28 +1753,28 @@ var searchRecipesDescription = [
1598
1753
  "One call returns one page of results. Marmiton's robots.txt disallows paginating search results, so this",
1599
1754
  "server does not, and there is no page parameter: narrow the query instead of asking for more pages."
1600
1755
  ].join(" ");
1601
- var searchRecipesInputShape = {
1756
+ var searchRecipesInput = strictInput({
1602
1757
  // Deliberately no min(1): an empty string would be rejected by the schema with
1603
1758
  // a protocol-level validation error, while a whitespace-only one would reach
1604
1759
  // the tool and come back as invalid_input. Letting both through to the same
1605
1760
  // check gives callers one error code for one problem.
1606
- query: z4.string().max(200).describe("What to search for, in French, for example 'tarte aux pommes'."),
1607
- limit: z4.number().int().min(1).max(30).default(10).describe("Maximum recipes to return from this page of results.")
1608
- };
1761
+ query: z5.string().max(200).describe("What to search for, in French, for example 'tarte aux pommes'."),
1762
+ limit: z5.number().int().min(1).max(30).default(10).describe("Maximum recipes to return from this page of results.")
1763
+ });
1609
1764
  var searchRecipesOutputShape = {
1610
- query: z4.string(),
1611
- results: z4.array(
1612
- z4.object({
1613
- id: z4.string().describe("Marmiton recipe id. Pass this to get_recipe."),
1614
- title: z4.string(),
1615
- url: z4.string(),
1616
- image_url: z4.string().nullable()
1765
+ query: z5.string(),
1766
+ results: z5.array(
1767
+ z5.object({
1768
+ id: z5.string().describe("Marmiton recipe id. Pass this to get_recipe."),
1769
+ title: z5.string(),
1770
+ url: z5.string(),
1771
+ image_url: z5.string().nullable()
1617
1772
  })
1618
1773
  ),
1619
- result_count: z4.number().int(),
1620
- total_available: z4.number().int().describe("Recipes on this page before applying 'limit'."),
1621
- source: z4.literal("marmiton.org"),
1622
- notes: z4.array(z4.string())
1774
+ result_count: z5.number().int(),
1775
+ total_available: z5.number().int().describe("Recipes on this page before applying 'limit'."),
1776
+ source: z5.literal("marmiton.org"),
1777
+ notes: z5.array(z5.string())
1623
1778
  };
1624
1779
  async function runSearchRecipes(client, args) {
1625
1780
  try {
@@ -1691,8 +1846,8 @@ function createServer(options = {}) {
1691
1846
  {
1692
1847
  title: "Search recipes",
1693
1848
  description: searchRecipesDescription,
1694
- inputSchema: z5.object(searchRecipesInputShape),
1695
- outputSchema: z5.object(searchRecipesOutputShape),
1849
+ inputSchema: searchRecipesInput,
1850
+ outputSchema: z6.object(searchRecipesOutputShape),
1696
1851
  annotations: READ_ONLY
1697
1852
  },
1698
1853
  async (args) => runSearchRecipes(client, args)
@@ -1702,8 +1857,8 @@ function createServer(options = {}) {
1702
1857
  {
1703
1858
  title: "Get a recipe",
1704
1859
  description: getRecipeDescription,
1705
- inputSchema: z5.object(getRecipeInputShape),
1706
- outputSchema: z5.object(getRecipeOutputShape),
1860
+ inputSchema: getRecipeInput,
1861
+ outputSchema: z6.object(getRecipeOutputShape),
1707
1862
  annotations: READ_ONLY
1708
1863
  },
1709
1864
  async (args) => runGetRecipe(client, args)
@@ -1713,8 +1868,8 @@ function createServer(options = {}) {
1713
1868
  {
1714
1869
  title: "Scale an ingredient list",
1715
1870
  description: scaleIngredientsDescription,
1716
- inputSchema: z5.object(scaleIngredientsInputShape),
1717
- outputSchema: z5.object(scaleIngredientsOutputShape),
1871
+ inputSchema: scaleIngredientsInput,
1872
+ outputSchema: z6.object(scaleIngredientsOutputShape),
1718
1873
  annotations: OFFLINE
1719
1874
  },
1720
1875
  async (args) => runScaleIngredients(args)