@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.
@@ -176,16 +176,58 @@ export class PaprikaClient {
176
176
  });
177
177
  }
178
178
  async authenticate() {
179
- const response = await fetch(AUTH_URL, {
180
- method: "POST",
181
- body: new URLSearchParams({ email: this.email, password: this.password }),
182
- });
183
- if (!response.ok) {
184
- throw new PaprikaAuthError(`Authentication failed (HTTP ${response.status.toString()})`);
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));
@@ -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. Results sorted by total time ascending.",
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(formatRecipeList(results, ctx));
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
- }>, "many">;
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: a recipe-linked entry placed on a specific day with a
9
- // meal type. Display name auto-resolves from the recipe (like add_meals), so no
10
- // `name` field is accepted. `.strict()` rejects extra keys at the Zod boundary.
11
- const addMenuItemSchema = z
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: 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
- 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 recipe-linked menuitems to add (1 or more)."),
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 recipe-linked menuitems to a menu (saved meal plan). Look the menu up by UID or " +
38
- "name (tiered fuzzy match). Each item carries a recipe_uid (display name auto-resolves from the " +
39
- "recipe), a 1-indexed day, and a meal type (name, UID, or built-in index 0=Breakfast, 1=Lunch, " +
40
- "2=Dinner, 3=Snacks). If any day falls beyond the menu's current span the menu is automatically " +
41
- "extended to fit before the items are added. All items validate up-front; if ANY item is invalid " +
42
- "the entire batch is rejected with a per-index error enumeration so callers can fix every problem " +
43
- "in one pass.",
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 must be known to the local store so we can denormalize the
71
- // display name (matching add_meals' recipe-link contract).
72
- const recipe = ctx.store.get(item.recipe_uid);
73
- if (recipe === undefined) {
74
- errors.push(`Item ${i.toString()}: recipe_uid "${item.recipe_uid}" is not known to the local recipe store; ` +
75
- `wait for the next sync and retry.`);
76
- continue;
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: recipe.name,
99
- recipeUid: item.recipe_uid,
124
+ resolvedName,
125
+ recipeUid,
100
126
  });
101
127
  }
102
128
  if (errors.length > 0) {
@@ -34,7 +34,13 @@ function colonParser(input) {
34
34
  return ok(Duration.fromObject({ hours, minutes }));
35
35
  }
36
36
  function humanAndIsoParser(input) {
37
- const ms = parseDurationLib(input);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bojanrajkovic/mcp-paprika",
3
- "version": "1.5.0-beta.2",
3
+ "version": "1.5.0",
4
4
  "description": "MCP server for Paprika recipe manager",
5
5
  "license": "MIT",
6
6
  "repository": {