mcp-recipes 4.0.0 → 4.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.0
4
+
5
+ - **Node.js 24.22.1 or later**, inside the Node.js 24 line. The package, the
6
+ extension manifest and the image all declare it.
7
+ - **zod `^4.6.7`.** Below 4.6.6, zod can exhaust memory while
8
+ validating oversized constrained collections, and the range accepts none of
9
+ those versions.
10
+ - **Dependency floors:** `@modelcontextprotocol/sdk` `^1.32.2`, `mcp-bbc-goodfood` `^1.1.0`, `mcp-marmiton` `^2.1.0`, `mcp-pequerecetas` `^1.1.0`, `mcp-supertoinette` `^1.1.0`, `mcp-wikibooks-cookbook` `^2.1.0`.
11
+
3
12
  ## 4.0.0
4
13
 
5
14
  - **The published client entry renames a field on `SourceProfile` and
package/README.md CHANGED
@@ -65,7 +65,7 @@ Node 24 or later is required, and no environment variable has to be set.
65
65
  "mcpServers": {
66
66
  "recipes": {
67
67
  "command": "docker",
68
- "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-recipes:4.0.0"]
68
+ "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-recipes:4.1.0"]
69
69
  }
70
70
  }
71
71
  }
@@ -79,7 +79,7 @@ nothing else: no volume, no port, no credential.
79
79
 
80
80
  ### Bundle, without npm
81
81
 
82
- Download `mcp-recipes-4.0.0.mcpb` from
82
+ Download `mcp-recipes-4.1.0.mcpb` from
83
83
  [the latest release](https://github.com/smeet666/mcp-recipes/releases/latest) and
84
84
  open it. A client that supports MCP bundles installs it on its own, with no npm
85
85
  and no configuration file to edit. The bundle carries its dependencies, so
@@ -133,12 +133,12 @@ search says so, and `get_recipe` says what it read off the page.
133
133
 
134
134
  Searches every source with one question.
135
135
 
136
- | Argument | Type | Required | What it does |
137
- | ------------------ | ----------------------------- | -------- | ---------------------------------------------------------------- |
138
- | `query` | string, 1 to 200 characters | yes | The dish or the ingredient to look for. |
139
- | `limit_per_source` | integer, 1 to 25, default `5` | no | Rows to keep from each source. |
140
- | `sources` | array of source ids | no | Ask these sources alone. |
141
- | `fan_out` | boolean, default `true` | no | Ask every source rather than stopping at the first that answers. |
136
+ | Argument | Type | Required | What it does |
137
+ | ------------------ | ----------------------------- | -------- | ----------------------------------------------------------------- |
138
+ | `query` | string, 1 to 200 characters | yes | The dish or the ingredient to look for. |
139
+ | `limit_per_source` | integer, 1 to 25, default `5` | no | Rows to keep from each source. |
140
+ | `sources` | array of source ids | no | Ask these sources alone. |
141
+ | `fan_out` | boolean, default `true` | no | Also send each source shorter wordings derived from the question. |
142
142
 
143
143
  **In return:** `results`, rows carrying `id`, which `get_recipe` takes;
144
144
  `source` and `source_name` saying which site published the row; `title`; `url`;
@@ -314,11 +314,11 @@ Each site keeps its own pace, and the floors hold here as well.
314
314
 
315
315
  ## Pacing and attribution
316
316
 
317
- Each site is paced on its own, one request at a time with at least a second
318
- between two, and the floor of half a second holds however the server is
319
- configured. Two of the sites ask for more, three seconds between two requests,
320
- and they get it: a setting posted for every source can raise a site's spacing
321
- and never lower it. Asking every site at once therefore costs each of them one request,
317
+ Each site is paced on its own, one request at a time. Every site holds a floor
318
+ of its own that no configuration goes under: half a second for Marmiton and the
319
+ Cookbook, one second for Ptitchef and BBC Good Food, three seconds for
320
+ Supertoinette and Pequerecetas. A setting posted for every source can raise a
321
+ site's spacing and never lower it. Asking every site at once therefore costs each of them one request,
322
322
  never two. The `User-Agent` always ends with the project identity and an address
323
323
  where a person can be reached.
324
324
 
@@ -438,7 +438,7 @@ renseigner.
438
438
  "mcpServers": {
439
439
  "recipes": {
440
440
  "command": "docker",
441
- "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-recipes:4.0.0"]
441
+ "args": ["run", "-i", "--rm", "ghcr.io/smeet666/mcp-recipes:4.1.0"]
442
442
  }
443
443
  }
