mcp-recipes 1.1.0 → 1.2.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
@@ -4,6 +4,37 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.2.0] - 2026-08-07
8
+
9
+ ### Changed
10
+
11
+ - A mass stated behind the thing counted is read as the size of one of them.
12
+ "1 dinde de 3 kg" names one bird and says what it weighs, and multiplying the
13
+ count answered a dinner for twelve with a bird and a half. Lines of that shape
14
+ come back as the page published them, with a note saying the figure gives a
15
+ size and that more people means a bigger bird. A container keeps counting:
16
+ "2 boîtes de 400 g de tomates" states what one boîte holds and goes on to name
17
+ what is in it, and an equivalence such as "450 g (1 livre) de spaghetti"
18
+ restates one quantity twice and is scaled on both sides.
19
+
20
+ - A comparison tells apart the two moments a source can fail in. Searching and
21
+ then opening the version a search offered are two different readings, and a
22
+ source missing from a comparison was described as one whose search did not
23
+ answer whichever of the two had happened. Every row of `per_source` now
24
+ carries a `read` field saying what became of the version that source offered,
25
+ and the notes name the moment that failed.
26
+
27
+ - A search Marmiton answers with nothing is counted as an answer. Marmiton
28
+ serves a 404 on its results page when no recipe matches, and reading that as a
29
+ failed source put "Marmiton did not answer" in front of a reader for a search
30
+ Marmiton had answered. Every other failure stays a failure, so a site that
31
+ cannot be reached is never read as a site that holds nothing.
32
+
33
+ - A list of results carrying none of the words asked for says so. A source
34
+ ranking a title on the letters it opens with answers "chameau farci" with a
35
+ chapeau and a gâteau château. When no title returned carries a word of the
36
+ query, the answer names that, and the rows read as candidates to check.
37
+
7
38
  ## [1.1.0] - 2026-08-07
8
39
 
9
40
  ### Changed
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
 
9
9
  // src/version.ts
10
- var PKG_VERSION = "1.1.0";
10
+ var PKG_VERSION = "1.2.0";
11
11
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
12
12
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
13
13
 
@@ -379,6 +379,15 @@ var MARMITON_PROFILE = {
379
379
  };
380
380
  var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
381
381
  var REFERENCE = /^\d+$/;
