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 +31 -0
- package/dist/index.js +82 -11
- package/dist/index.js.map +1 -1
- package/dist/recipe/scale.js +22 -3
- package/dist/recipe/scale.js.map +1 -1
- package/dist/sources/client.js +11 -2
- package/dist/sources/client.js.map +1 -1
- package/package.json +4 -4
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.
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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."
|