@bojanrajkovic/mcp-paprika 1.5.0-beta.2 → 1.5.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/dist/paprika/client.js +51 -9
- package/dist/tools/filter.js +44 -2
- package/dist/tools/menu-item-write.d.ts +68 -6
- package/dist/tools/menu-item-write.js +53 -27
- package/dist/utils/duration.js +7 -1
- package/package.json +1 -1
package/dist/paprika/client.js
CHANGED
|
@@ -176,16 +176,58 @@ export class PaprikaClient {
|
|
|
176
176
|
});
|
|
177
177
|
}
|
|
178
178
|
async authenticate() {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
179
|
+
// One authentication attempt. Classifies failures the same way request()
|
|
180
|
+
// does so the shared retry policy can tell transient blips (retry) from a
|
|
181
|
+
// real auth rejection (fail fast).
|
|
182
|
+
const attempt = async () => {
|
|
183
|
+
let response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(AUTH_URL, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
body: new URLSearchParams({ email: this.email, password: this.password }),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
// undici throws a bare TypeError for network-level failures (DNS, TCP
|
|
192
|
+
// reset, TLS handshake). Mark it retryable so a transient blip at
|
|
193
|
+
// startup backs off and retries instead of crashlooping the process (#158).
|
|
194
|
+
if (error instanceof TypeError) {
|
|
195
|
+
throw new NetworkRetryableError(error);
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
// Transient upstream failures (5xx / 429) are worth retrying; a real
|
|
201
|
+
// auth rejection (401/403, bad credentials) is not — fail fast.
|
|
202
|
+
if (RETRYABLE_STATUSES.has(response.status)) {
|
|
203
|
+
throw new TransientHTTPError(response.status);
|
|
204
|
+
}
|
|
205
|
+
throw new PaprikaAuthError(`Authentication failed (HTTP ${response.status.toString()})`);
|
|
206
|
+
}
|
|
207
|
+
const json = await response.json();
|
|
208
|
+
const data = AuthResponseSchema.parse(json);
|
|
209
|
+
this.token = data.result.token;
|
|
210
|
+
};
|
|
211
|
+
// Reuse the same bounded retry policy as request() (maxAttempts: 3, exp
|
|
212
|
+
// backoff) so a transient network/5xx failure at startup retries instead of
|
|
213
|
+
// throwing on the first blip. The circuit breaker is intentionally NOT
|
|
214
|
+
// applied — startup auth is one-shot, not a hot path. Non-retryable errors
|
|
215
|
+
// (PaprikaAuthError on bad creds, ZodError on a malformed body) are not
|
|
216
|
+
// matched by the policy and propagate immediately.
|
|
217
|
+
try {
|
|
218
|
+
await this.retryPolicy.execute(attempt);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
// Once the bounded retries are exhausted, surface a clean PaprikaAuthError
|
|
222
|
+
// (preserving the underlying cause) rather than the internal retry marker.
|
|
223
|
+
if (error instanceof NetworkRetryableError) {
|
|
224
|
+
throw new PaprikaAuthError("Authentication failed (network error)", { cause: error.cause });
|
|
225
|
+
}
|
|
226
|
+
if (error instanceof TransientHTTPError) {
|
|
227
|
+
throw new PaprikaAuthError(`Authentication failed (HTTP ${error.status.toString()})`, { cause: error });
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
185
230
|
}
|
|
186
|
-
const json = await response.json();
|
|
187
|
-
const data = AuthResponseSchema.parse(json);
|
|
188
|
-
this.token = data.result.token;
|
|
189
231
|
}
|
|
190
232
|
async listRecipes() {
|
|
191
233
|
return this.request("GET", `${API_BASE}/recipes/`, z.array(RecipeEntrySchema));
|
package/dist/tools/filter.js
CHANGED
|
@@ -34,7 +34,11 @@ export function registerFilterTools(server, ctx) {
|
|
|
34
34
|
}, (guard) => guard);
|
|
35
35
|
});
|
|
36
36
|
server.registerTool("filter_by_time", {
|
|
37
|
-
description: "Filter recipes by prep, cook, or total time. All constraints are optional
|
|
37
|
+
description: "Filter recipes by prep, cook, or total time. All constraints are optional; results are sorted by " +
|
|
38
|
+
"total time ascending. ADVISORY: a recipe whose relevant time can't be parsed (free-text like " +
|
|
39
|
+
'"5+ hours" or "overnight") is NOT hidden — it is included and flagged "Time unverified" so quick ' +
|
|
40
|
+
"recipes with odd time strings aren't silently dropped. For any flagged result, check the displayed " +
|
|
41
|
+
"time yourself rather than trusting the filter for that one.",
|
|
38
42
|
inputSchema: {
|
|
39
43
|
maxPrepTime: z.string().optional().describe('Maximum prep time (e.g., "30 minutes", "1 hr")'),
|
|
40
44
|
maxCookTime: z.string().optional().describe('Maximum cook time (e.g., "45 min", "1 hour")'),
|
|
@@ -66,7 +70,7 @@ export function registerFilterTools(server, ctx) {
|
|
|
66
70
|
if (results.length === 0) {
|
|
67
71
|
return textResult("No recipes found matching the specified time constraints.");
|
|
68
72
|
}
|
|
69
|
-
return textResult(
|
|
73
|
+
return textResult(formatTimeFilterResults(results, ctx, constraints));
|
|
70
74
|
}, (errorMsg) => textResult(errorMsg));
|
|
71
75
|
}, (guard) => guard);
|
|
72
76
|
});
|
|
@@ -97,3 +101,41 @@ function formatRecipeItem(recipe, categoryNames) {
|
|
|
97
101
|
lines.push(...recipeMetadataLines(recipe));
|
|
98
102
|
return lines.join("\n");
|
|
99
103
|
}
|
|
104
|
+
// Which active time constraints could NOT be confirmed for this recipe. A recipe
|
|
105
|
+
// is "verified" against a constraint only when its corresponding field parses —
|
|
106
|
+
// inclusion already guarantees it's within the bound, since the store excludes
|
|
107
|
+
// parse-and-exceed recipes. A null or unparseable field (free-text like
|
|
108
|
+
// "5+ hours" or "overnight") is kept (the store stays lenient — AC5.5 / issue
|
|
109
|
+
// #162) but can't be confirmed, so filter_by_time flags it as advisory rather
|
|
110
|
+
// than silently presenting it as a clean match.
|
|
111
|
+
function unverifiedTimeFields(recipe, constraints) {
|
|
112
|
+
const unverified = [];
|
|
113
|
+
const check = (max, value, label) => {
|
|
114
|
+
if (max === undefined)
|
|
115
|
+
return;
|
|
116
|
+
const parses = value !== null &&
|
|
117
|
+
parseDuration(value).match(() => true, () => false);
|
|
118
|
+
if (!parses)
|
|
119
|
+
unverified.push(label);
|
|
120
|
+
};
|
|
121
|
+
check(constraints.maxPrepTime, recipe.prepTime, "prep time");
|
|
122
|
+
check(constraints.maxCookTime, recipe.cookTime, "cook time");
|
|
123
|
+
check(constraints.maxTotalTime, recipe.totalTime, "total time");
|
|
124
|
+
return unverified;
|
|
125
|
+
}
|
|
126
|
+
// filter_by_time-specific renderer: same item formatting as filter_by_ingredient,
|
|
127
|
+
// plus a one-line "Time unverified" advisory appended to any recipe whose time
|
|
128
|
+
// couldn't be confirmed against the active constraints.
|
|
129
|
+
function formatTimeFilterResults(recipes, ctx, constraints) {
|
|
130
|
+
return recipes
|
|
131
|
+
.map((recipe) => {
|
|
132
|
+
const categoryNames = ctx.store.resolveCategories(recipe.categories);
|
|
133
|
+
const item = formatRecipeItem(recipe, categoryNames);
|
|
134
|
+
const unverified = unverifiedTimeFields(recipe, constraints);
|
|
135
|
+
if (unverified.length === 0)
|
|
136
|
+
return item;
|
|
137
|
+
return (`${item}\n> ⚠️ _Time unverified — couldn't parse this recipe's ${unverified.join(" / ")} against your ` +
|
|
138
|
+
`limit, so it's shown rather than hidden. Check the displayed time before relying on it._`);
|
|
139
|
+
})
|
|
140
|
+
.join("\n\n---\n\n");
|
|
141
|
+
}
|
|
@@ -15,7 +15,7 @@ export declare const addMenuItemsInputSchema: z.ZodObject<{
|
|
|
15
15
|
}, {
|
|
16
16
|
name: string;
|
|
17
17
|
}>]>;
|
|
18
|
-
items: z.ZodArray<z.ZodObject<{
|
|
18
|
+
items: z.ZodArray<z.ZodUnion<[z.ZodObject<{
|
|
19
19
|
recipe_uid: z.ZodBranded<z.ZodString, "RecipeUid">;
|
|
20
20
|
day: z.ZodNumber;
|
|
21
21
|
type: z.ZodUnion<[z.ZodObject<{
|
|
@@ -57,9 +57,51 @@ export declare const addMenuItemsInputSchema: z.ZodObject<{
|
|
|
57
57
|
};
|
|
58
58
|
recipe_uid: string;
|
|
59
59
|
day: number;
|
|
60
|
-
}>,
|
|
60
|
+
}>, z.ZodObject<{
|
|
61
|
+
name: z.ZodString;
|
|
62
|
+
day: z.ZodNumber;
|
|
63
|
+
type: z.ZodUnion<[z.ZodObject<{
|
|
64
|
+
name: z.ZodEffects<z.ZodString, string, string>;
|
|
65
|
+
}, "strict", z.ZodTypeAny, {
|
|
66
|
+
name: string;
|
|
67
|
+
}, {
|
|
68
|
+
name: string;
|
|
69
|
+
}>, z.ZodObject<{
|
|
70
|
+
uid: z.ZodBranded<z.ZodString, "MealTypeUid">;
|
|
71
|
+
}, "strict", z.ZodTypeAny, {
|
|
72
|
+
uid: string & z.BRAND<"MealTypeUid">;
|
|
73
|
+
}, {
|
|
74
|
+
uid: string;
|
|
75
|
+
}>, z.ZodObject<{
|
|
76
|
+
builtin: z.ZodNumber;
|
|
77
|
+
}, "strict", z.ZodTypeAny, {
|
|
78
|
+
builtin: number;
|
|
79
|
+
}, {
|
|
80
|
+
builtin: number;
|
|
81
|
+
}>]>;
|
|
82
|
+
}, "strict", z.ZodTypeAny, {
|
|
83
|
+
type: {
|
|
84
|
+
name: string;
|
|
85
|
+
} | {
|
|
86
|
+
uid: string & z.BRAND<"MealTypeUid">;
|
|
87
|
+
} | {
|
|
88
|
+
builtin: number;
|
|
89
|
+
};
|
|
90
|
+
name: string;
|
|
91
|
+
day: number;
|
|
92
|
+
}, {
|
|
93
|
+
type: {
|
|
94
|
+
name: string;
|
|
95
|
+
} | {
|
|
96
|
+
uid: string;
|
|
97
|
+
} | {
|
|
98
|
+
builtin: number;
|
|
99
|
+
};
|
|
100
|
+
name: string;
|
|
101
|
+
day: number;
|
|
102
|
+
}>]>, "many">;
|
|
61
103
|
}, "strip", z.ZodTypeAny, {
|
|
62
|
-
items: {
|
|
104
|
+
items: ({
|
|
63
105
|
type: {
|
|
64
106
|
name: string;
|
|
65
107
|
} | {
|
|
@@ -69,14 +111,24 @@ export declare const addMenuItemsInputSchema: z.ZodObject<{
|
|
|
69
111
|
};
|
|
70
112
|
recipe_uid: string & z.BRAND<"RecipeUid">;
|
|
71
113
|
day: number;
|
|
72
|
-
}
|
|
114
|
+
} | {
|
|
115
|
+
type: {
|
|
116
|
+
name: string;
|
|
117
|
+
} | {
|
|
118
|
+
uid: string & z.BRAND<"MealTypeUid">;
|
|
119
|
+
} | {
|
|
120
|
+
builtin: number;
|
|
121
|
+
};
|
|
122
|
+
name: string;
|
|
123
|
+
day: number;
|
|
124
|
+
})[];
|
|
73
125
|
menu: {
|
|
74
126
|
uid: string & z.BRAND<"MenuUid">;
|
|
75
127
|
} | {
|
|
76
128
|
name: string;
|
|
77
129
|
};
|
|
78
130
|
}, {
|
|
79
|
-
items: {
|
|
131
|
+
items: ({
|
|
80
132
|
type: {
|
|
81
133
|
name: string;
|
|
82
134
|
} | {
|
|
@@ -86,7 +138,17 @@ export declare const addMenuItemsInputSchema: z.ZodObject<{
|
|
|
86
138
|
};
|
|
87
139
|
recipe_uid: string;
|
|
88
140
|
day: number;
|
|
89
|
-
}
|
|
141
|
+
} | {
|
|
142
|
+
type: {
|
|
143
|
+
name: string;
|
|
144
|
+
} | {
|
|
145
|
+
uid: string;
|
|
146
|
+
} | {
|
|
147
|
+
builtin: number;
|
|
148
|
+
};
|
|
149
|
+
name: string;
|
|
150
|
+
day: number;
|
|
151
|
+
})[];
|
|
90
152
|
menu: {
|
|
91
153
|
uid: string;
|
|
92
154
|
} | {
|
|
@@ -5,20 +5,33 @@ import { MenuItemUidSchema, MenuUidSchema, RecipeUidSchema } from "../paprika/ty
|
|
|
5
5
|
import { resolveLookup, textResult, uidOrTextLookupSchema } from "./helpers.js";
|
|
6
6
|
import { mealTypeSpecSchema, resolveMealTypeSpec } from "./meal-helpers.js";
|
|
7
7
|
import { commitMenu, commitMenuItem, commitMenuItemsBatch, menuStartGuard, menuToMarkdown } from "./menu-helpers.js";
|
|
8
|
-
// One menuitem to add
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
8
|
+
// One menuitem to add. Structurally EITHER recipe-linked (recipe_uid; display
|
|
9
|
+
// name auto-resolves from the recipe) OR freeform (name; no recipe), mirroring
|
|
10
|
+
// add_meals — Paprika.app dispatches a menuitem's display off recipe_uid, so a
|
|
11
|
+
// stored custom name on a recipe-linked item would never render. The z.union of
|
|
12
|
+
// two `.strict()` objects rejects extra keys (including supplying BOTH recipe_uid
|
|
13
|
+
// and name) at the Zod boundary, surfacing the constraint structurally.
|
|
14
|
+
const menuItemDay = z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.positive()
|
|
18
|
+
.describe("1-indexed day within the menu. Days beyond the menu's current span auto-extend the menu.");
|
|
19
|
+
const menuItemType = mealTypeSpecSchema.describe('Meal type. Pick exactly one shape: {"name": "Dinner"} | {"uid": "<MealType UID>"} | {"builtin": 2}.');
|
|
20
|
+
const recipeMenuItemSchema = z
|
|
12
21
|
.object({
|
|
13
22
|
recipe_uid: RecipeUidSchema.describe("Recipe UID to place on the menu. Display name auto-resolves from the recipe."),
|
|
14
|
-
day:
|
|
15
|
-
|
|
16
|
-
.int()
|
|
17
|
-
.positive()
|
|
18
|
-
.describe("1-indexed day within the menu. Days beyond the menu's current span auto-extend the menu."),
|
|
19
|
-
type: mealTypeSpecSchema.describe('Meal type. Pick exactly one shape: {"name": "Dinner"} | {"uid": "<MealType UID>"} | {"builtin": 2}.'),
|
|
23
|
+
day: menuItemDay,
|
|
24
|
+
type: menuItemType,
|
|
20
25
|
})
|
|
21
26
|
.strict();
|
|
27
|
+
const freeformMenuItemSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
name: z.string().min(1).describe("Display name for a freeform (non-recipe) menuitem."),
|
|
30
|
+
day: menuItemDay,
|
|
31
|
+
type: menuItemType,
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
const addMenuItemSchema = z.union([recipeMenuItemSchema, freeformMenuItemSchema]);
|
|
22
35
|
export const addMenuItemsInputSchema = z.object({
|
|
23
36
|
menu: uidOrTextLookupSchema({
|
|
24
37
|
uidSchema: MenuUidSchema,
|
|
@@ -29,18 +42,19 @@ export const addMenuItemsInputSchema = z.object({
|
|
|
29
42
|
items: z
|
|
30
43
|
.array(addMenuItemSchema)
|
|
31
44
|
.min(1, "At least one menu item is required.")
|
|
32
|
-
.describe("Array of
|
|
45
|
+
.describe("Array of menuitems to add (1 or more); each is recipe-linked OR freeform."),
|
|
33
46
|
});
|
|
34
47
|
export function registerAddMenuItemsTool(server, ctx) {
|
|
35
48
|
const log = ctx.log.child({ component: "add_menu_items" });
|
|
36
49
|
server.registerTool("add_menu_items", {
|
|
37
|
-
description: "Add one or more
|
|
38
|
-
"
|
|
39
|
-
"recipe)
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
50
|
+
description: "Add one or more menuitems to a menu (saved meal plan). Look the menu up by UID or name (tiered " +
|
|
51
|
+
"fuzzy match). Each item is EITHER recipe-linked (supply recipe_uid; display name auto-resolves " +
|
|
52
|
+
"from the recipe) OR freeform (supply name; no recipe) — the two are mutually exclusive, matching " +
|
|
53
|
+
"add_meals. Each item also carries a 1-indexed day and a meal type (name, UID, or built-in index " +
|
|
54
|
+
"0=Breakfast, 1=Lunch, 2=Dinner, 3=Snacks). If any day falls beyond the menu's current span the " +
|
|
55
|
+
"menu is automatically extended to fit before the items are added. All items validate up-front; " +
|
|
56
|
+
"if ANY item is invalid the entire batch is rejected with a per-index error enumeration so callers " +
|
|
57
|
+
"can fix every problem in one pass.",
|
|
44
58
|
inputSchema: addMenuItemsInputSchema.shape,
|
|
45
59
|
}, async (args) => {
|
|
46
60
|
log.info({ tool: "add_menu_items", ...args.menu, count: args.items.length }, "tool invoked");
|
|
@@ -67,13 +81,25 @@ export function registerAddMenuItemsTool(server, ctx) {
|
|
|
67
81
|
const resolved = [];
|
|
68
82
|
for (let i = 0; i < args.items.length; i++) {
|
|
69
83
|
const item = args.items[i];
|
|
70
|
-
// Recipe
|
|
71
|
-
// display name
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
// Recipe-linked XOR freeform — the structural union guarantees exactly
|
|
85
|
+
// one shape. Recipe items denormalize the display name from the local
|
|
86
|
+
// store (matching add_meals' recipe-link contract); freeform items keep
|
|
87
|
+
// the supplied name and store recipeUid: null.
|
|
88
|
+
let recipeUid;
|
|
89
|
+
let resolvedName;
|
|
90
|
+
if ("recipe_uid" in item) {
|
|
91
|
+
const recipe = ctx.store.get(item.recipe_uid);
|
|
92
|
+
if (recipe === undefined) {
|
|
93
|
+
errors.push(`Item ${i.toString()}: recipe_uid "${item.recipe_uid}" is not known to the local recipe store; ` +
|
|
94
|
+
`wait for the next sync and retry, or supply a freeform item (omit recipe_uid, supply name).`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
recipeUid = item.recipe_uid;
|
|
98
|
+
resolvedName = recipe.name;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
recipeUid = null;
|
|
102
|
+
resolvedName = item.name;
|
|
77
103
|
}
|
|
78
104
|
// Meal type resolution via the shared helper (same DU as add_meals).
|
|
79
105
|
const typeResult = resolveMealTypeSpec(ctx, item.type);
|
|
@@ -95,8 +121,8 @@ export function registerAddMenuItemsTool(server, ctx) {
|
|
|
95
121
|
resolved.push({
|
|
96
122
|
day: item.day,
|
|
97
123
|
typeUid: typeResult.resolved.uid,
|
|
98
|
-
resolvedName
|
|
99
|
-
recipeUid
|
|
124
|
+
resolvedName,
|
|
125
|
+
recipeUid,
|
|
100
126
|
});
|
|
101
127
|
}
|
|
102
128
|
if (errors.length > 0) {
|
package/dist/utils/duration.js
CHANGED
|
@@ -34,7 +34,13 @@ function colonParser(input) {
|
|
|
34
34
|
return ok(Duration.fromObject({ hours, minutes }));
|
|
35
35
|
}
|
|
36
36
|
function humanAndIsoParser(input) {
|
|
37
|
-
|
|
37
|
+
// parse-duration reads a number with a detached "+" (e.g. "5+ hours") as a bare
|
|
38
|
+
// millisecond count — "5" → 5ms — silently producing a wildly-wrong tiny value
|
|
39
|
+
// instead of "5 hours". Recipe time fields are full of these ("3+ hours",
|
|
40
|
+
// "10 min (plus 4+ hours rest)"). Strip a "+" that immediately follows a number
|
|
41
|
+
// so the duration reads correctly (#162).
|
|
42
|
+
const normalized = input.replace(/(\d)\s*\+/g, "$1");
|
|
43
|
+
const ms = parseDurationLib(normalized);
|
|
38
44
|
if (ms === null || ms === undefined) {
|
|
39
45
|
return null;
|
|
40
46
|
}
|