mcp-marmiton 1.7.0 → 1.8.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
@@ -5,6 +5,8 @@
5
5
  [![license](https://img.shields.io/npm/l/mcp-marmiton.svg)](./LICENSE)
6
6
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-6E56CF)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.smeet666/mcp-marmiton)
7
7
  [![Glama](https://glama.ai/mcp/servers/smeet666/mcp-marmiton/badges/score.svg)](https://glama.ai/mcp/servers/smeet666/mcp-marmiton)
8
+ [![Install in Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=marmiton&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC1tYXJtaXRvbiJdfQ%3D%3D)
9
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=marmiton&config=%7B%22name%22%3A%22marmiton%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22mcp-marmiton%22%5D%7D)
8
10
 
9
11
  An [MCP](https://modelcontextprotocol.io) server for [Marmiton](https://www.marmiton.org),
10
12
  the French recipe site. Search recipes, read their ingredients and steps, and
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.7.0";
7
+ var PKG_VERSION = "1.8.1";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -113,44 +113,6 @@ function createLogger(level) {
113
113
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
114
114
  import { z as z6 } from "zod";
115
115
 
116
- // src/marmiton/cache.ts
117
- var TtlLruCache = class {
118
- constructor(maxEntries, ttlMs) {
119
- this.maxEntries = maxEntries;
120
- this.ttlMs = ttlMs;
121
- }
122
- maxEntries;
123
- ttlMs;
124
- store = /* @__PURE__ */ new Map();
125
- get(key) {
126
- const entry = this.store.get(key);
127
- if (!entry) return void 0;
128
- if (Date.now() >= entry.expiresAt) {
129
- this.store.delete(key);
130
- return void 0;
131
- }
132
- this.store.delete(key);
133
- this.store.set(key, entry);
134
- return entry.value;
135
- }
136
- set(key, value) {
137
- if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
138
- this.store.delete(key);
139
- this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
140
- while (this.store.size > this.maxEntries) {
141
- const oldest = this.store.keys().next();
142
- if (oldest.done) break;
143
- this.store.delete(oldest.value);
144
- }
145
- }
146
- get size() {
147
- return this.store.size;
148
- }
149
- clear() {
150
- this.store.clear();
151
- }
152
- };
153
-
154
116
  // src/errors.ts
155
117
  var MarmitonError = class extends Error {
156
118
  constructor(code, message, details = {}) {
@@ -199,6 +161,44 @@ function upstreamError(url, status) {
199
161
  });
200
162
  }
201
163
 
164
+ // src/marmiton/cache.ts
165
+ var TtlLruCache = class {
166
+ constructor(maxEntries, ttlMs) {
167
+ this.maxEntries = maxEntries;
168
+ this.ttlMs = ttlMs;
169
+ }
170
+ maxEntries;
171
+ ttlMs;
172
+ store = /* @__PURE__ */ new Map();
173
+ get(key) {
174
+ const entry = this.store.get(key);
175
+ if (!entry) return void 0;
176
+ if (Date.now() >= entry.expiresAt) {
177
+ this.store.delete(key);
178
+ return void 0;
179
+ }
180
+ this.store.delete(key);
181
+ this.store.set(key, entry);
182
+ return entry.value;
183
+ }
184
+ set(key, value) {
185
+ if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
186
+ this.store.delete(key);
187
+ this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
188
+ while (this.store.size > this.maxEntries) {
189
+ const oldest = this.store.keys().next();
190
+ if (oldest.done) break;
191
+ this.store.delete(oldest.value);
192
+ }
193
+ }
194
+ get size() {
195
+ return this.store.size;
196
+ }
197
+ clear() {
198
+ this.store.clear();
199
+ }
200
+ };
201
+
202
202
  // src/marmiton/rateLimiter.ts
203
203
  var DEFAULT_MAX_INTERVAL_MS = 1e4;
204
204
  var RateLimiter = class {
@@ -647,7 +647,14 @@ var MarmitonClient = class {
647
647
  */
648
648
  async search(query) {
649
649
  const url = buildSearchUrl(query);
650
- return this.fetchParsed(url, (html) => parseSearchPage(html, url));
650
+ try {
651
+ return await this.fetchParsed(url, (html) => parseSearchPage(html, url));
652
+ } catch (error) {
653
+ if (error instanceof MarmitonError && error.code === "not_found") {
654
+ return { data: [], cached: false };
655
+ }
656
+ throw error;
657
+ }
651
658
  }
652
659
  async getRecipe(ref) {
653
660
  const { id, url } = resolveRecipeRef(ref);
@@ -1024,6 +1031,21 @@ function matchLeadingUnit(text, partitive = false) {
1024
1031
  return null;
1025
1032
  }
1026
1033
  var AMBIGUOUS_COMMA = /^\s*\d+,\d+,\d/;
1034
+ var CONTAINER_CONTENTS = /^\s*(?:de\s|d'|du\s|des\s)/i;
1035
+ function statesItemSize(item) {
1036
+ const attached = /\s+de\s+(?=\d)/i.exec(item);
1037
+ if (!attached) return false;
1038
+ const named = item.slice(0, attached.index).trim();
1039
+ if (!named || /\d/.test(named)) return false;
1040
+ return isStatedSize(item.slice(attached.index + attached[0].length));
1041
+ }
1042
+ function isStatedSize(text) {
1043
+ const size = parseLeadingQuantity(text);
1044
+ if (!size) return false;
1045
+ const measure = matchLeadingUnit(text.slice(size.length).trimStart());
1046
+ if (!measure || measure.unit.kind !== "measured") return false;
1047
+ return !CONTAINER_CONTENTS.test(measure.rest);
1048
+ }
1027
1049
  var PER_PERSON = /\bpar\s+(?:personne|convive|part|tête)\b/i;
1028
1050
  var APPROXIMATION_PREFIX = /^(?:~|≈|environ|approximativement|à peu près|a peu pres)\s*/i;
1029
1051
  var MEASURE_ADJECTIVES = /* @__PURE__ */ new Set([
@@ -1093,6 +1115,10 @@ function parseIngredient(line) {
1093
1115
  rest = bracketed.rest;
1094
1116
  const slashed = bracketed.measures.length > 0 ? null : takeSlashAlternates(rest);
1095
1117
  if (slashed) rest = slashed.rest;
1118
+ const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
1119
+ if (!unit && statesItemSize(item)) return empty("itemSize");
1120
+ const readForItsPosition = unitText !== null && lookupUnit(normalizeUnitKey(unitText)) === null;
1121
+ if (unit && readForItsPosition && isStatedSize(item)) return empty("itemSize");
1096
1122
  return {
1097
1123
  original,
1098
1124
  heldBack: PER_PERSON.test(text) ? "perPerson" : null,
@@ -1105,13 +1131,7 @@ function parseIngredient(line) {
1105
1131
  unitText,
1106
1132
  alternates: slashed ? slashed.measures : bracketed.measures,
1107
1133
  alternateStyle: slashed ? "slash" : bracketed.measures.length > 0 ? "bracket" : null,
1108
- // "200 g de farine" reads better as item "farine" than "de farine".
1109
- //
1110
- // The article a partitive introduces goes with it: "2/3 d'un flacon" names a
1111
- // share of one flacon, and once the share has been multiplied the count sits
1112
- // where "un" stood. Leaving the article behind produces "4 un flacon", which
1113
- // reads as broken text rather than as a quantity.
1114
- item: rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim(),
1134
+ item,
1115
1135
  articleWord: fromArticle ? article?.word ?? null : null,
1116
1136
  countMultiplier: multiplier?.times ?? null
1117
1137
  };
@@ -1507,6 +1527,7 @@ function scaleAlternative(tail, options) {
1507
1527
  }
1508
1528
  var HELD_BACK_NOTE = {
1509
1529
  sizeQualifier: "The figures here give the size of one item rather than how many, so the line is left as published.",
1530
+ 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.",
1510
1531
  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.",
1511
1532
  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."
1512
1533
  };
@@ -2073,6 +2094,13 @@ var searchRecipesOutputShape = {
2073
2094
  source: z5.literal("marmiton.org"),
2074
2095
  notes: z5.array(z5.string())
2075
2096
  };
2097
+ function titleCarries(title, query) {
2098
+ const fold2 = (text) => text.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
2099
+ const haystack = ` ${fold2(title)} `;
2100
+ const words = fold2(query).split(" ").filter((word) => word.length > 2);
2101
+ if (words.length === 0) return true;
2102
+ return words.some((word) => haystack.includes(` ${word}`));
2103
+ }
2076
2104
  async function runSearchRecipes(client, args) {
2077
2105
  try {
2078
2106
  const query = args.query.trim();
@@ -2099,6 +2127,11 @@ async function runSearchRecipes(client, args) {
2099
2127
  url: recipe.url,
2100
2128
  image_url: recipe.imageUrl
2101
2129
  }));
2130
+ if (results.length > 0 && !results.some((recipe) => titleCarries(recipe.title, query))) {
2131
+ notes.push(
2132
+ `No title here carries a word of "${query}". Marmiton ranks a title on the letters it opens with, so these rows are what the site offered for that spelling. Read them as candidates to check rather than as recipes for the dish.`
2133
+ );
2134
+ }
2102
2135
  const structured = {
2103
2136
  query,
2104
2137
  results,