382
+ async function readSearch(reader, query) {
383
+ try {
384
+ return await reader.search(query);
385
+ } catch (error) {
386
+ const code = error?.code;
387
+ if (code === "not_found") return { data: [], cached: false };
388
+ throw error;
389
+ }
390
+ }
382
391
  function marmitonAdapter(reader) {
383
392
  return {
384
393
  ...MARMITON_PROFILE,
@@ -396,7 +405,7 @@ function marmitonAdapter(reader) {
396
405
  return null;
397
406
  },
398
407
  async search(query) {
399
- const outcome = await reader.search(query);
408
+ const outcome = await readSearch(reader, query);
400
409
  const list = rowsOf(outcome.data, MARMITON_PROFILE);
401
410
  const rows = [];
402
411
  let skipped = 0;
@@ -1256,6 +1265,21 @@ var APPROXIMATION_PREFIX = {
1256
1265
  en: /^(?:~|\u2248|about|approx\.?|approximately|around|roughly)\s*/i,
1257
1266
  fr: /^(?:~|\u2248|environ|approximativement|\u00e0 peu pr\u00e8s|a peu pres)\s*/i
1258
1267
  };
1268
+ var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
1269
+ function statesItemSize(item) {
1270
+ const attached = /\s+de\s+(?=\d)/i.exec(item);
1271
+ if (!attached) return false;
1272
+ const named = item.slice(0, attached.index).trim();
1273
+ if (!named || /\d/.test(named)) return false;
1274
+ return isStatedSize(item.slice(attached.index + attached[0].length));
1275
+ }
1276
+ function isStatedSize(text2) {
1277
+ const size = parseLeadingQuantity(text2, "fr");
1278
+ if (!size) return false;
1279
+ const measure = takeUnit(text2.slice(size.length).trimStart(), "fr");
1280
+ if (!measure.unit || measure.unit.kind !== "measured") return false;
1281
+ return !CONTAINER_CONTENTS.test(measure.rest);
1282
+ }
1259
1283
  var PER_PERSON = {
1260
1284
  en: /\bper\s+(?:person|head|serving|guest|diner)\b/i,
1261
1285
  fr: /\bpar\s+(?:personne|convive|part|t\u00eate)\b/i
@@ -1453,6 +1477,9 @@ function parseIngredient(line, choice = "auto") {
1453
1477
  rest = bracketed.rest;
1454
1478
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest, language);
1455
1479
  if (slashed) rest = slashed.rest;
1480
+ const item = stripItemLead(rest, language);
1481
+ if (language === "fr" && !leading.unit && statesItemSize(item)) return empty("itemSize");
1482
+ if (language === "fr" && leading.partitive && isStatedSize(item)) return empty("itemSize");
1456
1483
  return {
1457
1484
  original,
1458
1485
  language,
@@ -1507,12 +1534,12 @@ function readCountMultiplier(text2) {
1507
1534
  }
1508
1535
  function takeLeadingUnit(text2, language, fromArticle) {
1509
1536
  const taken = takeUnit(text2, language);
1510
- if (taken.unit) return taken;
1537
+ if (taken.unit) return { ...taken, partitive: false };
1511
1538
  if (language === "fr" && fromArticle) {
1512
1539
  const measure = readPartitiveMeasure(text2);
1513
- if (measure) return { unit: measure.unit, rest: measure.rest };
1540
+ if (measure) return { unit: measure.unit, rest: measure.rest, partitive: true };
1514
1541
  }
1515
- return taken;
1542
+ return { ...taken, partitive: false };
1516
1543
  }
1517
1544
  function stripItemLead(text2, language) {
1518
1545
  if (language === "fr") {
@@ -2045,6 +2072,7 @@ function scaleAlternative(tail, options) {
2045
2072
  }
2046
2073
  var HELD_BACK_NOTE = {
2047
2074
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
2075
+ itemSize: "The measure standing behind the item gives the size of one of them rather than how many, so the line is left as published. Serving more people is a matter of taking a bigger one, and serving fewer a smaller one.",
2048
2076
  perPerson: "This line already states an amount for one person, and the factor is what changes how many people the recipe serves, so the line is left as published.",
2049
2077
  ambiguousDecimal: "The comma in this number marks thousands in one convention and the decimal point in another, and the line gives no sign which was meant, so it is left as published."
2050
2078
  };
@@ -2235,7 +2263,13 @@ var reportSchema = z.object({
2235
2263
  code: z.string(),
2236
2264
  message: z.string(),
2237
2265
  hint: z.string().optional()
2238
- }).nullable().describe("Why this source did not answer. Null when it did.")
2266
+ }).nullable().describe("Why this source did not answer. Null when it did."),
2267
+ read: z.object({
2268
+ status: z.enum(["read", "failed"]),
2269
+ error: z.object({ code: z.string(), message: z.string(), hint: z.string().optional() }).nullable()
2270
+ }).nullable().describe(
2271
+ "What became of the row this source offered, once it was opened. Null when no row was opened, which is what a search that did not answer and a search holding nothing both come to. A search and a read are two moments, and this is the second one."
2272
+ )
2239
2273
  });
2240
2274
  var ingredientSchema = z.object({
2241
2275
  text: z.string().describe("The line as it now reads."),
@@ -2285,9 +2319,15 @@ function toReportPayload(report) {
2285
2319
  reported_total_means: report.reportedTotalMeans,
2286
2320
  skipped: report.skipped,
2287
2321
  cached: report.cached,
2288
- error: report.error ?? null
2322
+ error: report.error ?? null,
2323
+ // A search alone opens nothing, so only a tool that reads a row fills this
2324
+ // in, through `withRead`.
2325
+ read: null
2289
2326
  };
2290
2327
  }
2328
+ function withRead(payload, read) {
2329
+ return { ...payload, read };
2330
+ }
2291
2331
  function reportNotes(reports) {
2292
2332
  const notes = [];
2293
2333
  const answered = reports.filter((report) => report.status === "answered");
@@ -2721,14 +2761,19 @@ async function runCompareRecipes(client, args) {
2721
2761
  const reads = await Promise.allSettled(offered.map(([, id]) => client.getRecipe(id)));
2722
2762
  const recipes = [];
2723
2763
  const unread = /* @__PURE__ */ new Map();
2764
+ const opened = /* @__PURE__ */ new Set();
2724
2765
  reads.forEach((read, index) => {
2725
2766
  const source = offered[index][0];
2767
+ opened.add(source);
2726
2768
  if (read.status === "fulfilled") {
2727
2769
  recipes.push(read.value.recipe);
2728
2770
  return;
2729
2771
  }
2730
2772
  const reason = read.reason;
2731
- unread.set(source, `${reason?.code ?? "network_error"}: ${reason?.message ?? "unknown"}`);
2773
+ unread.set(source, {
2774
+ code: reason?.code ?? "network_error",
2775
+ message: reason?.message ?? "unknown"
2776
+ });
2732
2777
  });
2733
2778
  const views = recipes.map(
2734
2779
  (recipe) => buildRecipeView(recipe, {
@@ -2756,11 +2801,15 @@ async function runCompareRecipes(client, args) {
2756
2801
  const failedRead = unread.get(report.source);
2757
2802
  if (report.status === "failed") {
2758
2803
  notes.push(
2759
- `${report.name} is missing from this comparison because its search did not answer, not because it holds nothing.`
2804
+ `${report.name} is missing from this comparison because its search did not answer (${quoteForeign(
2805
+ `${report.error?.code ?? "unknown"}: ${report.error?.message ?? "unknown"}`
2806
+ )}). Nothing here is evidence about what it holds.`
2760
2807
  );
2761
2808
  } else if (failedRead) {
2762
2809
  notes.push(
2763
- `${report.name} offered a recipe and it could not be read (${quoteForeign(failedRead)}), so its version is missing rather than absent.`
2810
+ `${report.name}'s search answered and offered a version, and that version could not be read (${quoteForeign(
2811
+ `${failedRead.code}: ${failedRead.message}`
2812
+ )}). The failure is in the reading, so nothing here says whether ${report.name} holds this dish.`
2764
2813
  );
2765
2814
  } else {
2766
2815
  notes.push(
@@ -2817,7 +2866,17 @@ ${shownDifferences.map((line) => `- ${line}`).join("\n")}${moreDifferences > 0 ?
2817
2866
  dish: args.dish,
2818
2867
  versions: payloads,
2819
2868
  differences,
2820
- per_source: merged.reports.map(toReportPayload),
2869
+ // Two moments per source, told apart: whether the search answered, and
2870
+ // what became of the row it offered.
2871
+ per_source: merged.reports.map((report) => {
2872
+ const payload = toReportPayload(report);
2873
+ if (!opened.has(report.source)) return payload;
2874
+ const failedRead = unread.get(report.source);
2875
+ return withRead(
2876
+ payload,
2877
+ failedRead ? { status: "failed", error: { code: failedRead.code, message: failedRead.message } } : { status: "read", error: null }
2878
+ );
2879
+ }),
2821
2880
  notes
2822
2881
  },
2823
2882
  summary,
@@ -3029,6 +3088,13 @@ var searchRecipesOutput = z7.object({
3029
3088
  notes: z7.array(z7.string())
3030
3089
  });
3031
3090
  var WORDS_BEFORE_SAYING_SO = 4;
3091
+ function titleCarries(title, query) {
3092
+ const fold = (text2) => text2.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
3093
+ const haystack = ` ${fold(title)} `;
3094
+ const words = fold(query).split(" ").filter((word) => word.length > 2);
3095
+ if (words.length === 0) return true;
3096
+ return words.some((word) => haystack.includes(` ${word}`));
3097
+ }
3032
3098
  async function runSearchRecipes(client, args) {
3033
3099
  try {
3034
3100
  const merged = await client.searchRecipes(
@@ -3046,6 +3112,11 @@ async function runSearchRecipes(client, args) {
3046
3112
  "This query went to each source as free text and no filter was applied to it. Words like 'vegan' or 'under 300 calories' help only where a source's own index happens to match them, so read the rows rather than assuming they meet every condition."
3047
3113
  );
3048
3114
  }
3115
+ if (results.length > 0 && !results.some((row) => titleCarries(row.title, args.query))) {
3116
+ notes.push(
3117
+ `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.`
3118
+ );
3119
+ }
3049
3120
  if (results.length === 0 && answered.length === merged.reports.length) {
3050
3121
  notes.push(
3051
3122
  "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."