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 +32 -0
- package/dist/index.js +196 -75
- package/dist/index.js.map +1 -1
- package/dist/marmiton/client.js +1 -1
- package/dist/marmiton/client.js.map +1 -1
- package/package.json +5 -1
- package/server.json +18 -3
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
|
|
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
|
|
863
|
+
const range = parseLeadingRange(text);
|
|
864
|
+
const quantity = range ?? parseLeadingQuantity(text);
|
|
857
865
|
if (!quantity) {
|
|
858
|
-
return {
|
|
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 {
|
|
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
|
-
|
|
911
|
-
|
|
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
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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 ?
|
|
983
|
-
const
|
|
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 (
|
|
993
|
-
result.note = `
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
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:
|
|
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 ${
|
|
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:
|
|
1506
|
+
inputSchema: z5.object(scaleIngredientsInputShape),
|
|
1507
|
+
outputSchema: z5.object(scaleIngredientsOutputShape),
|
|
1508
|
+
annotations: OFFLINE
|
|
1388
1509
|
},
|
|
1389
1510
|
async (args) => runScaleIngredients(args)
|
|
1390
1511
|
);
|