mcp-recipes 1.0.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 +147 -0
- package/LICENSE +21 -0
- package/README.md +684 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3037 -0
- package/dist/index.js.map +1 -0
- package/dist/language-BgfmjwtF.d.ts +14 -0
- package/dist/recipe/scale.d.ts +86 -0
- package/dist/recipe/scale.js +1367 -0
- package/dist/recipe/scale.js.map +1 -0
- package/dist/sources/client.d.ts +448 -0
- package/dist/sources/client.js +697 -0
- package/dist/sources/client.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { a as Language } from '../language-BgfmjwtF.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings, read from the environment.
|
|
5
|
+
*
|
|
6
|
+
* A value that cannot be read warns and falls back rather than stopping the
|
|
7
|
+
* server: a typo in one variable should not take away every tool. Warnings go
|
|
8
|
+
* to stderr, because stdout carries the protocol and anything written there
|
|
9
|
+
* corrupts the session.
|
|
10
|
+
*
|
|
11
|
+
* One setting governs every source. Each is read through its own client, and
|
|
12
|
+
* each of those keeps a pacing floor of its own that nothing here can lower, so
|
|
13
|
+
* a value set here can only make this server more patient than it already is.
|
|
14
|
+
*/
|
|
15
|
+
declare const LOG_LEVELS: readonly ["silent", "error", "info", "debug"];
|
|
16
|
+
type LogLevel = (typeof LOG_LEVELS)[number];
|
|
17
|
+
interface Config {
|
|
18
|
+
userAgent: string;
|
|
19
|
+
minIntervalMs: number;
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
maxRetries: number;
|
|
22
|
+
cacheTtlMs: number;
|
|
23
|
+
cacheMaxEntries: number;
|
|
24
|
+
logLevel: LogLevel;
|
|
25
|
+
}
|
|
26
|
+
interface Logger {
|
|
27
|
+
debug(message: string): void;
|
|
28
|
+
info(message: string): void;
|
|
29
|
+
warn(message: string): void;
|
|
30
|
+
error(message: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The shapes the source layer produces. Nothing here knows about MCP. */
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which corpus a row, a recipe or a failure came from.
|
|
37
|
+
*
|
|
38
|
+
* A plain string rather than a closed set of names: the sources this server
|
|
39
|
+
* reads are registered rather than compiled in, so adding one is an entry in
|
|
40
|
+
* the registry and changes nothing here. What the server reads today is
|
|
41
|
+
* whatever the registry holds, and an answer names it.
|
|
42
|
+
*/
|
|
43
|
+
type SourceId = string;
|
|
44
|
+
/** What a source is called, where it lives, and what it asks of a reader. */
|
|
45
|
+
interface SourceProfile {
|
|
46
|
+
id: SourceId;
|
|
47
|
+
/** What to call the source in prose and in a credit line. */
|
|
48
|
+
name: string;
|
|
49
|
+
/** The source's own home, for a reader who wants to go there. */
|
|
50
|
+
homeUrl: string;
|
|
51
|
+
/** The language the source publishes in, which its lines are read in. */
|
|
52
|
+
language: Language;
|
|
53
|
+
/** What a caller has to say when repeating the source's text. */
|
|
54
|
+
attribution: string;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the corpus files pages about ingredients beside recipes using
|
|
57
|
+
* them. Where it does, a search row can be either, and only reading the page
|
|
58
|
+
* tells them apart, so an answer holding rows from such a source says so.
|
|
59
|
+
*/
|
|
60
|
+
mixesReferencePages: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** A search row, trimmed to what picks one recipe out of a list. */
|
|
63
|
+
interface RecipeRow {
|
|
64
|
+
/** Carries the source, and the string get_recipe takes back. */
|
|
65
|
+
id: string;
|
|
66
|
+
source: SourceId;
|
|
67
|
+
sourceName: string;
|
|
68
|
+
title: string;
|
|
69
|
+
url: string;
|
|
70
|
+
imageUrl: string | null;
|
|
71
|
+
/** The gloss or the matching passage the source offered, when it offered one. */
|
|
72
|
+
excerpt: string | null;
|
|
73
|
+
}
|
|
74
|
+
/** A rating, always with the scale it sits on. */
|
|
75
|
+
interface Rating {
|
|
76
|
+
value: number;
|
|
77
|
+
count: number | null;
|
|
78
|
+
/** The top of the scale. A rating read without it means nothing. */
|
|
79
|
+
max: number | null;
|
|
80
|
+
}
|
|
81
|
+
/** One recipe, in the shape every source is read into. */
|
|
82
|
+
interface RecipeDetail {
|
|
83
|
+
id: string;
|
|
84
|
+
source: SourceId;
|
|
85
|
+
sourceName: string;
|
|
86
|
+
/** The language this recipe's lines are written in. */
|
|
87
|
+
language: Language;
|
|
88
|
+
title: string;
|
|
89
|
+
url: string;
|
|
90
|
+
imageUrl: string | null;
|
|
91
|
+
/** Servings the page states as a number, when it states one. */
|
|
92
|
+
yieldCount: number | null;
|
|
93
|
+
/**
|
|
94
|
+
* The upper end when the page states a range, as in "4 à 6 personnes". Null
|
|
95
|
+
* when the page names one number, which is the ordinary case.
|
|
96
|
+
*/
|
|
97
|
+
yieldMax: number | null;
|
|
98
|
+
/** The yield exactly as published, which can be "4 à 6 personnes" or "24 balls". */
|
|
99
|
+
yieldText: string | null;
|
|
100
|
+
/** What the yield counts, when it counts something other than people. */
|
|
101
|
+
yieldUnit: string | null;
|
|
102
|
+
/** The lines as published, before any scaling. */
|
|
103
|
+
ingredients: string[];
|
|
104
|
+
steps: string[];
|
|
105
|
+
prepMinutes: number | null;
|
|
106
|
+
cookMinutes: number | null;
|
|
107
|
+
totalMinutes: number | null;
|
|
108
|
+
category: string | null;
|
|
109
|
+
author: string | null;
|
|
110
|
+
rating: Rating | null;
|
|
111
|
+
/** The panel as published, key by key, with what the page left out omitted. */
|
|
112
|
+
nutrition: Record<string, string> | null;
|
|
113
|
+
equipment: string[];
|
|
114
|
+
tips: string[];
|
|
115
|
+
/** Terms the page is published under, when the source states them. */
|
|
116
|
+
license: {
|
|
117
|
+
title: string;
|
|
118
|
+
url: string;
|
|
119
|
+
} | null;
|
|
120
|
+
attribution: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* What one source answered, or why it did not.
|
|
124
|
+
*
|
|
125
|
+
* The two travel together so an answer can never read as an absence: a caller
|
|
126
|
+
* seeing `status: "failed"` knows the source was asked and did not reply, which
|
|
127
|
+
* is a different statement from a source that answered and holds nothing.
|
|
128
|
+
*/
|
|
129
|
+
interface SourceReport {
|
|
130
|
+
source: SourceId;
|
|
131
|
+
name: string;
|
|
132
|
+
status: "answered" | "failed";
|
|
133
|
+
/** Rows this source contributed to the answer. */
|
|
134
|
+
count: number;
|
|
135
|
+
/** Rows the source said it saw, in the terms that source counts in. */
|
|
136
|
+
reportedTotal: number | null;
|
|
137
|
+
/** What `reportedTotal` counts on this source, in words. */
|
|
138
|
+
reportedTotalMeans: string | null;
|
|
139
|
+
/** Rows the source sent that could not be read, and were left out. */
|
|
140
|
+
skipped: number;
|
|
141
|
+
/** Whether this source files reference pages beside recipes. */
|
|
142
|
+
mixesReferencePages: boolean;
|
|
143
|
+
cached: boolean;
|
|
144
|
+
error: {
|
|
145
|
+
code: string;
|
|
146
|
+
message: string;
|
|
147
|
+
hint?: string;
|
|
148
|
+
} | null;
|
|
149
|
+
}
|
|
150
|
+
interface MergedSearch {
|
|
151
|
+
rows: RecipeRow[];
|
|
152
|
+
reports: SourceReport[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What a source has to provide to be one.
|
|
157
|
+
*
|
|
158
|
+
* Everything above this file is written for however many sources the registry
|
|
159
|
+
* holds. A source knows four things nothing else does: what it is called, how
|
|
160
|
+
* to recognise one of its own identifiers, how to search it, and how to read one
|
|
161
|
+
* recipe off it. Adding a corpus is writing those four and registering it.
|
|
162
|
+
*
|
|
163
|
+
* The helpers below are the ones every adapter needs, because no source answers
|
|
164
|
+
* with a contract: a field can arrive missing, null, or holding something other
|
|
165
|
+
* than the type it usually holds.
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/** Rows that could be read, and how many could not. */
|
|
169
|
+
interface ReadRows {
|
|
170
|
+
rows: RecipeRow[];
|
|
171
|
+
/** Rows the source sent in a shape this server could not read. */
|
|
172
|
+
skipped: number;
|
|
173
|
+
/** Rows the source said it saw, in the terms that source counts in. */
|
|
174
|
+
reportedTotal: number | null;
|
|
175
|
+
/** What `reportedTotal` counts on this source, in words. Null when it states none. */
|
|
176
|
+
reportedTotalMeans: string | null;
|
|
177
|
+
cached: boolean;
|
|
178
|
+
}
|
|
179
|
+
interface ReadRecipe {
|
|
180
|
+
recipe: RecipeDetail;
|
|
181
|
+
cached: boolean;
|
|
182
|
+
}
|
|
183
|
+
/** How a source recognised a raw identifier as one of its own. */
|
|
184
|
+
interface Claim {
|
|
185
|
+
/** The string this source's own reader takes. */
|
|
186
|
+
reference: string;
|
|
187
|
+
/** Why the shape was read this way, for an answer that has to say so. */
|
|
188
|
+
why: string;
|
|
189
|
+
/**
|
|
190
|
+
* Whether reading it this way is a guess. A shape only this source mints is
|
|
191
|
+
* not; a shape it would merely accept is, and the answer says so.
|
|
192
|
+
*/
|
|
193
|
+
guess: boolean;
|
|
194
|
+
}
|
|
195
|
+
interface SourceAdapter extends SourceProfile {
|
|
196
|
+
/**
|
|
197
|
+
* Recognise a raw identifier, or decline it.
|
|
198
|
+
*
|
|
199
|
+
* A source claims a shape it mints and declines everything else, so a string
|
|
200
|
+
* no source claims is refused, instead of being sent somewhere that would
|
|
201
|
+
* answer it with a confident absence.
|
|
202
|
+
*/
|
|
203
|
+
claims(raw: string): Claim | null;
|
|
204
|
+
search(query: string, limit: number): Promise<ReadRows>;
|
|
205
|
+
getRecipe(reference: string): Promise<ReadRecipe>;
|
|
206
|
+
}
|
|
207
|
+
/** Build the identifier this server hands out for a row from a source. */
|
|
208
|
+
declare function namespacedId(source: SourceId, reference: string): string;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Identifiers that name the source they came from.
|
|
212
|
+
*
|
|
213
|
+
* Every source hands back an opaque string, and a caller holding a merged list
|
|
214
|
+
* has no way to tell one source's string from another's. Every identifier this
|
|
215
|
+
* server returns therefore carries a prefix, and `get_recipe` routes on it
|
|
216
|
+
* rather than trying each source in turn.
|
|
217
|
+
*/
|
|
218
|
+
|
|
219
|
+
interface ResolvedId {
|
|
220
|
+
source: SourceAdapter;
|
|
221
|
+
/** The string that source's own reader takes. */
|
|
222
|
+
reference: string;
|
|
223
|
+
/** Set when the shape was read rather than stated, so the answer can say so. */
|
|
224
|
+
inferred: string | null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Work out which source an identifier belongs to.
|
|
228
|
+
*
|
|
229
|
+
* A prefixed identifier is routed on what it says. A raw one is offered to each
|
|
230
|
+
* source in turn, and a source claims only the shapes it mints: digits for one,
|
|
231
|
+
* a namespaced page key for another. Exactly one claim routes the read and the
|
|
232
|
+
* answer says which reading it used.
|
|
233
|
+
*
|
|
234
|
+
* More than one claim is refused rather than resolved, because picking a winner
|
|
235
|
+
* would send the read somewhere a caller did not intend and answer with a
|
|
236
|
+
* confident recipe for the wrong dish. No claim at all is refused too: a bare
|
|
237
|
+
* dish name is a search, not an identifier, and guessing a page name from it
|
|
238
|
+
* returns whatever that guess happens to hit.
|
|
239
|
+
*/
|
|
240
|
+
declare function resolveId(rawId: string, sources: readonly SourceAdapter[]): ResolvedId;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The Cookbook on the English Wikibooks, as a source.
|
|
244
|
+
*
|
|
245
|
+
* A wiki written by whoever edits it, published under a licence that requires
|
|
246
|
+
* attribution, with no author and no reader rating by nature. Its identifiers
|
|
247
|
+
* are page keys carrying the namespace every Cookbook page lives in.
|
|
248
|
+
*/
|
|
249
|
+
|
|
250
|
+
/** A search row, as the Cookbook's own reader publishes one. */
|
|
251
|
+
interface CookbookSummary {
|
|
252
|
+
key: string;
|
|
253
|
+
title: string;
|
|
254
|
+
description: string | null;
|
|
255
|
+
excerpt: string | null;
|
|
256
|
+
imageUrl: string | null;
|
|
257
|
+
sourceUrl: string;
|
|
258
|
+
}
|
|
259
|
+
/** A page, as the Cookbook's own reader publishes one. */
|
|
260
|
+
interface CookbookRecipe {
|
|
261
|
+
key: string;
|
|
262
|
+
title: string;
|
|
263
|
+
sourceUrl: string;
|
|
264
|
+
license: {
|
|
265
|
+
title: string;
|
|
266
|
+
url: string;
|
|
267
|
+
} | null;
|
|
268
|
+
description: string | null;
|
|
269
|
+
category: string | null;
|
|
270
|
+
servings: number | null;
|
|
271
|
+
yieldText: string | null;
|
|
272
|
+
yieldUnit: string | null;
|
|
273
|
+
totalMinutes: number | null;
|
|
274
|
+
ingredients: string[];
|
|
275
|
+
equipment: string[];
|
|
276
|
+
steps: string[];
|
|
277
|
+
tips: string[];
|
|
278
|
+
nutrition: unknown;
|
|
279
|
+
}
|
|
280
|
+
/** The part of the Cookbook's client this server uses. */
|
|
281
|
+
interface CookbookReader {
|
|
282
|
+
search(query: string, limit: number): Promise<{
|
|
283
|
+
data: {
|
|
284
|
+
results: CookbookSummary[];
|
|
285
|
+
};
|
|
286
|
+
cached: boolean;
|
|
287
|
+
}>;
|
|
288
|
+
getRecipe(reference: string): Promise<{
|
|
289
|
+
data: CookbookRecipe;
|
|
290
|
+
cached: boolean;
|
|
291
|
+
}>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Marmiton, as a source.
|
|
296
|
+
*
|
|
297
|
+
* A French recipe site with reader ratings, whose identifiers are digits and
|
|
298
|
+
* whose search serves a single page it disallows paging past.
|
|
299
|
+
*/
|
|
300
|
+
|
|
301
|
+
/** A search row, as Marmiton's own reader publishes one. */
|
|
302
|
+
interface MarmitonSummary {
|
|
303
|
+
id: string;
|
|
304
|
+
title: string;
|
|
305
|
+
url: string;
|
|
306
|
+
imageUrl: string | null;
|
|
307
|
+
}
|
|
308
|
+
/** A recipe, as Marmiton's own reader publishes one. */
|
|
309
|
+
interface MarmitonRecipe {
|
|
310
|
+
id: string;
|
|
311
|
+
title: string;
|
|
312
|
+
url: string;
|
|
313
|
+
imageUrl: string | null;
|
|
314
|
+
ingredients: string[];
|
|
315
|
+
steps: string[];
|
|
316
|
+
recipeYield: {
|
|
317
|
+
count: number | null;
|
|
318
|
+
unit: string | null;
|
|
319
|
+
text: string;
|
|
320
|
+
};
|
|
321
|
+
prepMinutes: number | null;
|
|
322
|
+
cookMinutes: number | null;
|
|
323
|
+
totalMinutes: number | null;
|
|
324
|
+
category: string | null;
|
|
325
|
+
author: string | null;
|
|
326
|
+
rating: {
|
|
327
|
+
value: number;
|
|
328
|
+
count: number | null;
|
|
329
|
+
best: number | null;
|
|
330
|
+
} | null;
|
|
331
|
+
nutrition: unknown;
|
|
332
|
+
}
|
|
333
|
+
/** The part of Marmiton's client this server uses. */
|
|
334
|
+
interface MarmitonReader {
|
|
335
|
+
search(query: string): Promise<{
|
|
336
|
+
data: MarmitonSummary[];
|
|
337
|
+
cached: boolean;
|
|
338
|
+
}>;
|
|
339
|
+
getRecipe(ref: {
|
|
340
|
+
id?: string;
|
|
341
|
+
url?: string;
|
|
342
|
+
}): Promise<{
|
|
343
|
+
data: MarmitonRecipe;
|
|
344
|
+
cached: boolean;
|
|
345
|
+
}>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The sources this server reads.
|
|
350
|
+
*
|
|
351
|
+
* One list, and everything above it is written for however long the list is.
|
|
352
|
+
* Adding a corpus means writing an adapter and adding it here; no tool, no
|
|
353
|
+
* merge and no error path has a branch per source.
|
|
354
|
+
*/
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* A reader may be supplied in place of the one that talks to a site, so a
|
|
358
|
+
* program embedding this server can put its own cache in front of a corpus, and
|
|
359
|
+
* a test can drive it from fixed answers.
|
|
360
|
+
*/
|
|
361
|
+
interface Readers {
|
|
362
|
+
marmiton?: MarmitonReader;
|
|
363
|
+
cookbook?: CookbookReader;
|
|
364
|
+
}
|
|
365
|
+
/** The sources a caller can name, in the order an answer takes them. */
|
|
366
|
+
declare const SOURCE_IDS: readonly SourceId[];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The one place that talks to the sources.
|
|
370
|
+
*
|
|
371
|
+
* It asks them all at once and merges what comes back, so a question is
|
|
372
|
+
* answered by whichever of them holds something. It imports nothing from the
|
|
373
|
+
* MCP layer and is published on its own, so the same code serves a plain
|
|
374
|
+
* script.
|
|
375
|
+
*
|
|
376
|
+
* A source that fails is reported as a source that failed. It is never folded
|
|
377
|
+
* into the answer as an absence: "Marmiton was unreachable" and "Marmiton holds
|
|
378
|
+
* no such recipe" are different statements about the world, and a caller that
|
|
379
|
+
* cannot tell them apart will say the second when only the first is true.
|
|
380
|
+
*/
|
|
381
|
+
|
|
382
|
+
interface RecipesClientOptions {
|
|
383
|
+
config?: Partial<Config>;
|
|
384
|
+
logger?: Logger;
|
|
385
|
+
/** Stands in for one or more of the site readers. */
|
|
386
|
+
readers?: Readers;
|
|
387
|
+
/** Replaces the registry outright, for a program bringing its own corpora. */
|
|
388
|
+
sources?: SourceAdapter[];
|
|
389
|
+
}
|
|
390
|
+
/** The largest number of rows any one source is asked for in a single call. */
|
|
391
|
+
declare const MAX_LIMIT_PER_SOURCE = 25;
|
|
392
|
+
/**
|
|
393
|
+
* Interleave rows so no source opens the list twice before another has opened
|
|
394
|
+
* it once.
|
|
395
|
+
*
|
|
396
|
+
* Ranking them against each other would need a score every source carries, and
|
|
397
|
+
* there is none: one has reader ratings, another has no author and no rating by
|
|
398
|
+
* nature, and a single ordering would rank on a field most rows cannot have.
|
|
399
|
+
* Taking one from each in turn is an order a caller can describe exactly, which
|
|
400
|
+
* is what the answer does.
|
|
401
|
+
*/
|
|
402
|
+
declare function interleave(groups: RecipeRow[][]): RecipeRow[];
|
|
403
|
+
declare class RecipesClient {
|
|
404
|
+
private readonly config;
|
|
405
|
+
private readonly logger;
|
|
406
|
+
private readonly sources;
|
|
407
|
+
constructor(options?: RecipesClientOptions);
|
|
408
|
+
/** What every source sees this client call itself. */
|
|
409
|
+
get userAgent(): string;
|
|
410
|
+
get intervalMs(): number;
|
|
411
|
+
get timeoutMs(): number;
|
|
412
|
+
get maxRetries(): number;
|
|
413
|
+
/** The sources this server reads, in the order an answer takes them. */
|
|
414
|
+
get profiles(): Array<{
|
|
415
|
+
id: SourceId;
|
|
416
|
+
name: string;
|
|
417
|
+
language: string;
|
|
418
|
+
homeUrl: string;
|
|
419
|
+
}>;
|
|
420
|
+
/**
|
|
421
|
+
* The backstop deadline over one source, covering every attempt it is
|
|
422
|
+
* entitled to make and the pacing between them.
|
|
423
|
+
*/
|
|
424
|
+
private get deadlineMs();
|
|
425
|
+
/**
|
|
426
|
+
* Ask every source the same question and merge the rows.
|
|
427
|
+
*
|
|
428
|
+
* The calls go out together, so the answer takes as long as the slowest
|
|
429
|
+
* source rather than as long as all of them. Whichever of them fail, the rest
|
|
430
|
+
* still come back, and the report says which was which.
|
|
431
|
+
*/
|
|
432
|
+
searchRecipes(query: string, limitPerSource: number, wanted?: readonly SourceId[]): Promise<MergedSearch>;
|
|
433
|
+
private searchOne;
|
|
434
|
+
/**
|
|
435
|
+
* Read one recipe, from whichever source the identifier names.
|
|
436
|
+
*
|
|
437
|
+
* Only that source is called. Trying another after a miss would turn "this
|
|
438
|
+
* source has no page by this name" into somebody else's recipe with a
|
|
439
|
+
* different title, which is a worse answer than the absence it replaced.
|
|
440
|
+
*/
|
|
441
|
+
getRecipe(id: string): Promise<{
|
|
442
|
+
recipe: RecipeDetail;
|
|
443
|
+
cached: boolean;
|
|
444
|
+
read: ResolvedId;
|
|
445
|
+
}>;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export { type CookbookReader, MAX_LIMIT_PER_SOURCE, type MarmitonReader, type MergedSearch, type ReadRecipe, type ReadRows, type Readers, type RecipeDetail, type RecipeRow, RecipesClient, type RecipesClientOptions, SOURCE_IDS, type SourceAdapter, type SourceId, type SourceProfile, type SourceReport, interleave, namespacedId, resolveId };
|