mcp-marmiton 1.1.0 → 1.2.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
@@ -71,6 +71,15 @@ original: half a sachet at factor 0.667 becomes a third of a sachet, never a who
71
71
  one. And scaling never **silently drops** an ingredient by rounding it to zero,
72
72
  which is why small amounts come back as fractions.
73
73
 
74
+ Ranges are read as one quantity: "2 à 3 gousses" doubled reads "4 à 6 gousses",
75
+ and `amount` reports the upper bound, since that is what a cook buys.
76
+
77
+ Below a quarter there is nothing a kitchen can measure, so the amount is clamped
78
+ up and **the line stops holding its share of the recipe**. Half a sachet of
79
+ baking powder against three pots of flour comes out four times too strong that
80
+ way, so the ingredient and the response both say it happened rather than leaving
81
+ you to find out in the oven.
82
+
74
83
  `scale_ingredients` exposes the same logic **without any network request**, so it
75
84
  also works on a recipe pasted from somewhere else.
76
85
 
@@ -157,6 +166,19 @@ and use your judgement.
157
166
 
158
167
  This is an unofficial project, with no affiliation to or endorsement by Marmiton.
159
168
 
169
+ ## Contributing
170
+
171
+ Bugs, questions and ideas all belong in
172
+ [the issue tracker](https://github.com/smeet666/mcp-marmiton/issues). Pull requests
173
+ are welcome; please open an issue first so we can agree on what the right
174
+ answer is before you write it. [CONTRIBUTING.md](CONTRIBUTING.md) has the
175
+ detail, and [SECURITY.md](SECURITY.md) covers anything exploitable.
176
+
177
+ ## Support
178
+
179
+ These servers are free and stay free. If one of them saved you an afternoon,
180
+ you can [buy me a coffee](https://buymeacoffee.com/smeet666).
181
+
160
182
  ## License
161
183
 
162
184
  MIT. See [LICENSE](./LICENSE). The license covers this source code only, not the
@@ -229,6 +251,16 @@ l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais
229
251
  sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
230
252
  à zéro, d'où les fractions pour les petites quantités.
231
253
 
254
+ Les fourchettes sont lues comme une seule quantité : « 2 à 3 gousses » doublé
255
+ donne « 4 à 6 gousses », et `amount` renvoie la borne haute, celle que l'on
256
+ achète.
257
+
258
+ Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
259
+ alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
260
+ de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
261
+ l'ingrédient et la réponse le disent, plutôt que de vous le laisser découvrir au
262
+ four.
263
+
232
264
  `scale_ingredients` expose la même logique **sans aucune requête réseau**, ce qui
233
265
  permet aussi d'adapter une recette venue d'ailleurs.
234
266
 
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.1.0";
7
+ var PKG_VERSION = "1.2.1";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -111,6 +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
115
 
115
116
  // src/marmiton/cache.ts
116
117
  var TtlLruCache = class {
@@ -685,22 +686,22 @@ import { z as z2 } from "zod";
685
686
  // src/recipe/units.ts
686
687
  var UNITS = {
687
688
  // Mass
688
- g: { canonical: "g", kind: "measured" },
689
- gr: { canonical: "g", kind: "measured" },
690
- gramme: { canonical: "g", kind: "measured" },
691
- grammes: { canonical: "g", kind: "measured" },
692
- kg: { canonical: "kg", kind: "measured" },
693
- kilo: { canonical: "kg", kind: "measured" },
694
- kilos: { canonical: "kg", kind: "measured" },
695
- kilogramme: { canonical: "kg", kind: "measured" },
696
- mg: { canonical: "mg", kind: "measured" },
689
+ g: { canonical: "g", kind: "measured", symbol: true },
690
+ gr: { canonical: "g", kind: "measured", symbol: true },
691
+ gramme: { canonical: "g", kind: "measured", symbol: true },
692
+ grammes: { canonical: "g", kind: "measured", symbol: true },
693
+ kg: { canonical: "kg", kind: "measured", symbol: true },
694
+ kilo: { canonical: "kg", kind: "measured", symbol: true },
695
+ kilos: { canonical: "kg", kind: "measured", symbol: true },
696
+ kilogramme: { canonical: "kg", kind: "measured", symbol: true },
697
+ mg: { canonical: "mg", kind: "measured", symbol: true },
697
698
  // Volume
698
- ml: { canonical: "ml", kind: "measured" },
699
- cl: { canonical: "cl", kind: "measured" },
700
- dl: { canonical: "dl", kind: "measured" },
701
- l: { canonical: "l", kind: "measured" },
702
- litre: { canonical: "l", kind: "measured" },
703
- litres: { canonical: "l", kind: "measured" },
699
+ ml: { canonical: "ml", kind: "measured", symbol: true },
700
+ cl: { canonical: "cl", kind: "measured", symbol: true },
701
+ dl: { canonical: "dl", kind: "measured", symbol: true },
702
+ l: { canonical: "l", kind: "measured", symbol: true },
703
+ litre: { canonical: "l", kind: "measured", symbol: true },
704
+ litres: { canonical: "l", kind: "measured", symbol: true },
704
705
  // Spoons and cups: real measures, but only in sensible fractions.
705
706
  "cuillere a soupe": {
706
707
  canonical: "cuill\xE8re \xE0 soupe",
@@ -784,6 +785,12 @@ var DEMOTIONS = {
784
785
  cl: { to: "ml", per: 10 },
785
786
  g: { to: "mg", per: 1e3 }
786
787
  };
788
+ function demoteUnit(unit) {
789
+ const step = DEMOTIONS[unit.canonical];
790
+ if (!step) return null;
791
+ const target = lookupUnit(step.to);
792
+ return target ? { unit: target, per: step.per } : null;
793
+ }
787
794
  function convertToReadableUnit(unit, amount) {
788
795
  if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
789
796
  return { amount, unit };
@@ -801,9 +808,9 @@ function convertToReadableUnit(unit, amount) {
801
808
  return { amount, unit };
802
809
  }
803
810
  function formatUnit(unit, amount) {
811
+ if (unit.symbol) return unit.canonical;
804
812
  if (amount < 2) return unit.canonical;
805
813
  if (unit.plural) return unit.plural;
806
- if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;
807
814
  return `${unit.canonical}s`;
808
815
  }
809
816
 
@@ -853,9 +860,18 @@ function parseLeadingQuantity(text) {
853
860
  function parseIngredient(line) {
854
861
  const original = line;
855
862
  const text = line.trim();
856
- const quantity = parseLeadingQuantity(text);
863
+ const range = parseLeadingRange(text);
864
+ const quantity = range ?? parseLeadingQuantity(text);
857
865
  if (!quantity) {
858
- return { original, amount: null, unit: null, unitText: null, item: text };
866
+ return {
867
+ original,
868
+ amount: null,
869
+ amountMax: null,
870
+ rangeSeparator: null,
871
+ unit: null,
872
+ unitText: null,
873
+ item: text
874
+ };
859
875
  }
860
876
  let rest = text.slice(quantity.length).trimStart();
861
877
  let unit = null;
@@ -872,7 +888,31 @@ function parseIngredient(line) {
872
888
  }
873
889
  }
874
890
  const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
875
- return { original, amount: quantity.amount, unit, unitText, item };
891
+ return {
892
+ original,
893
+ amount: quantity.amount,
894
+ amountMax: range?.max ?? null,
895
+ rangeSeparator: range?.separator ?? null,
896
+ unit,
897
+ unitText,
898
+ item
899
+ };
900
+ }
901
+ function parseLeadingRange(text) {
902
+ const low = parseLeadingQuantity(text);
903
+ if (!low) return null;
904
+ const after = text.slice(low.length);
905
+ const separator = /^\s*(à|a|ou|-|–|—|\/)\s*/.exec(after);
906
+ if (!separator) return null;
907
+ if (separator[1] === "/") return null;
908
+ const high = parseLeadingQuantity(after.slice(separator[0].length));
909
+ if (!high || high.amount <= low.amount) return null;
910
+ return {
911
+ amount: low.amount,
912
+ max: high.amount,
913
+ separator: separator[1],
914
+ length: low.length + separator[0].length + high.length
915
+ };
876
916
  }
877
917
  function formatAmount(amount, options = {}) {
878
918
  if (!Number.isFinite(amount)) return "";
@@ -907,17 +947,38 @@ function roundMeasured(value) {
907
947
  if (value >= 1) return roundTo(value, 0.5);
908
948
  return Math.round(value * 100) / 100;
909
949
  }
910
- function roundCountable(value, allowHalves) {
911
- if (value <= 0) return 0;
950
+ var SMALLEST_USABLE_FRACTION = 0.25;
951
+ function roundCountable(value, allowHalves, ceiling) {
952
+ if (value <= 0) return { value: 0, clamped: false };
912
953
  if (value < 1) {
913
- const USABLE_FRACTIONS = [0.25, 1 / 3, 0.5, 2 / 3, 0.75];
914
- let closest = USABLE_FRACTIONS[0];
915
- for (const fraction of USABLE_FRACTIONS) {
916
- if (Math.abs(value - fraction) < Math.abs(value - closest)) closest = fraction;
954
+ const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
955
+ (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
956
+ );
957
+ let closest = candidates[0];
958
+ for (const candidate of candidates) {
959
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
917
960
  }
918
- return Math.round(closest * 100) / 100;
961
+ return {
962
+ value: Math.round(closest * 100) / 100,
963
+ clamped: value < SMALLEST_USABLE_FRACTION
964
+ };
965
+ }
966
+ return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
967
+ }
968
+ function roundMeasuredInUsableUnit(unit, raw) {
969
+ let current = unit;
970
+ let value = raw;
971
+ while (value > 0 && value < 1) {
972
+ const step = demoteUnit(current);
973
+ if (!step) break;
974
+ value *= step.per;
975
+ current = step.unit;
919
976
  }
920
- return allowHalves ? roundTo(value, 0.5) : Math.round(value);
977
+ const rounded = roundMeasured(value);
978
+ return {
979
+ amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,
980
+ unit: current
981
+ };
921
982
  }
922
983
  function agreeWithAmount(item, amount) {
923
984
  if (!item) return item;
@@ -935,6 +996,12 @@ function agreeWithAmount(item, amount) {
935
996
  }
936
997
  return words.join(" ");
937
998
  }
999
+ var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
1000
+ function joinItem(item) {
1001
+ if (!item) return "";
1002
+ const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
1003
+ return elides ? ` d'${item}` : ` de ${item}`;
1004
+ }
938
1005
  function scaleIngredient(line, options) {
939
1006
  const { factor } = options;
940
1007
  const parsed = parseIngredient(line);
@@ -945,6 +1012,7 @@ function scaleIngredient(line, options) {
945
1012
  amount: null,
946
1013
  unit: null,
947
1014
  scaling: "unscaled",
1015
+ adjusted: false,
948
1016
  note: "No quantity given; adjust to taste."
949
1017
  };
950
1018
  }
@@ -956,41 +1024,47 @@ function scaleIngredient(line, options) {
956
1024
  amount: parsed.amount,
957
1025
  unit: parsed.unit?.canonical ?? null,
958
1026
  scaling: "unscaled",
1027
+ adjusted: false,
959
1028
  note: "Measure is approximate by nature; adjust to taste."
960
1029
  };
961
1030
  }
962
- const raw = parsed.amount * factor;
963
- let amount;
964
- let scaling;
965
- let unit = parsed.unit;
966
- if (kind === "measured") {
967
- const converted = convertToReadableUnit(parsed.unit, roundMeasured(raw));
968
- amount = converted.amount;
969
- unit = converted.unit;
970
- scaling = "scaled";
971
- } else if (kind === "portioned") {
1031
+ const scaleBound = (source) => {
1032
+ const raw = source * factor;
1033
+ if (kind === "measured") {
1034
+ const converted = roundMeasuredInUsableUnit(parsed.unit, raw);
1035
+ const readable = convertToReadableUnit(converted.unit, converted.amount);
1036
+ return { amount: readable.amount, unit: readable.unit, raw, clamped: false };
1037
+ }
972
1038
  const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
973
- amount = roundCountable(raw, allowHalves);
974
- scaling = "rounded";
975
- } else {
976
- amount = roundCountable(raw, false);
977
- scaling = "rounded";
978
- }
1039
+ const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
1040
+ const rounded = roundCountable(raw, allowHalves, ceiling);
1041
+ return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };
1042
+ };
1043
+ const low = scaleBound(parsed.amount);
1044
+ const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);
1045
+ const amount = high?.amount ?? low.amount;
1046
+ const unit = high?.unit ?? low.unit;
1047
+ const scaling = kind === "measured" ? "scaled" : "rounded";
979
1048
  const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
980
- const separator = unit ? " de " : " ";
981
1049
  const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
982
- const item = itemText ? `${separator}${itemText}` : "";
983
- const amountText = formatAmount(amount, { fractions: kind !== "measured" });
1050
+ const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : "";
1051
+ const asText = (value) => formatAmount(value, { fractions: kind !== "measured" });
1052
+ const amountText = high === null ? asText(low.amount) : parsed.rangeSeparator === "-" || parsed.rangeSeparator === "\u2013" || parsed.rangeSeparator === "\u2014" ? `${asText(low.amount)}${parsed.rangeSeparator}${asText(high.amount)}` : `${asText(low.amount)} ${parsed.rangeSeparator} ${asText(high.amount)}`;
984
1053
  const text = `${amountText}${unitLabel}${item}`.trim();
1054
+ const bounds = high === null ? [low] : [low, high];
1055
+ const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);
985
1056
  const result = {
986
1057
  original: parsed.original,
987
1058
  text,
988
1059
  amount,
989
1060
  unit: unit?.canonical ?? null,
990
- scaling
1061
+ scaling,
1062
+ adjusted
991
1063
  };
992
- if (scaling === "rounded" && Math.abs(raw - amount) > 0.01) {
993
- result.note = `Rounded from ${formatAmount(Math.round(raw * 100) / 100)}.`;
1064
+ if (bounds.some((bound) => bound.clamped)) {
1065
+ 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.`;
1066
+ } else if (scaling === "rounded" && adjusted) {
1067
+ result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
994
1068
  }
995
1069
  return result;
996
1070
  }
@@ -1005,7 +1079,8 @@ function passthroughIngredients(lines) {
1005
1079
  text: parsed.original,
1006
1080
  amount: parsed.amount,
1007
1081
  unit: parsed.unit?.canonical ?? null,
1008
- scaling: "unscaled"
1082
+ scaling: "unscaled",
1083
+ adjusted: false
1009
1084
  };
1010
1085
  });
1011
1086
  }
@@ -1023,11 +1098,19 @@ var scaledIngredientSchema = z.object({
1023
1098
  scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
1024
1099
  "'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. 'unscaled' was left alone, either because it carries no quantity or because the measure is approximate by nature."
1025
1100
  ),
1026
- note: z.string().optional().describe("Why the line was rounded or left alone.")
1101
+ adjusted: z.boolean().describe(
1102
+ "True when rounding moved the value away from the exact product. A line can be 'rounded' and still land exactly, as three eggs doubled land on six."
1103
+ ),
1104
+ note: z.string().optional().describe("Why the line was rounded, clamped or left alone.")
1027
1105
  });
1028
- function ok(structured, text) {
1106
+ function ok(structured, text, notes = []) {
1107
+ const trailer = notes.map((note) => `Note: ${note}`).join("\n");
1108
+ const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
1109
+ const body = truncate(text, Math.max(0, budget));
1029
1110
  return {
1030
- content: [{ type: "text", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],
1111
+ content: [{ type: "text", text: trailer ? `${body}
1112
+
1113
+ ${trailer}` : body }],
1031
1114
  structuredContent: structured
1032
1115
  };
1033
1116
  }
@@ -1119,8 +1202,21 @@ async function runGetRecipe(client, args) {
1119
1202
  notes.push(
1120
1203
  `Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${Math.round(factor * 100) / 100}).`
1121
1204
  );
1122
- if (ingredients.some((entry) => entry.scaling === "rounded")) {
1123
- notes.push("Some quantities were rounded to remain usable; see the 'scaling' field.");
1205
+ const roundedCount = ingredients.filter(
1206
+ (entry) => entry.scaling === "rounded" && entry.adjusted
1207
+ ).length;
1208
+ if (roundedCount > 0) {
1209
+ notes.push(
1210
+ `${roundedCount} quantity(ies) were rounded to remain usable; see the 'scaling' field.`
1211
+ );
1212
+ }
1213
+ const clampedCount = ingredients.filter(
1214
+ (entry) => /clamped up/i.test(entry.note ?? "")
1215
+ ).length;
1216
+ if (clampedCount > 0) {
1217
+ notes.push(
1218
+ `${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the published recipe.`
1219
+ );
1124
1220
  }
1125
1221
  if (ingredients.some((entry) => entry.scaling === "unscaled")) {
1126
1222
  notes.push("Some ingredients carry no usable quantity and were left as published.");
@@ -1173,14 +1269,20 @@ async function runGetRecipe(client, args) {
1173
1269
  args.servings !== void 0 && factor !== null ? `Pour ${args.servings} ${data.recipeYield.unit ?? "portions"} (recette pr\xE9vue pour ${data.recipeYield.text}).` : `Recette pour ${data.recipeYield.text || "portions non pr\xE9cis\xE9es"}.`,
1174
1270
  times
1175
1271
  ].filter(Boolean).join("\n");
1272
+ const rescaled = args.servings !== void 0 && factor !== null;
1176
1273
  const ingredientLines = ingredients.map((entry) => {
1177
- const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1274
+ const flag = rescaled && entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1178
1275
  return `- ${entry.text}${flag}`;
1179
1276
  }).join("\n");
1180
- return ok(structured, `${header}
1181
-
1182
- Ingr\xE9dients:
1183
- ${ingredientLines}`);
1277
+ const stepLines = data.steps.map((step, index) => `${index + 1}. ${step}`).join("\n");
1278
+ const body = [
1279
+ header,
1280
+ `Ingr\xE9dients:
1281
+ ${ingredientLines}`,
1282
+ stepLines ? `Pr\xE9paration:
1283
+ ${stepLines}` : ""
1284
+ ].filter(Boolean).join("\n\n");
1285
+ return ok(structured, body, notes);
1184
1286
  } catch (error) {
1185
1287
  return toToolError(error);
1186
1288
  }
@@ -1206,15 +1308,26 @@ var scaleIngredientsOutputShape = {
1206
1308
  factor: z3.number(),
1207
1309
  ingredients: z3.array(scaledIngredientSchema),
1208
1310
  scaled_count: z3.number().int(),
1209
- rounded_count: z3.number().int(),
1311
+ rounded_count: z3.number().int().describe(
1312
+ "Lines whose value rounding moved away from the exact product, not lines that could have been rounded."
1313
+ ),
1210
1314
  unscaled_count: z3.number().int(),
1211
1315
  notes: z3.array(z3.string())
1212
1316
  };
1317
+ function formatFactor(factor) {
1318
+ return String(Number(factor.toPrecision(3)));
1319
+ }
1213
1320
  function runScaleIngredients(args) {
1214
1321
  try {
1215
1322
  let factor;
1323
+ const notes = [];
1216
1324
  if (args.factor !== void 0) {
1217
1325
  factor = args.factor;
1326
+ if (args.from_servings !== void 0 || args.to_servings !== void 0) {
1327
+ notes.push(
1328
+ "'factor' was given alongside 'from_servings'/'to_servings'. 'factor' was applied and the pair ignored; send only one of the two to remove the ambiguity."
1329
+ );
1330
+ }
1218
1331
  } else if (args.from_servings !== void 0 && args.to_servings !== void 0) {
1219
1332
  factor = args.to_servings / args.from_servings;
1220
1333
  } else {
@@ -1226,10 +1339,12 @@ function runScaleIngredients(args) {
1226
1339
  const ingredients = scaleIngredients(args.ingredients, { factor });
1227
1340
  const counts = {
1228
1341
  scaled: ingredients.filter((entry) => entry.scaling === "scaled").length,
1229
- rounded: ingredients.filter((entry) => entry.scaling === "rounded").length,
1230
- unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length
1342
+ // Lines that were actually moved, rather than lines that merely belong to
1343
+ // the roundable category: at a factor of 100 every egg lands whole.
1344
+ rounded: ingredients.filter((entry) => entry.scaling === "rounded" && entry.adjusted).length,
1345
+ unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length,
1346
+ clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length
1231
1347
  };
1232
- const notes = [];
1233
1348
  if (counts.rounded > 0) {
1234
1349
  notes.push(
1235
1350
  `${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`
@@ -1240,8 +1355,13 @@ function runScaleIngredients(args) {
1240
1355
  `${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
1241
1356
  );
1242
1357
  }
1358
+ if (counts.clamped > 0) {
1359
+ notes.push(
1360
+ `${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the original recipe.`
1361
+ );
1362
+ }
1243
1363
  const structured = {
1244
- factor: Math.round(factor * 1e3) / 1e3,
1364
+ factor: Number(factor.toPrecision(3)),
1245
1365
  ingredients,
1246
1366
  scaled_count: counts.scaled,
1247
1367
  rounded_count: counts.rounded,
@@ -1252,8 +1372,8 @@ function runScaleIngredients(args) {
1252
1372
  const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1253
1373
  return `- ${entry.text}${flag}`;
1254
1374
  }).join("\n");
1255
- return ok(structured, `Facteur ${Math.round(factor * 100) / 100}:
1256
- ${lines}`);
1375
+ return ok(structured, `Facteur ${formatFactor(factor)}:
1376
+ ${lines}`, notes);
1257
1377
  } catch (error) {
1258
1378
  return toToolError(error);
1259
1379
  }
@@ -1341,6 +1461,7 @@ var READ_ONLY = {
1341
1461
  idempotentHint: true,
1342
1462
  openWorldHint: true
1343
1463
  };
1464
+ var OFFLINE = { ...READ_ONLY, openWorldHint: false };
1344
1465
  function createServer(options = {}) {
1345
1466
  const config = options.config ?? loadConfig();
1346
1467
  const logger = options.logger ?? createLogger(config.logLevel);
@@ -1360,8 +1481,8 @@ function createServer(options = {}) {
1360
1481
  {
1361
1482
  title: "Search recipes",
1362
1483
  description: searchRecipesDescription,
1363
- inputSchema: searchRecipesInputShape,
1364
- outputSchema: searchRecipesOutputShape,
1484
+ inputSchema: z5.object(searchRecipesInputShape),
1485
+ outputSchema: z5.object(searchRecipesOutputShape),
1365
1486
  annotations: READ_ONLY
1366
1487
  },
1367
1488
  async (args) => runSearchRecipes(client, args)
@@ -1371,8 +1492,8 @@ function createServer(options = {}) {
1371
1492
  {
1372
1493
  title: "Get a recipe",
1373
1494
  description: getRecipeDescription,
1374
- inputSchema: getRecipeInputShape,
1375
- outputSchema: getRecipeOutputShape,
1495
+ inputSchema: z5.object(getRecipeInputShape),
1496
+ outputSchema: z5.object(getRecipeOutputShape),
1376
1497
  annotations: READ_ONLY
1377
1498
  },
1378
1499
  async (args) => runGetRecipe(client, args)
@@ -1382,9 +1503,9 @@ function createServer(options = {}) {
1382
1503
  {
1383
1504
  title: "Scale an ingredient list",
1384
1505
  description: scaleIngredientsDescription,
1385
- inputSchema: scaleIngredientsInputShape,
1386
- outputSchema: scaleIngredientsOutputShape,
1387
- annotations: READ_ONLY
1506
+ inputSchema: z5.object(scaleIngredientsInputShape),
1507
+ outputSchema: z5.object(scaleIngredientsOutputShape),
1508
+ annotations: OFFLINE
1388
1509
  },
1389
1510
  async (args) => runScaleIngredients(args)
1390
1511
  );