444
444
  }
@@ -452,7 +452,7 @@ rien d'autre : aucun volume, aucun port, aucun identifiant.
452
452
 
453
453
  ### Bundle, sans npm
454
454
 
455
- Téléchargez `mcp-recipes-4.0.0.mcpb` depuis
455
+ Téléchargez `mcp-recipes-4.1.0.mcpb` depuis
456
456
  [la dernière publication](https://github.com/smeet666/mcp-recipes/releases/latest)
457
457
  et ouvrez-le. Un client qui gère les bundles MCP l'installe seul, sans npm et
458
458
  sans fichier de configuration à modifier. Le bundle emporte ses dépendances, donc
@@ -508,12 +508,12 @@ page.
508
508
 
509
509
  Cherche dans toutes les sources avec une seule question.
510
510
 
511
- | Argument | Type | Requis | Ce qu'il fait |
512
- | ------------------ | -------------------------------- | ------ | ----------------------------------------------------------------------- |
513
- | `query` | chaîne, 1 à 200 caractères | oui | Le plat ou l'ingrédient cherché. |
514
- | `limit_per_source` | entier, 1 à 25, défaut `5` | non | Lignes à garder de chaque source. |
515
- | `sources` | tableau d'identifiants de source | non | N'interroger que ces sources. |
516
- | `fan_out` | booléen, défaut `true` | non | Interroger chaque source plutôt que s'arrêter à la première qui répond. |
511
+ | Argument | Type | Requis | Ce qu'il fait |
512
+ | ------------------ | -------------------------------- | ------ | ---------------------------------------------------------------------------------- |
513
+ | `query` | chaîne, 1 à 200 caractères | oui | Le plat ou l'ingrédient cherché. |
514
+ | `limit_per_source` | entier, 1 à 25, défaut `5` | non | Lignes à garder de chaque source. |
515
+ | `sources` | tableau d'identifiants de source | non | N'interroger que ces sources. |
516
+ | `fan_out` | booléen, défaut `true` | non | Envoyer aussi à chaque source des formulations plus courtes tirées de la question. |
517
517
 
518
518
  **En retour :** `results`, des lignes portant `id`, que `get_recipe` reprend ;
519
519
  `source` et `source_name` qui disent quel site a publié la ligne ; `title` ;
@@ -695,11 +695,11 @@ Chaque site garde son propre rythme, et les planchers tiennent également ici.
695
695
 
696
696
  ## Rythme et attribution
697
697
 
698
- Chaque site est cadencé pour lui-même, une requête à la fois avec au moins une
699
- seconde entre deux, et le plancher d'une demi-seconde tient quelle que soit la
700
- configuration. Deux des sites en demandent davantage, trois secondes entre deux
701
- requêtes, et ils l'obtiennent : un réglage posé pour toutes les sources peut
702
- élargir l'écart d'un site, jamais le réduire. Les interroger toutes à la fois coûte donc à chacune une requête,
698
+ Chaque site est cadencé pour lui-même, une requête à la fois. Chaque site tient
699
+ un plancher qui lui est propre et sous lequel aucune configuration ne descend :
700
+ une demi-seconde pour Marmiton et le Cookbook, une seconde pour Ptitchef et BBC
701
+ Good Food, trois secondes pour Supertoinette et Pequerecetas. Un réglage posé
702
+ pour toutes les sources peut élargir l'écart d'un site, jamais le réduire. Les interroger toutes à la fois coûte donc à chacune une requête,
703
703
  jamais deux. Le `User-Agent` se termine toujours par l'identité du projet et une
704
704
  adresse où joindre une personne.
705
705
 
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  import process from "process";
12
12
 
13
13
  // src/version.ts
14
- var PKG_VERSION = "4.0.0";
14
+ var PKG_VERSION = "4.1.0";
15
15
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
16
16
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
17
17
 
@@ -174,6 +174,7 @@ function fromSource(error, sourceName) {
174
174
 
175
175
  // src/sources/adapter.ts
176
176
  var NUMBER_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
177
+ var NUMBER_INSIDE = /^\D*?(\d+(?:[.,]\d+)?)\s*(.*)$/;
177
178
  var RANGE_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i;
178
179
  function text(value) {
179
180
  return typeof value === "string" && value.trim() !== "" ? value : null;
@@ -237,6 +238,14 @@ function readYieldSpan(published) {
237
238
  return { count: value, max: null, unit: text(single[2]) };
238
239
  }
239
240
  }
241
+ const inside = NUMBER_INSIDE.exec(published);
242
+ if (inside) {
243
+ const [figure = ""] = inside.slice(1);
244
+ const value = Number(figure.replace(",", "."));
245
+ if (Number.isFinite(value)) {
246
+ return { count: value, max: null, unit: text(inside[2]) };
247
+ }
248
+ }
240
249
  return { count: null, max: null, unit: null };
241
250
  }
242
251
  function detailBase(source) {
@@ -609,8 +618,10 @@ function marmitonAdapter(reader) {
609
618
  skipped,
610
619
  // Rows on the page, including the ones this server could not read: the
611
620
  // site saw them, and reporting only the readable ones would understate
612
- // what it holds.
613
- reportedTotal: rows.length + skipped,
621
+ // what it holds. A page carrying none is a page this server counted
622
+ // nothing on, and "Marmiton reported 0" is a sentence Marmiton never
623
+ // said.
624
+ reportedTotal: rows.length + skipped === 0 ? null : rows.length + skipped,
614
625
  reportedTotalMeans: "rows on the single page of results Marmiton serves; the site disallows paging, so this is not a catalogue count",
615
626
  cached: outcome.cached
616
627
  };
@@ -1096,7 +1107,8 @@ function supertoinetteDetail(payload) {
1096
1107
  }
1097
1108
 
1098
1109
  // src/sources/registry.ts
1099
- var PAGE_READ_BOUNDS = { maxBodyBytes: 8e6, budgetMs: 6e4 };
1110
+ var MAX_BODY_BYTES = 8e6;
1111
+ var PTITCHEF_BUDGET_MS = 6e4;
1100
1112
  var PACING_FLOOR_MS = {
1101
1113
  marmiton: 500,
1102
1114
  cookbook: 500,
@@ -1116,6 +1128,7 @@ function pacedConfig(config, source) {
1116
1128
  minIntervalMs: pacingFor(source, config.minIntervalMs),
1117
1129
  timeoutMs: config.timeoutMs,
1118
1130
  maxRetries: config.maxRetries,
1131
+ maxBodyBytes: MAX_BODY_BYTES,
1119
1132
  cacheTtlMs: config.cacheTtlMs,
1120
1133
  cacheMaxEntries: config.cacheMaxEntries,
1121
1134
  logLevel: config.logLevel
@@ -1126,7 +1139,7 @@ function buildSources(config, readers, logger) {
1126
1139
  const marmitonOptions = { config: paced(MARMITON_PROFILE.id) };
1127
1140
  const cookbookOptions = { config: paced(COOKBOOK_PROFILE.id) };
1128
1141
  const ptitchefOptions = {
1129
- config: { ...paced(PTITCHEF_PROFILE.id), ...PAGE_READ_BOUNDS },
1142
+ config: { ...paced(PTITCHEF_PROFILE.id), budgetMs: PTITCHEF_BUDGET_MS },
1130
1143
  logger
1131
1144
  };
1132
1145
  const goodfoodOptions = { config: paced(GOODFOOD_PROFILE.id), logger };
@@ -1806,13 +1819,16 @@ function deriveWordings(question) {
1806
1819
  );
1807
1820
  return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);
1808
1821
  }
1809
- function namesDish(title, question) {
1822
+ function sharesDishWord(title, question) {
1810
1823
  const words = namingWords(question);
1811
1824
  if (words.length === 0) {
1812
1825
  return true;
1813
1826
  }
1814
1827
  return dishWordsMissing(title, question).length < words.length;
1815
1828
  }
1829
+ function carriesDish(title, question) {
1830
+ return dishWordsMissing(title, question).length === 0;
1831
+ }
1816
1832
  function dishWordsMissing(title, question) {
1817
1833
  const haystack = ` ${fold(title)} `;
1818
1834
  return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));
@@ -1894,7 +1910,7 @@ function reportOf(attempt, rows, question) {
1894
1910
  preferredByName: attempt.preferredByName,
1895
1911
  // Counted over the rows this answer holds, so it is read against `count`
1896
1912
  // and never against a number of rows nobody was shown.
1897
- namesTheDish: rows.filter((row) => namesDish(row.title, question)).length
1913
+ namesTheDish: rows.filter((row) => carriesDish(row.title, question)).length
1898
1914
  };
1899
1915
  }
1900
1916
  function heldBack(wording, because) {
@@ -1918,7 +1934,7 @@ function keepNewRows(read, seen, rows, question) {
1918
1934
  seen.add(row.id);
1919
1935
  rows.push(row);
1920
1936
  added += 1;
1921
- if (namesDish(row.title, question)) {
1937
+ if (sharesDishWord(row.title, question)) {
1922
1938
  onTopic += 1;
1923
1939
  }
1924
1940
  }
@@ -1982,8 +1998,9 @@ var RecipesClient = class {
1982
1998
  */
1983
1999
  deadlineFor(source) {
1984
2000
  const attempts = this.config.maxRetries + 1;
1985
- const between = pacingFor(source, this.config.minIntervalMs) + LONGEST_WAIT_BETWEEN_TRIES_MS;
1986
- return this.config.timeoutMs * attempts + between * this.config.maxRetries;
2001
+ const spacing = pacingFor(source, this.config.minIntervalMs) * attempts;
2002
+ const backoff = LONGEST_WAIT_BETWEEN_TRIES_MS * this.config.maxRetries;
2003
+ return this.config.timeoutMs * attempts + spacing + backoff;
1987
2004
  }
1988
2005
  /**
1989
2006
  * Ask every source the same question and merge the rows.
@@ -2123,8 +2140,8 @@ var RecipesClient = class {
2123
2140
  failure = reason;
2124
2141
  }
2125
2142
  }
2126
- const onTopicRows = rows.filter((row) => namesDish(row.title, question));
2127
- const ordered = [...onTopicRows, ...rows.filter((row) => !namesDish(row.title, question))];
2143
+ const onTopicRows = rows.filter((row) => sharesDishWord(row.title, question));
2144
+ const ordered = [...onTopicRows, ...rows.filter((row) => !sharesDishWord(row.title, question))];
2128
2145
  return {
2129
2146
  source,
2130
2147
  rows: ordered,
@@ -5058,9 +5075,17 @@ function labelNote(label, note) {
5058
5075
  function answeredNotes(report) {
5059
5076
  const notes = [];
5060
5077
  const sent = report.wordings.filter((attempt) => attempt.ran && attempt.error === null);
5078
+ const failed = report.wordings.filter((attempt) => attempt.error !== null);
5061
5079
  if (report.count === 0) {
5062
5080
  notes.push(nothingOfferedNote(report, sent));
5063
5081
  }
5082
+ if (failed.length > 0) {
5083
+ notes.push(
5084
+ mustKeep(
5085
+ `${report.name} did not answer ${failed.map((attempt) => `"${quoteForeign(attempt.query)}" ([${attempt.error?.code}])`).join(", ")}, so nothing here is a statement about ${failed.length > 1 ? "those wordings" : "that wording"}.`
5086
+ )
5087
+ );
5088
+ }
5064
5089
  if (sent.length > 1) {
5065
5090
  notes.push(
5066
5091
  `${report.name} was asked ${sent.length} wordings and these rows are their union: ${sent.map((attempt) => `"${quoteForeign(attempt.query)}" (${attempt.count ?? 0})`).join(", ")}. Those counts are per wording and are never added.`
@@ -5225,7 +5250,7 @@ function truncate(text2, maxChars) {
5225
5250
  }
5226
5251
  function renderRows(rows) {
5227
5252
  return rows.map((row, index) => {
5228
- const head = `${index + 1}. ${quoteForeign(row.title)} \xB7 ${quoteForeign(row.source_name)} \xB7 id: ${row.id}`;
5253
+ const head = `${index + 1}. ${quoteForeign(row.title)} \xB7 ${quoteForeign(row.source_name)} \xB7 id: ${quoteForeign(row.id)}`;
5229
5254
  return `${head}
5230
5255
  ${quoteForeign(row.url)}`;
5231
5256
  }).join("\n");
@@ -5235,7 +5260,7 @@ function creditLine(contributors) {
5235
5260
  return "No source contributed to this answer.";
5236
5261
  }
5237
5262
  const names2 = contributors.map(
5238
- (entry) => entry.url ? `${entry.attribution} \u2014 ${quoteForeign(entry.url)}` : entry.attribution
5263
+ (entry) => entry.url ? `${entry.attribution} (${quoteForeign(entry.url)})` : entry.attribution
5239
5264
  );
5240
5265
  return names2.join("\n");
5241
5266
  }
@@ -5538,16 +5563,21 @@ function buildRecipeView(recipe, options) {
5538
5563
  attribution: recipe.attribution,
5539
5564
  sections_returned: [...options.sections],
5540
5565
  sections_omitted: omitted,
5541
- scaling_summary: {
5542
- // A tool line is counted apart, so the four counts add up to the lines in
5543
- // the list. It carries "unscaled" like a line with no figure on it, and
5544
- // merging the two would say this server found no quantity where it found
5545
- // one and declined it.
5546
- scaled_count: ingredients.filter((entry) => entry.scaling === "scaled" && !entry.isEquipment).length,
5566
+ // The counts are over the lines this answer carries. Counting the lines a
5567
+ // recipe holds while the list is empty would say the answer scaled and
5568
+ // returned them.
5569
+ scaling_summary: wants("ingredients") ? {
5570
+ // A tool line is counted apart, so the four counts add up to the lines
5571
+ // in the list. It carries "unscaled" like a line with no figure on it,
5572
+ // and merging the two would say this server found no quantity where it
5573
+ // found one and declined it.
5574
+ scaled_count: ingredients.filter(
5575
+ (entry) => entry.scaling === "scaled" && !entry.isEquipment
5576
+ ).length,
5547
5577
  rounded_count: rounded.length,
5548
5578
  unscaled_count: unscaled.length,
5549
5579
  equipment_count: equipment.length
5550
- }
5580
+ } : { scaled_count: 0, rounded_count: 0, unscaled_count: 0, equipment_count: 0 }
5551
5581
  };
5552
5582
  if (options.label) {
5553
5583
  const label = quoteForeign(options.label);
@@ -5645,10 +5675,13 @@ function editDistance(left, right) {
5645
5675
  }
5646
5676
 
5647
5677
  // src/tools/compareRecipes.ts
5648
- function nothingWasCompared(someWereUnread, dish) {
5678
+ function nothingWasCompared(someWereUnread, dish, answeredCount) {
5649
5679
  if (someWereUnread) {
5650
5680
  return `Every version of "${quoteForeign(dish)}" that was offered could not be read, so nothing was compared.`;
5651
5681
  }
5682
+ if (answeredCount === 0) {
5683
+ return `No source answered for "${quoteForeign(dish)}", so nothing here says whether such a recipe exists.`;
5684
+ }
5652
5685
  return `No source offered a recipe for "${quoteForeign(dish)}".`;
5653
5686
  }
5654
5687
  var compareRecipesDescription = [
@@ -5656,7 +5689,7 @@ var compareRecipesDescription = [
5656
5689
  "Each source's closest match is read in full, and all of them can be rescaled to the same number of servings so the ingredient lists stand comparison.",
5657
5690
  "The answer states what differs and leaves it there: the quantities each asks for, what each measures in, what each yields, and which fields each source publishes at all.",
5658
5691
  "No version is ranked above another. Some sources carry reader ratings and some have no author and no rating by nature, so there is no score they share.",
5659
- "When only one source answers, the answer says so and shows that one rather than presenting part of a comparison as the whole of it."
5692
+ "When only one source answers, the answer names that source and shows its version, so a single version is read as the one it is."
5660
5693
  ].join(" ");
5661
5694
  var compareRecipesInput = strictInput({
5662
5695
  dish: z4.string().min(1).max(200).describe("The dish to compare, in any of the languages the sources publish in."),
@@ -5685,7 +5718,7 @@ function weighTitlesAgainstTheDish(payloads, dish, notes) {
5685
5718
  payload,
5686
5719
  missing: dishWordsMissing(payload.title, dish)
5687
5720
  }));
5688
- const carriesDish = weighed.some((one) => one.missing.length === 0);
5721
+ const carriesDish2 = weighed.some((one) => one.missing.length === 0);
5689
5722
  for (const { payload, missing } of weighed) {
5690
5723
  if (missing.length === 0) {
5691
5724
  continue;
@@ -5696,12 +5729,12 @@ function weighTitlesAgainstTheDish(payloads, dish, notes) {
5696
5729
  )
5697
5730
  );
5698
5731
  }
5699
- if (payloads.length > 0 && !carriesDish) {
5732
+ if (payloads.length > 0 && !carriesDish2) {
5700
5733
  notes.push(
5701
5734
  `No version here carries the whole of "${quoteForeign(dish)}" in its title. These are the closest rows each source returned for that spelling, so read them as candidates to check rather than as that dish.`
5702
5735
  );
5703
5736
  }
5704
- if (payloads.length === 1 && carriesDish) {
5737
+ if (payloads.length === 1 && carriesDish2) {
5705
5738
  notes.push(
5706
5739
  mustKeep(
5707
5740
  "This is one version rather than a comparison. Read it as what that one source publishes."
@@ -5922,7 +5955,11 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
5922
5955
  }
5923
5956
  return lines.join("\n");
5924
5957
  });
5925
- const body = payloads.length === 0 ? nothingWasCompared(unread.size > 0, args.dish) : versionBlocks.join("\n\n");
5958
+ const body = payloads.length === 0 ? nothingWasCompared(
5959
+ unread.size > 0,
5960
+ args.dish,
5961
+ merged.reports.filter((report) => report.error === null).length
5962
+ ) : versionBlocks.join("\n\n");
5926
5963
  const summary = `${differencesBlock}${body}`;
5927
5964
  return ok(
5928
5965
  {
@@ -5957,7 +5994,7 @@ import { z as z5 } from "zod";
5957
5994
  var getRecipeDescription = [
5958
5995
  "Read one recipe in full: its ingredients, its steps, what it yields, and whatever times, rating and nutrition its source publishes.",
5959
5996
  "'id' must come from search_recipes. It names the source, so this reads the right one without guessing; an identifier no source would have minted is refused, because sending it anywhere would answer about the wrong dish.",
5960
- "Pass 'servings' to rescale. Quantities land where a kitchen can follow them: an egg stays whole, anything that pours or cuts can halve, a small measurement moves to a smaller unit before it is rounded, and anything unmultipliable is flagged rather than scaled.",
5997
+ "Pass 'servings' to rescale. Quantities land where a kitchen can follow them: an egg stays whole, anything that pours or cuts can halve, a small measurement moves to a smaller unit before it is rounded, and anything unmultipliable comes back flagged.",
5961
5998
  "A page that states no number of servings comes back as published and says so, because dividing by a yield nobody wrote would answer for a number of people the page never claimed.",
5962
5999
  "Read 'kind' first. Some sources publish articles gathering other recipes at the same kind of address as a recipe, and such an answer carries 'collection' with the recipes it points at and no 'recipe' at all: there is nothing to cook from that page, and the recipes it lists are read with get_recipe.",
5963
6000
  "A part this answer holds nothing for says which of two things happened: the page showed no sign of it, or this server failed to read what the page carries. An empty ingredient list is never evidence that an ingredient is absent from the dish.",
@@ -6024,7 +6061,10 @@ async function runGetRecipe(client, args) {
6024
6061
  if (entry.is_equipment) {
6025
6062
  return `- ${quoteForeign(entry.text)} (a tool)`;
6026
6063
  }
6027
- return entry.scaling === "unscaled" ? `- ${quoteForeign(entry.text)} (no quantity)` : `- ${quoteForeign(entry.text)}`;
6064
+ if (entry.scaling !== "unscaled") {
6065
+ return `- ${quoteForeign(entry.text)}`;
6066
+ }
6067
+ return entry.note ? `- ${quoteForeign(entry.text)} (${quoteForeign(entry.note)})` : `- ${quoteForeign(entry.text)} (no quantity)`;
6028
6068
  });
6029
6069
  const stepLines = payload.steps.map((step, index) => `${index + 1}. ${quoteForeign(step)}`);
6030
6070
  const room = roomForBody({ notes, credit }) - head.join("\n").length;
@@ -6110,9 +6150,7 @@ var scaleIngredientsDescription = [
6110
6150
  "No quantity is converted between measuring systems: grams stay grams and cups stay cups, because a conversion changes what the recipe said."
6111
6151
  ].join(" ");
6112
6152
  var scaleIngredientsInput = strictInput({
6113
- ingredients: z6.array(z6.string().max(500)).min(1).max(200).describe(
6114
- "The lines as written, one ingredient each, such as '200 g de farine' or '3 eggs'. One line, not a whole recipe."
6115
- ),
6153
+ ingredients: z6.array(z6.string().max(500)).min(1).max(200).describe("The lines as written, one ingredient each, such as '200 g de farine' or '3 eggs'."),
6116
6154
  factor: z6.number().gt(0).max(1e3).optional().describe(
6117
6155
  "What to multiply by. Give exactly one of: 'factor', or both 'from_servings' and 'to_servings'."
6118
6156
  ),
@@ -6185,7 +6223,7 @@ function runScaleIngredients(args) {
6185
6223
  }
6186
6224
  if (counts.unscaled_count > 0) {
6187
6225
  notes.push(
6188
- `${counts.unscaled_count} line(s) carry no quantity to multiply and are repeated as given.`
6226
+ `${counts.unscaled_count} line(s) came back as given: they carry no figure to multiply, or one this server declined to multiply and named on the line itself.`
6189
6227
  );
6190
6228
  }
6191
6229
  if (counts.equipment_count > 0) {
@@ -6209,7 +6247,10 @@ function runScaleIngredients(args) {
6209
6247
  const body = scaled.map((entry, index) => `${index + 1}. ${quoteForeign(entry.text)}`).join("\n");
6210
6248
  return ok(
6211
6249
  {
6212
- factor: statedFactor(factor),
6250
+ // The multiplier the quantities were worked out with, so a caller
6251
+ // recomputing one from it lands where this answer landed. The block
6252
+ // below states a shortened one, which is for reading.
6253
+ factor,
6213
6254
  language: args.language,
6214
6255
  ingredients: scaled.map(toIngredientPayload),
6215
6256
  ...counts,
@@ -6237,10 +6278,11 @@ function howTheRowsAreOrdered(contributed) {
6237
6278
  return "No source contributed a row.";
6238
6279
  }
6239
6280
  function nothingCameBack(answeredCount, query, alsoSent) {
6281
+ const asked = quoteForeign(query);
6240
6282
  if (answeredCount === 0) {
6241
- return `No source answered for "${query}", so nothing here says whether such a recipe exists.`;
6283
+ return `No source answered for "${asked}", so nothing here says whether such a recipe exists.`;
6242
6284
  }
6243
- return `Nothing came back for "${query}".${alsoSent}`;
6285
+ return `Nothing came back for "${asked}".${alsoSent}`;
6244
6286
  }
6245
6287
  var searchRecipesDescription = [
6246
6288
  "Search every recipe source this server reads, at the same time, for a dish or an ingredient, and get one merged list.",
@@ -6251,7 +6293,7 @@ var searchRecipesDescription = [
6251
6293
  "Ask in a whole sentence if that is the question. These indexes answer the words they are handed, so a sentence is also sent as the words naming the dish and as the dish word alone, and the rows are the union; 'per_source' lists every wording and what it returned.",
6252
6294
  "What the question says the recipe must not hold is set aside from those shorter wordings rather than searched for, and named back in the notes: a negation, an allergy stated as one, a diet named in one word, and the number of people at the table. No source filters on any of it, so open a row with get_recipe and read the ingredient list before calling it suitable.",
6253
6295
  "A condition is read with the food on whichever side of it the sentence put one, so 'allergique aux noix' and 'peanut allergy' both name the nut. Where a sentence puts a food on neither side, the notes say a condition was stated and that its food was not read, because naming the wrong word would hide a dish and search for the food being avoided at once.",
6254
- "Rows are interleaved one source at a time rather than ranked, because the sources share no score that could order them against each other."
6296
+ "Rows are interleaved one source at a time, since the sources share no score that could order them against each other."
6255
6297
  ].join(" ");
6256
6298
  var searchRecipesInput = strictInput({
6257
6299
  query: z7.string().min(1).max(200).describe("A dish or an ingredient, in any of the languages the sources publish in."),
@@ -6342,12 +6384,13 @@ async function runSearchRecipes(client, args) {
6342
6384
  )
6343
6385
  );
6344
6386
  }
6345
- if (results.length > 0 && !results.some((row) => namesDish(row.title, args.query))) {
6387
+ if (results.length > 0 && !results.some((row) => sharesDishWord(row.title, args.query))) {
6346
6388
  notes.push(
6347
- `No title here carries a word of "${args.query}". These rows are what the sources ranked for that spelling, so read them as candidates to check rather than as recipes for the dish.`
6389
+ `No title here carries a word of "${quoteForeign(args.query)}". These rows are what the sources ranked for that spelling, so read them as candidates to check rather than as recipes for the dish.`
6348
6390
  );
6349
6391
  }
6350
- if (results.length === 0 && answered.length === merged.reports.length) {
6392
+ const allReadable = merged.reports.every((report) => report.skipped === 0);
6393
+ if (results.length === 0 && answered.length === merged.reports.length && allReadable) {
6351
6394
  notes.push(
6352
6395
  mustKeep(
6353
6396
  "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
@@ -6364,7 +6407,7 @@ async function runSearchRecipes(client, args) {
6364
6407
  const alsoSent = derived.length > 0 ? `
6365
6408
  Also searched for: ${derived.map((wording) => `"${quoteForeign(wording)}"`).join(", ")}.` : "";
6366
6409
  const anyNamesTheDish = merged.reports.some((report) => report.namesTheDish > 0);
6367
- const body = results.length > 0 ? `${results.length} ${anyNamesTheDish ? "recipes" : "rows"} for "${args.query}":
6410
+ const body = results.length > 0 ? `${results.length} ${anyNamesTheDish ? "recipes" : "rows"} for "${quoteForeign(args.query)}":
6368
6411
  ${renderRows(results)}${alsoSent}` : nothingCameBack(answered.length, args.query, alsoSent);
6369
6412
  return ok(
6370
6413
  {