@bojanrajkovic/mcp-paprika 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/LICENSE +21 -0
- package/README.md +42 -0
- package/dist/cache/disk-cache.d.ts +21 -0
- package/dist/cache/disk-cache.js +252 -0
- package/dist/cache/recipe-store.d.ts +33 -0
- package/dist/cache/recipe-store.js +189 -0
- package/dist/features/discover-feature.d.ts +5 -0
- package/dist/features/discover-feature.js +39 -0
- package/dist/features/embedding-errors.d.ts +26 -0
- package/dist/features/embedding-errors.js +34 -0
- package/dist/features/embeddings.d.ts +70 -0
- package/dist/features/embeddings.js +186 -0
- package/dist/features/vector-store-errors.d.ts +12 -0
- package/dist/features/vector-store-errors.js +15 -0
- package/dist/features/vector-store.d.ts +63 -0
- package/dist/features/vector-store.js +202 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +100 -0
- package/dist/paprika/client.d.ts +27 -0
- package/dist/paprika/client.js +183 -0
- package/dist/paprika/errors.d.ts +37 -0
- package/dist/paprika/errors.js +48 -0
- package/dist/paprika/sync.d.ts +27 -0
- package/dist/paprika/sync.js +150 -0
- package/dist/paprika/types.d.ts +324 -0
- package/dist/paprika/types.js +116 -0
- package/dist/resources/recipes.d.ts +3 -0
- package/dist/resources/recipes.js +34 -0
- package/dist/tools/categories.d.ts +3 -0
- package/dist/tools/categories.js +38 -0
- package/dist/tools/create.d.ts +3 -0
- package/dist/tools/create.js +79 -0
- package/dist/tools/delete.d.ts +3 -0
- package/dist/tools/delete.js +33 -0
- package/dist/tools/discover.d.ts +4 -0
- package/dist/tools/discover.js +60 -0
- package/dist/tools/filter.d.ts +3 -0
- package/dist/tools/filter.js +101 -0
- package/dist/tools/helpers.d.ts +31 -0
- package/dist/tools/helpers.js +112 -0
- package/dist/tools/list.d.ts +3 -0
- package/dist/tools/list.js +34 -0
- package/dist/tools/read.d.ts +3 -0
- package/dist/tools/read.js +42 -0
- package/dist/tools/search.d.ts +3 -0
- package/dist/tools/search.js +46 -0
- package/dist/tools/update.d.ts +3 -0
- package/dist/tools/update.js +77 -0
- package/dist/types/server-context.d.ts +10 -0
- package/dist/types/server-context.js +1 -0
- package/dist/utils/config.d.ts +115 -0
- package/dist/utils/config.js +197 -0
- package/dist/utils/duration.d.ts +10 -0
- package/dist/utils/duration.js +86 -0
- package/dist/utils/xdg.d.ts +5 -0
- package/dist/utils/xdg.js +17 -0
- package/package.json +64 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { coldStartGuard, textResult } from "./helpers.js";
|
|
3
|
+
export function registerDiscoverTool(server, ctx, vectorStore) {
|
|
4
|
+
server.registerTool("discover_recipes", {
|
|
5
|
+
description: "Discover recipes using semantic search. Finds recipes matching a natural language description of what you're looking for.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
query: z.string().describe("Natural language description of what you're looking for"),
|
|
8
|
+
topK: z
|
|
9
|
+
.number()
|
|
10
|
+
.int()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(20)
|
|
13
|
+
.optional()
|
|
14
|
+
.default(5)
|
|
15
|
+
.describe("Maximum number of results to return (default: 5, max: 20)"),
|
|
16
|
+
},
|
|
17
|
+
}, async (args) => {
|
|
18
|
+
return coldStartGuard(ctx).match(async () => {
|
|
19
|
+
const results = await vectorStore.search(args.query, args.topK);
|
|
20
|
+
if (results.length === 0) {
|
|
21
|
+
return textResult("No recipes found matching that description.");
|
|
22
|
+
}
|
|
23
|
+
// Enrich results and filter out deleted recipes
|
|
24
|
+
const enriched = [];
|
|
25
|
+
for (const result of results) {
|
|
26
|
+
const recipe = ctx.store.get(result.uid);
|
|
27
|
+
if (recipe) {
|
|
28
|
+
enriched.push({ result, recipe });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (enriched.length === 0) {
|
|
32
|
+
return textResult("No recipes found matching that description.");
|
|
33
|
+
}
|
|
34
|
+
// Format results with re-numbered indices
|
|
35
|
+
const lines = enriched.map((entry, index) => {
|
|
36
|
+
const categoryNames = ctx.store.resolveCategories(entry.recipe.categories);
|
|
37
|
+
return formatDiscoverHit(index + 1, entry.recipe, entry.result.score, categoryNames);
|
|
38
|
+
});
|
|
39
|
+
return textResult(lines.join("\n\n"));
|
|
40
|
+
}, (guard) => guard);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function formatDiscoverHit(index, recipe, score, categoryNames) {
|
|
44
|
+
const percentage = Math.round(score * 100);
|
|
45
|
+
const lines = [];
|
|
46
|
+
lines.push(`${String(index)}. **${recipe.name}** — ${String(percentage)}% match`);
|
|
47
|
+
if (categoryNames.length > 0) {
|
|
48
|
+
lines.push(` **Categories:** ${categoryNames.join(", ")}`);
|
|
49
|
+
}
|
|
50
|
+
const timeParts = [];
|
|
51
|
+
if (recipe.prepTime)
|
|
52
|
+
timeParts.push(`Prep: ${recipe.prepTime}`);
|
|
53
|
+
if (recipe.cookTime)
|
|
54
|
+
timeParts.push(`Cook: ${recipe.cookTime}`);
|
|
55
|
+
if (timeParts.length > 0) {
|
|
56
|
+
lines.push(` ${timeParts.join(" · ")}`);
|
|
57
|
+
}
|
|
58
|
+
lines.push(` UID: \`${recipe.uid}\``);
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ok } from "neverthrow";
|
|
3
|
+
import { parseDuration } from "../utils/duration.js";
|
|
4
|
+
import { coldStartGuard, textResult } from "./helpers.js";
|
|
5
|
+
export function registerFilterTools(server, ctx) {
|
|
6
|
+
server.registerTool("filter_by_ingredient", {
|
|
7
|
+
description: 'Filter recipes by ingredient. Use mode="all" (default) to require all ingredients, or mode="any" to match any.',
|
|
8
|
+
inputSchema: {
|
|
9
|
+
ingredients: z.array(z.string()).min(1).describe("One or more ingredient terms to filter by"),
|
|
10
|
+
mode: z
|
|
11
|
+
.enum(["all", "any"])
|
|
12
|
+
.default("all")
|
|
13
|
+
.describe('Match mode: "all" (default) requires every ingredient; "any" matches at least one'),
|
|
14
|
+
limit: z
|
|
15
|
+
.number()
|
|
16
|
+
.int()
|
|
17
|
+
.positive()
|
|
18
|
+
.max(50)
|
|
19
|
+
.optional()
|
|
20
|
+
.default(20)
|
|
21
|
+
.describe("Maximum number of results to return (default: 20, max: 50)"),
|
|
22
|
+
},
|
|
23
|
+
}, async (args) => {
|
|
24
|
+
return coldStartGuard(ctx).match(async () => {
|
|
25
|
+
const results = ctx.store.filterByIngredients(args.ingredients, args.mode, args.limit);
|
|
26
|
+
if (results.length === 0) {
|
|
27
|
+
const qualifier = args.mode === "all" ? "all of" : "any of";
|
|
28
|
+
return textResult(`No recipes found containing ${qualifier}: ${args.ingredients.join(", ")}.`);
|
|
29
|
+
}
|
|
30
|
+
return textResult(formatRecipeList(results, ctx));
|
|
31
|
+
}, (guard) => guard);
|
|
32
|
+
});
|
|
33
|
+
server.registerTool("filter_by_time", {
|
|
34
|
+
description: "Filter recipes by prep, cook, or total time. All constraints are optional. Results sorted by total time ascending.",
|
|
35
|
+
inputSchema: {
|
|
36
|
+
maxPrepTime: z.string().optional().describe('Maximum prep time (e.g., "30 minutes", "1 hr")'),
|
|
37
|
+
maxCookTime: z.string().optional().describe('Maximum cook time (e.g., "45 min", "1 hour")'),
|
|
38
|
+
maxTotalTime: z.string().optional().describe('Maximum total time (e.g., "1 hour 30 minutes", "2 hrs")'),
|
|
39
|
+
limit: z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.positive()
|
|
43
|
+
.max(50)
|
|
44
|
+
.optional()
|
|
45
|
+
.default(20)
|
|
46
|
+
.describe("Maximum number of results to return (default: 20, max: 50)"),
|
|
47
|
+
},
|
|
48
|
+
}, async (args) => {
|
|
49
|
+
return coldStartGuard(ctx).match(async () => {
|
|
50
|
+
const constraintsResult = parseMaybeMinutes(args.maxPrepTime).andThen((maxPrepTime) => parseMaybeMinutes(args.maxCookTime).andThen((maxCookTime) => parseMaybeMinutes(args.maxTotalTime).map((maxTotalTime) => {
|
|
51
|
+
// Build object using spread operator to satisfy exactOptionalPropertyTypes
|
|
52
|
+
const base = {};
|
|
53
|
+
return Object.assign(base, {
|
|
54
|
+
...(maxPrepTime !== undefined && { maxPrepTime }),
|
|
55
|
+
...(maxCookTime !== undefined && { maxCookTime }),
|
|
56
|
+
...(maxTotalTime !== undefined && { maxTotalTime }),
|
|
57
|
+
});
|
|
58
|
+
})));
|
|
59
|
+
return constraintsResult.match((constraints) => {
|
|
60
|
+
const allResults = ctx.store.filterByTime(constraints);
|
|
61
|
+
const results = allResults.slice(0, args.limit);
|
|
62
|
+
if (results.length === 0) {
|
|
63
|
+
return textResult("No recipes found matching the specified time constraints.");
|
|
64
|
+
}
|
|
65
|
+
return textResult(formatRecipeList(results, ctx));
|
|
66
|
+
}, (errorMsg) => textResult(errorMsg));
|
|
67
|
+
}, (guard) => guard);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// Parses a human-readable time string to minutes, or passes through undefined.
|
|
71
|
+
// Returns Err with a user-friendly message if parsing fails.
|
|
72
|
+
function parseMaybeMinutes(input) {
|
|
73
|
+
if (input === undefined)
|
|
74
|
+
return ok(undefined);
|
|
75
|
+
return parseDuration(input)
|
|
76
|
+
.map((d) => d.as("minutes"))
|
|
77
|
+
.mapErr((e) => `Invalid time format "${e.input}": ${e.reason}`);
|
|
78
|
+
}
|
|
79
|
+
function formatRecipeList(recipes, ctx) {
|
|
80
|
+
const lines = recipes.map((recipe) => {
|
|
81
|
+
const categoryNames = ctx.store.resolveCategories(recipe.categories);
|
|
82
|
+
return formatRecipeItem(recipe, categoryNames);
|
|
83
|
+
});
|
|
84
|
+
return lines.join("\n\n---\n\n");
|
|
85
|
+
}
|
|
86
|
+
function formatRecipeItem(recipe, categoryNames) {
|
|
87
|
+
const lines = [];
|
|
88
|
+
lines.push(`## ${recipe.name}`);
|
|
89
|
+
if (categoryNames.length > 0) {
|
|
90
|
+
lines.push(`**Categories:** ${categoryNames.join(", ")}`);
|
|
91
|
+
}
|
|
92
|
+
const timeParts = [];
|
|
93
|
+
if (recipe.prepTime)
|
|
94
|
+
timeParts.push(`Prep: ${recipe.prepTime}`);
|
|
95
|
+
if (recipe.totalTime)
|
|
96
|
+
timeParts.push(`Total: ${recipe.totalTime}`);
|
|
97
|
+
if (timeParts.length > 0) {
|
|
98
|
+
lines.push(timeParts.join(" · "));
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Result } from "neverthrow";
|
|
2
|
+
import type { Category, CategoryUid, Recipe } from "../paprika/types.js";
|
|
3
|
+
import type { ServerContext } from "../types/server-context.js";
|
|
4
|
+
export declare function textResult(text: string): {
|
|
5
|
+
content: [{
|
|
6
|
+
type: "text";
|
|
7
|
+
text: string;
|
|
8
|
+
}];
|
|
9
|
+
};
|
|
10
|
+
export declare function coldStartGuard(ctx: ServerContext): Result<void, ReturnType<typeof textResult>>;
|
|
11
|
+
export declare function recipeToMarkdown(recipe: Recipe, categoryNames: Array<string>): string;
|
|
12
|
+
/**
|
|
13
|
+
* Persists a saved recipe to the local cache and store, then triggers cloud sync.
|
|
14
|
+
* Called by all write tools after ctx.client.saveRecipe() returns.
|
|
15
|
+
*
|
|
16
|
+
* Order: putRecipe (sync) → flush (async) → store.set (sync) → sendResourceListChanged (sync) → notifySync (async)
|
|
17
|
+
* Do NOT call ctx.client.notifySync() separately in the tool handler — commitRecipe
|
|
18
|
+
* already calls it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function commitRecipe(ctx: ServerContext, saved: Recipe): Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Resolves human-readable category display names to CategoryUid values.
|
|
23
|
+
* Case-insensitive linear scan of all known categories.
|
|
24
|
+
*
|
|
25
|
+
* @returns uids — matched UIDs in the same order as input names
|
|
26
|
+
* unknown — names that had no matching category (caller should warn)
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveCategoryNames(all: Array<Category>, names: Array<string>): {
|
|
29
|
+
uids: Array<CategoryUid>;
|
|
30
|
+
unknown: Array<string>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { err, ok } from "neverthrow";
|
|
2
|
+
export function textResult(text) {
|
|
3
|
+
return { content: [{ type: "text", text }] };
|
|
4
|
+
}
|
|
5
|
+
export function coldStartGuard(ctx) {
|
|
6
|
+
if (ctx.store.size === 0) {
|
|
7
|
+
return err(textResult("Recipe store is not yet synced. Try again in a few seconds."));
|
|
8
|
+
}
|
|
9
|
+
return ok(undefined);
|
|
10
|
+
}
|
|
11
|
+
export function recipeToMarkdown(recipe, categoryNames) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
lines.push(`# ${recipe.name}`);
|
|
14
|
+
if (categoryNames.length > 0) {
|
|
15
|
+
lines.push("");
|
|
16
|
+
lines.push(`**Categories:** ${categoryNames.join(", ")}`);
|
|
17
|
+
}
|
|
18
|
+
if (recipe.description) {
|
|
19
|
+
lines.push("");
|
|
20
|
+
lines.push(recipe.description);
|
|
21
|
+
}
|
|
22
|
+
const timeParts = [];
|
|
23
|
+
if (recipe.prepTime)
|
|
24
|
+
timeParts.push(`Prep: ${recipe.prepTime}`);
|
|
25
|
+
if (recipe.cookTime)
|
|
26
|
+
timeParts.push(`Cook: ${recipe.cookTime}`);
|
|
27
|
+
if (recipe.totalTime)
|
|
28
|
+
timeParts.push(`Total: ${recipe.totalTime}`);
|
|
29
|
+
if (timeParts.length > 0) {
|
|
30
|
+
lines.push("");
|
|
31
|
+
lines.push(timeParts.join(" · "));
|
|
32
|
+
}
|
|
33
|
+
if (recipe.servings) {
|
|
34
|
+
lines.push("");
|
|
35
|
+
lines.push(`**Servings:** ${recipe.servings}`);
|
|
36
|
+
}
|
|
37
|
+
if (recipe.difficulty) {
|
|
38
|
+
lines.push("");
|
|
39
|
+
lines.push(`**Difficulty:** ${recipe.difficulty}`);
|
|
40
|
+
}
|
|
41
|
+
lines.push("");
|
|
42
|
+
lines.push("## Ingredients");
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(recipe.ingredients);
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push("## Directions");
|
|
47
|
+
lines.push("");
|
|
48
|
+
lines.push(recipe.directions);
|
|
49
|
+
if (recipe.notes) {
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push("## Notes");
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push(recipe.notes);
|
|
54
|
+
}
|
|
55
|
+
if (recipe.nutritionalInfo) {
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push("## Nutritional Info");
|
|
58
|
+
lines.push("");
|
|
59
|
+
lines.push(recipe.nutritionalInfo);
|
|
60
|
+
}
|
|
61
|
+
if (recipe.source) {
|
|
62
|
+
lines.push("");
|
|
63
|
+
if (recipe.sourceUrl) {
|
|
64
|
+
lines.push(`**Source:** [${recipe.source}](${recipe.sourceUrl})`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
lines.push(`**Source:** ${recipe.source}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (recipe.sourceUrl) {
|
|
71
|
+
lines.push("");
|
|
72
|
+
lines.push(`**Source:** ${recipe.sourceUrl}`);
|
|
73
|
+
}
|
|
74
|
+
return lines.join("\n");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Persists a saved recipe to the local cache and store, then triggers cloud sync.
|
|
78
|
+
* Called by all write tools after ctx.client.saveRecipe() returns.
|
|
79
|
+
*
|
|
80
|
+
* Order: putRecipe (sync) → flush (async) → store.set (sync) → sendResourceListChanged (sync) → notifySync (async)
|
|
81
|
+
* Do NOT call ctx.client.notifySync() separately in the tool handler — commitRecipe
|
|
82
|
+
* already calls it.
|
|
83
|
+
*/
|
|
84
|
+
export async function commitRecipe(ctx, saved) {
|
|
85
|
+
ctx.cache.putRecipe(saved, saved.hash); // sync — buffers to memory
|
|
86
|
+
await ctx.cache.flush(); // async — writes pending entries to disk
|
|
87
|
+
ctx.store.set(saved); // sync — updates in-process store
|
|
88
|
+
ctx.server.sendResourceListChanged(); // sync — notifies MCP clients to re-list resources
|
|
89
|
+
await ctx.client.notifySync(); // async — signals Paprika cloud to propagate
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Resolves human-readable category display names to CategoryUid values.
|
|
93
|
+
* Case-insensitive linear scan of all known categories.
|
|
94
|
+
*
|
|
95
|
+
* @returns uids — matched UIDs in the same order as input names
|
|
96
|
+
* unknown — names that had no matching category (caller should warn)
|
|
97
|
+
*/
|
|
98
|
+
export function resolveCategoryNames(all, names) {
|
|
99
|
+
const uids = [];
|
|
100
|
+
const unknown = [];
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const lower = name.toLowerCase();
|
|
103
|
+
const match = all.find((c) => c.name.toLowerCase() === lower);
|
|
104
|
+
if (match) {
|
|
105
|
+
uids.push(match.uid);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
unknown.push(name);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { uids, unknown };
|
|
112
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { coldStartGuard, textResult } from "./helpers.js";
|
|
3
|
+
export function registerListTool(server, ctx) {
|
|
4
|
+
server.registerTool("list_recipes", {
|
|
5
|
+
description: "List all recipes with pagination. Returns recipe summaries sorted alphabetically. Use offset/limit to paginate through the full library. Response includes total recipe count.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
offset: z.number().int().nonnegative().optional().default(0).describe("Number of recipes to skip (default: 0)"),
|
|
8
|
+
limit: z
|
|
9
|
+
.number()
|
|
10
|
+
.int()
|
|
11
|
+
.positive()
|
|
12
|
+
.max(50)
|
|
13
|
+
.optional()
|
|
14
|
+
.default(25)
|
|
15
|
+
.describe("Maximum number of recipes to return (default: 25, max: 50)"),
|
|
16
|
+
},
|
|
17
|
+
}, async (args) => {
|
|
18
|
+
return coldStartGuard(ctx).match(async () => {
|
|
19
|
+
const all = ctx.store.getAll().sort((a, b) => a.name.localeCompare(b.name));
|
|
20
|
+
const total = all.length;
|
|
21
|
+
const page = all.slice(args.offset, args.offset + args.limit);
|
|
22
|
+
if (page.length === 0) {
|
|
23
|
+
return textResult(`No recipes found (total: ${total.toString()}, offset: ${args.offset.toString()}).`);
|
|
24
|
+
}
|
|
25
|
+
const header = `Showing ${page.length.toString()} of ${total.toString()} recipes (offset: ${args.offset.toString()}):\n`;
|
|
26
|
+
const lines = page.map((recipe) => {
|
|
27
|
+
const categoryNames = ctx.store.resolveCategories(recipe.categories);
|
|
28
|
+
const cats = categoryNames.length > 0 ? ` [${categoryNames.join(", ")}]` : "";
|
|
29
|
+
return `- **${recipe.name}**${cats} (uid: ${recipe.uid})`;
|
|
30
|
+
});
|
|
31
|
+
return textResult(header + "\n" + lines.join("\n"));
|
|
32
|
+
}, (guard) => guard);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { RecipeUidSchema } from "../paprika/types.js";
|
|
3
|
+
import { coldStartGuard, recipeToMarkdown, textResult } from "./helpers.js";
|
|
4
|
+
export function registerReadTool(server, ctx) {
|
|
5
|
+
server.registerTool("read_recipe", {
|
|
6
|
+
description: "Read a recipe by UID or title. When both are provided, UID takes precedence. " +
|
|
7
|
+
"Title lookup is fuzzy (exact → starts-with → contains). Returns a disambiguation " +
|
|
8
|
+
"list when multiple recipes match the same tier.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
uid: z.string().optional().describe("Exact recipe UID"),
|
|
11
|
+
title: z.string().optional().describe("Recipe title (fuzzy match)"),
|
|
12
|
+
},
|
|
13
|
+
}, async (args) => {
|
|
14
|
+
return coldStartGuard(ctx).match(async () => {
|
|
15
|
+
if (!args.uid && !args.title) {
|
|
16
|
+
return textResult("Please provide either a uid or a title.");
|
|
17
|
+
}
|
|
18
|
+
// UID lookup takes precedence when both are provided (AC1.9)
|
|
19
|
+
if (args.uid) {
|
|
20
|
+
const recipe = ctx.store.get(RecipeUidSchema.parse(args.uid));
|
|
21
|
+
if (!recipe) {
|
|
22
|
+
return textResult(`No recipe found with UID "${args.uid}".`);
|
|
23
|
+
}
|
|
24
|
+
const categoryNames = ctx.store.resolveCategories(recipe.categories);
|
|
25
|
+
return textResult(recipeToMarkdown(recipe, categoryNames));
|
|
26
|
+
}
|
|
27
|
+
// Title fuzzy search — args.title is defined here
|
|
28
|
+
const matches = ctx.store.findByName(args.title);
|
|
29
|
+
if (matches.length === 0) {
|
|
30
|
+
return textResult(`No recipes found matching "${args.title}".`);
|
|
31
|
+
}
|
|
32
|
+
if (matches.length === 1) {
|
|
33
|
+
const recipe = matches[0]; // safe: length === 1
|
|
34
|
+
const categoryNames = ctx.store.resolveCategories(recipe.categories);
|
|
35
|
+
return textResult(recipeToMarkdown(recipe, categoryNames));
|
|
36
|
+
}
|
|
37
|
+
// Disambiguation list (AC1.4)
|
|
38
|
+
const list = matches.map((r) => `- ${r.name} (UID: ${r.uid})`).join("\n");
|
|
39
|
+
return textResult(`Multiple recipes match "${args.title}":\n${list}\n\nPlease re-invoke with a specific uid.`);
|
|
40
|
+
}, (guard) => guard);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { coldStartGuard, textResult } from "./helpers.js";
|
|
3
|
+
export function registerSearchTool(server, ctx) {
|
|
4
|
+
server.registerTool("search_recipes", {
|
|
5
|
+
description: "Search for recipes by name, ingredients, or description. Returns a ranked list of matching recipes.",
|
|
6
|
+
inputSchema: {
|
|
7
|
+
query: z.string().describe("Search query text"),
|
|
8
|
+
limit: z
|
|
9
|
+
.number()
|
|
10
|
+
.int()
|
|
11
|
+
.positive()
|
|
12
|
+
.max(50)
|
|
13
|
+
.optional()
|
|
14
|
+
.default(20)
|
|
15
|
+
.describe("Maximum number of results to return (default: 20, max: 50)"),
|
|
16
|
+
},
|
|
17
|
+
}, async (args) => {
|
|
18
|
+
return coldStartGuard(ctx).match(async () => {
|
|
19
|
+
const results = ctx.store.search(args.query, { limit: args.limit });
|
|
20
|
+
if (results.length === 0) {
|
|
21
|
+
return textResult(`No recipes found matching "${args.query}".`);
|
|
22
|
+
}
|
|
23
|
+
const lines = results.map((r) => {
|
|
24
|
+
const categoryNames = ctx.store.resolveCategories(r.recipe.categories);
|
|
25
|
+
return formatSearchHit(r, categoryNames);
|
|
26
|
+
});
|
|
27
|
+
return textResult(lines.join("\n\n---\n\n"));
|
|
28
|
+
}, (guard) => guard);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function formatSearchHit(result, categoryNames) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(`## ${result.recipe.name}`);
|
|
34
|
+
if (categoryNames.length > 0) {
|
|
35
|
+
lines.push(`**Categories:** ${categoryNames.join(", ")}`);
|
|
36
|
+
}
|
|
37
|
+
const timeParts = [];
|
|
38
|
+
if (result.recipe.prepTime)
|
|
39
|
+
timeParts.push(`Prep: ${result.recipe.prepTime}`);
|
|
40
|
+
if (result.recipe.totalTime)
|
|
41
|
+
timeParts.push(`Total: ${result.recipe.totalTime}`);
|
|
42
|
+
if (timeParts.length > 0) {
|
|
43
|
+
lines.push(timeParts.join(" · "));
|
|
44
|
+
}
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { RecipeUidSchema } from "../paprika/types.js";
|
|
3
|
+
import { coldStartGuard, commitRecipe, recipeToMarkdown, resolveCategoryNames, textResult } from "./helpers.js";
|
|
4
|
+
export function registerUpdateTool(server, ctx) {
|
|
5
|
+
server.registerTool("update_recipe", {
|
|
6
|
+
description: "Update an existing recipe by UID. Only provided fields are changed; " +
|
|
7
|
+
"omitted fields retain their existing values. If categories is provided, " +
|
|
8
|
+
"it replaces the existing category list entirely; omitting categories " +
|
|
9
|
+
"leaves the existing list unchanged.",
|
|
10
|
+
inputSchema: {
|
|
11
|
+
uid: z.string().describe("Recipe UID to update"),
|
|
12
|
+
name: z.string().optional().describe("New recipe name"),
|
|
13
|
+
ingredients: z.string().optional().describe("New ingredients list"),
|
|
14
|
+
directions: z.string().optional().describe("New cooking directions"),
|
|
15
|
+
description: z.string().optional().describe("New description"),
|
|
16
|
+
notes: z.string().optional().describe("New notes"),
|
|
17
|
+
servings: z.string().optional().describe("New servings"),
|
|
18
|
+
prepTime: z.string().optional().describe("New prep time"),
|
|
19
|
+
cookTime: z.string().optional().describe("New cook time"),
|
|
20
|
+
totalTime: z.string().optional().describe("New total time"),
|
|
21
|
+
categories: z
|
|
22
|
+
.array(z.string())
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Category display names — replaces existing list when provided"),
|
|
25
|
+
source: z.string().optional().describe("New source name"),
|
|
26
|
+
sourceUrl: z.string().optional().describe("New source URL"),
|
|
27
|
+
difficulty: z.string().optional().describe("New difficulty level"),
|
|
28
|
+
rating: z.number().int().min(0).max(5).optional().describe("New rating 0–5"),
|
|
29
|
+
nutritionalInfo: z.string().optional().describe("New nutritional information"),
|
|
30
|
+
},
|
|
31
|
+
}, async (args) => {
|
|
32
|
+
return coldStartGuard(ctx).match(async () => {
|
|
33
|
+
const uid = RecipeUidSchema.parse(args.uid);
|
|
34
|
+
const existing = ctx.store.get(uid);
|
|
35
|
+
if (!existing) {
|
|
36
|
+
return textResult(`No recipe found with UID "${args.uid}".`);
|
|
37
|
+
}
|
|
38
|
+
// Resolve categories if provided — replaces list entirely (AC3.2)
|
|
39
|
+
// Check !== undefined so empty array [] correctly removes all categories (AC3.3)
|
|
40
|
+
const { uids: resolvedCategories, unknown: unknownCategories } = args.categories !== undefined
|
|
41
|
+
? resolveCategoryNames(ctx.store.getAllCategories(), args.categories)
|
|
42
|
+
: { uids: existing.categories, unknown: [] };
|
|
43
|
+
const warnings = unknownCategories.map((name) => `Warning: category "${name}" not found and was skipped.`);
|
|
44
|
+
// Partial merge: conditional spread omits keys when value is undefined (AC3.1)
|
|
45
|
+
const updated = {
|
|
46
|
+
...existing,
|
|
47
|
+
...(args.name !== undefined && { name: args.name }),
|
|
48
|
+
...(args.ingredients !== undefined && { ingredients: args.ingredients }),
|
|
49
|
+
...(args.directions !== undefined && { directions: args.directions }),
|
|
50
|
+
...(args.description !== undefined && { description: args.description }),
|
|
51
|
+
...(args.notes !== undefined && { notes: args.notes }),
|
|
52
|
+
...(args.servings !== undefined && { servings: args.servings }),
|
|
53
|
+
...(args.prepTime !== undefined && { prepTime: args.prepTime }),
|
|
54
|
+
...(args.cookTime !== undefined && { cookTime: args.cookTime }),
|
|
55
|
+
...(args.totalTime !== undefined && { totalTime: args.totalTime }),
|
|
56
|
+
...(args.source !== undefined && { source: args.source }),
|
|
57
|
+
...(args.sourceUrl !== undefined && { sourceUrl: args.sourceUrl }),
|
|
58
|
+
...(args.difficulty !== undefined && { difficulty: args.difficulty }),
|
|
59
|
+
...(args.rating !== undefined && { rating: args.rating }),
|
|
60
|
+
...(args.nutritionalInfo !== undefined && { nutritionalInfo: args.nutritionalInfo }),
|
|
61
|
+
categories: resolvedCategories, // always set — either resolved or existing
|
|
62
|
+
};
|
|
63
|
+
let saved;
|
|
64
|
+
try {
|
|
65
|
+
saved = await ctx.client.saveRecipe(updated); // AC3.4
|
|
66
|
+
await commitRecipe(ctx, saved); // AC3.4
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return textResult(`Failed to update recipe: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
const categoryNames = ctx.store.resolveCategories(saved.categories);
|
|
72
|
+
const markdown = recipeToMarkdown(saved, categoryNames);
|
|
73
|
+
const prefix = warnings.length > 0 ? warnings.join("\n") + "\n\n" : "";
|
|
74
|
+
return textResult(prefix + markdown);
|
|
75
|
+
}, (guard) => guard);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { DiskCache } from "../cache/disk-cache.js";
|
|
3
|
+
import type { RecipeStore } from "../cache/recipe-store.js";
|
|
4
|
+
import type { PaprikaClient } from "../paprika/client.js";
|
|
5
|
+
export interface ServerContext {
|
|
6
|
+
readonly client: PaprikaClient;
|
|
7
|
+
readonly cache: DiskCache;
|
|
8
|
+
readonly store: RecipeStore;
|
|
9
|
+
readonly server: McpServer;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Result } from "neverthrow";
|
|
3
|
+
export declare class ConfigError extends Error {
|
|
4
|
+
readonly reason: string;
|
|
5
|
+
readonly kind: "invalid_json" | "file_read_error" | "validation";
|
|
6
|
+
private constructor();
|
|
7
|
+
static invalidJson(path: string, cause: unknown): ConfigError;
|
|
8
|
+
static fileReadError(path: string, cause: unknown): ConfigError;
|
|
9
|
+
static validation(issues: ReadonlyArray<z.ZodIssue>): ConfigError;
|
|
10
|
+
}
|
|
11
|
+
declare const embeddingConfigSchema: z.ZodObject<{
|
|
12
|
+
apiKey: z.ZodString;
|
|
13
|
+
baseUrl: z.ZodString;
|
|
14
|
+
model: z.ZodString;
|
|
15
|
+
}, "strip", z.ZodTypeAny, {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
model: string;
|
|
19
|
+
}, {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
model: string;
|
|
23
|
+
}>;
|
|
24
|
+
export declare const paprikaConfigSchema: z.ZodObject<{
|
|
25
|
+
paprika: z.ZodEffects<z.ZodObject<{
|
|
26
|
+
email: z.ZodString;
|
|
27
|
+
password: z.ZodString;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
email: string;
|
|
30
|
+
password: string;
|
|
31
|
+
}, {
|
|
32
|
+
email: string;
|
|
33
|
+
password: string;
|
|
34
|
+
}>, {
|
|
35
|
+
email: string;
|
|
36
|
+
password: string;
|
|
37
|
+
}, unknown>;
|
|
38
|
+
sync: z.ZodDefault<z.ZodObject<{
|
|
39
|
+
enabled: z.ZodDefault<z.ZodEffects<z.ZodUnion<[z.ZodBoolean, z.ZodString]>, boolean, string | boolean>>;
|
|
40
|
+
interval: z.ZodDefault<z.ZodEffects<z.ZodUnion<[z.ZodString, z.ZodNumber]>, number, string | number>>;
|
|
41
|
+
}, "strip", z.ZodTypeAny, {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
interval: number;
|
|
44
|
+
}, {
|
|
45
|
+
enabled?: string | boolean | undefined;
|
|
46
|
+
interval?: string | number | undefined;
|
|
47
|
+
}>>;
|
|
48
|
+
features: z.ZodOptional<z.ZodObject<{
|
|
49
|
+
replicateApiToken: z.ZodOptional<z.ZodString>;
|
|
50
|
+
embeddings: z.ZodOptional<z.ZodObject<{
|
|
51
|
+
apiKey: z.ZodString;
|
|
52
|
+
baseUrl: z.ZodString;
|
|
53
|
+
model: z.ZodString;
|
|
54
|
+
}, "strip", z.ZodTypeAny, {
|
|
55
|
+
apiKey: string;
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
model: string;
|
|
58
|
+
}, {
|
|
59
|
+
apiKey: string;
|
|
60
|
+
baseUrl: string;
|
|
61
|
+
model: string;
|
|
62
|
+
}>>;
|
|
63
|
+
}, "strip", z.ZodTypeAny, {
|
|
64
|
+
replicateApiToken?: string | undefined;
|
|
65
|
+
embeddings?: {
|
|
66
|
+
apiKey: string;
|
|
67
|
+
baseUrl: string;
|
|
68
|
+
model: string;
|
|
69
|
+
} | undefined;
|
|
70
|
+
}, {
|
|
71
|
+
replicateApiToken?: string | undefined;
|
|
72
|
+
embeddings?: {
|
|
73
|
+
apiKey: string;
|
|
74
|
+
baseUrl: string;
|
|
75
|
+
model: string;
|
|
76
|
+
} | undefined;
|
|
77
|
+
}>>;
|
|
78
|
+
}, "strip", z.ZodTypeAny, {
|
|
79
|
+
paprika: {
|
|
80
|
+
email: string;
|
|
81
|
+
password: string;
|
|
82
|
+
};
|
|
83
|
+
sync: {
|
|
84
|
+
enabled: boolean;
|
|
85
|
+
interval: number;
|
|
86
|
+
};
|
|
87
|
+
features?: {
|
|
88
|
+
replicateApiToken?: string | undefined;
|
|
89
|
+
embeddings?: {
|
|
90
|
+
apiKey: string;
|
|
91
|
+
baseUrl: string;
|
|
92
|
+
model: string;
|
|
93
|
+
} | undefined;
|
|
94
|
+
} | undefined;
|
|
95
|
+
}, {
|
|
96
|
+
paprika?: unknown;
|
|
97
|
+
sync?: {
|
|
98
|
+
enabled?: string | boolean | undefined;
|
|
99
|
+
interval?: string | number | undefined;
|
|
100
|
+
} | undefined;
|
|
101
|
+
features?: {
|
|
102
|
+
replicateApiToken?: string | undefined;
|
|
103
|
+
embeddings?: {
|
|
104
|
+
apiKey: string;
|
|
105
|
+
baseUrl: string;
|
|
106
|
+
model: string;
|
|
107
|
+
} | undefined;
|
|
108
|
+
} | undefined;
|
|
109
|
+
}>;
|
|
110
|
+
export type PaprikaConfig = z.infer<typeof paprikaConfigSchema>;
|
|
111
|
+
export type EmbeddingConfig = z.infer<typeof embeddingConfigSchema>;
|
|
112
|
+
/** @internal Pure helper for config merging. Exported for property-based testing only. */
|
|
113
|
+
export declare function deepMerge(base: Record<string, unknown>, overrides: Record<string, unknown>): Record<string, unknown>;
|
|
114
|
+
export declare function loadConfig(configDir?: string): Result<PaprikaConfig, ConfigError>;
|
|
115
|
+
export {};
|