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 +2 -0
- package/dist/index.js +80 -47
- package/dist/index.js.map +1 -1
- package/dist/marmiton/client.js +47 -40
- package/dist/marmiton/client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +5 -9
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
[](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.smeet666/mcp-marmiton)
|
|
7
7
|
[](https://glama.ai/mcp/servers/smeet666/mcp-marmiton)
|
|
8
|
+
[](https://cursor.com/en/install-mcp?name=marmiton&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsIm1jcC1tYXJtaXRvbiJdfQ%3D%3D)
|
|
9
|
+
[](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
|
+
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
|
-
|
|
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
|
-
|
|
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,
|