@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bojan Rajkovic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @bojanrajkovic/mcp-paprika
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io/) server for [Paprika](https://www.paprikaapp.com/) recipe manager. Search, browse, create, and manage your recipes from any MCP client.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **10 tools** for recipe management — search, filter, CRUD, categories, pagination
|
|
8
|
+
- **Semantic search** via `discover_recipes` — find recipes by natural language description using any OpenAI-compatible embedding provider
|
|
9
|
+
- **Background sync** — keeps your local cache in sync with Paprika's cloud
|
|
10
|
+
- **MCP resources** — expose recipes as `paprika://recipe/{uid}` resources
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
Add to your MCP client config (e.g. Claude Desktop):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"mcpServers": {
|
|
19
|
+
"paprika": {
|
|
20
|
+
"command": "npx",
|
|
21
|
+
"args": ["-y", "@bojanrajkovic/mcp-paprika"],
|
|
22
|
+
"env": {
|
|
23
|
+
"PAPRIKA_EMAIL": "you@example.com",
|
|
24
|
+
"PAPRIKA_PASSWORD": "your-password"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
See [configuration](docs/configuration.md) for all available options including background sync and semantic search.
|
|
32
|
+
|
|
33
|
+
## Documentation
|
|
34
|
+
|
|
35
|
+
- **[Configuration](docs/configuration.md)** — env vars, config files, platform paths
|
|
36
|
+
- **[Tools reference](docs/tools/)** — all 10 tools with parameters and examples
|
|
37
|
+
- **[Embedding providers](docs/embedding-providers.md)** — set up semantic search with Ollama, OpenAI, OpenRouter, etc.
|
|
38
|
+
- **[Architecture](docs/architecture.md)** — how it works under the hood
|
|
39
|
+
|
|
40
|
+
## License
|
|
41
|
+
|
|
42
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Recipe, Category, RecipeEntry, DiffResult } from "../paprika/types.js";
|
|
2
|
+
export declare class DiskCache {
|
|
3
|
+
private readonly _cacheDir;
|
|
4
|
+
private readonly _indexPath;
|
|
5
|
+
private readonly _recipesDir;
|
|
6
|
+
private readonly _categoriesDir;
|
|
7
|
+
private _index;
|
|
8
|
+
private readonly _pendingRecipes;
|
|
9
|
+
private readonly _pendingCategories;
|
|
10
|
+
constructor(cacheDir: string);
|
|
11
|
+
init(): Promise<void>;
|
|
12
|
+
flush(): Promise<void>;
|
|
13
|
+
getRecipe(uid: string): Promise<Recipe | null>;
|
|
14
|
+
putRecipe(recipe: Recipe, hash: string): void;
|
|
15
|
+
removeRecipe(uid: string): Promise<void>;
|
|
16
|
+
getAllRecipes(): Promise<Array<Recipe>>;
|
|
17
|
+
getCategory(uid: string): Promise<Category | null>;
|
|
18
|
+
putCategory(category: Category, hash: string): void;
|
|
19
|
+
private _diffEntries;
|
|
20
|
+
diffRecipes(entries: ReadonlyArray<RecipeEntry>): DiffResult;
|
|
21
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { RecipeStoredSchema, CategoryStoredSchema } from "../paprika/types.js";
|
|
5
|
+
// Type guard for NodeJS.ErrnoException. Mirrors the local helper in
|
|
6
|
+
// utils/config.ts but is intentionally not exported from there — each
|
|
7
|
+
// module defines its own copy per the existing pattern.
|
|
8
|
+
function isNodeError(error) {
|
|
9
|
+
return error instanceof Error && "code" in error;
|
|
10
|
+
}
|
|
11
|
+
// I/O error handling convention throughout this file:
|
|
12
|
+
// We use try/catch and check error.code rather than existsSync()-then-read.
|
|
13
|
+
// Reason: existsSync() is synchronous (blocks the event loop) and introduces
|
|
14
|
+
// a TOCTOU race — the file can be deleted between the existence check and the
|
|
15
|
+
// read. The try/catch pattern handles the file's actual state at I/O time with
|
|
16
|
+
// no race window, and the explicit rethrow for non-ENOENT codes (EISDIR,
|
|
17
|
+
// EACCES, …) ensures unexpected errors are never silently swallowed.
|
|
18
|
+
// File-local schema for index.json. Not exported — internal to DiskCache.
|
|
19
|
+
const CacheIndexSchema = z.object({
|
|
20
|
+
recipes: z.record(z.string(), z.string()),
|
|
21
|
+
categories: z.record(z.string(), z.string()),
|
|
22
|
+
});
|
|
23
|
+
export class DiskCache {
|
|
24
|
+
_cacheDir;
|
|
25
|
+
_indexPath;
|
|
26
|
+
_recipesDir;
|
|
27
|
+
_categoriesDir;
|
|
28
|
+
// Null until init() is called. diff*() and flush() assert non-null.
|
|
29
|
+
_index = null;
|
|
30
|
+
// Pending writes buffered by put*(). Drained by flush(). get*() checks
|
|
31
|
+
// these maps before falling back to disk so callers can read back data
|
|
32
|
+
// they just put in the same sync cycle.
|
|
33
|
+
_pendingRecipes = new Map();
|
|
34
|
+
_pendingCategories = new Map();
|
|
35
|
+
constructor(cacheDir) {
|
|
36
|
+
this._cacheDir = cacheDir;
|
|
37
|
+
this._indexPath = join(cacheDir, "index.json");
|
|
38
|
+
this._recipesDir = join(cacheDir, "recipes");
|
|
39
|
+
this._categoriesDir = join(cacheDir, "categories");
|
|
40
|
+
}
|
|
41
|
+
async init() {
|
|
42
|
+
// Create subdirectories (idempotent — recursive: true).
|
|
43
|
+
await mkdir(this._recipesDir, { recursive: true });
|
|
44
|
+
await mkdir(this._categoriesDir, { recursive: true });
|
|
45
|
+
// Load index.json. ENOENT = first run → empty index.
|
|
46
|
+
// Parse failure = corruption → log warning + empty index.
|
|
47
|
+
// Other I/O error → rethrow.
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await readFile(this._indexPath, "utf-8");
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
54
|
+
this._index = { recipes: {}, categories: {} };
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
process.stderr.write("DiskCache: corrupt index.json (invalid JSON), resetting to empty index\n");
|
|
65
|
+
this._index = { recipes: {}, categories: {} };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const result = CacheIndexSchema.safeParse(parsed);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
process.stderr.write("DiskCache: corrupt index.json (schema mismatch), resetting to empty index\n");
|
|
71
|
+
this._index = { recipes: {}, categories: {} };
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this._index = result.data;
|
|
75
|
+
}
|
|
76
|
+
async flush() {
|
|
77
|
+
if (this._index === null) {
|
|
78
|
+
throw new Error("DiskCache: flush() called before init()");
|
|
79
|
+
}
|
|
80
|
+
// Write all pending recipe and category files in parallel.
|
|
81
|
+
// Each file is opened, written, fsynced, and closed before the index
|
|
82
|
+
// rename — guaranteeing that if a crash occurs after the rename, all
|
|
83
|
+
// referenced files are durably on disk.
|
|
84
|
+
await Promise.all([
|
|
85
|
+
...[...this._pendingRecipes.entries()].map(async ([uid, recipe]) => {
|
|
86
|
+
const filePath = join(this._recipesDir, `${uid}.json`);
|
|
87
|
+
const fh = await open(filePath, "w");
|
|
88
|
+
try {
|
|
89
|
+
await fh.writeFile(JSON.stringify(recipe, null, 2));
|
|
90
|
+
await fh.sync();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await fh.close();
|
|
94
|
+
}
|
|
95
|
+
}),
|
|
96
|
+
...[...this._pendingCategories.entries()].map(async ([uid, category]) => {
|
|
97
|
+
const filePath = join(this._categoriesDir, `${uid}.json`);
|
|
98
|
+
const fh = await open(filePath, "w");
|
|
99
|
+
try {
|
|
100
|
+
await fh.writeFile(JSON.stringify(category, null, 2));
|
|
101
|
+
await fh.sync();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await fh.close();
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
// Write index atomically via temp-then-rename.
|
|
109
|
+
// The tmp file is written to cacheDir (same filesystem as index.json)
|
|
110
|
+
// so rename() is a POSIX atomic op within the same directory.
|
|
111
|
+
const tmpPath = join(this._cacheDir, `.index-${Date.now()}.tmp`);
|
|
112
|
+
const fh = await open(tmpPath, "w");
|
|
113
|
+
try {
|
|
114
|
+
await fh.writeFile(JSON.stringify(this._index, null, 2));
|
|
115
|
+
await fh.sync();
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await fh.close();
|
|
119
|
+
}
|
|
120
|
+
await rename(tmpPath, this._indexPath);
|
|
121
|
+
this._pendingRecipes.clear();
|
|
122
|
+
this._pendingCategories.clear();
|
|
123
|
+
}
|
|
124
|
+
async getRecipe(uid) {
|
|
125
|
+
// Pending map is checked first so callers can read back data they just
|
|
126
|
+
// put in the same sync cycle (before flush writes it to disk).
|
|
127
|
+
const pending = this._pendingRecipes.get(uid);
|
|
128
|
+
if (pending !== undefined) {
|
|
129
|
+
return pending;
|
|
130
|
+
}
|
|
131
|
+
const filePath = join(this._recipesDir, `${uid}.json`);
|
|
132
|
+
let raw;
|
|
133
|
+
try {
|
|
134
|
+
raw = await readFile(filePath, "utf-8");
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
return RecipeStoredSchema.parse(JSON.parse(raw));
|
|
143
|
+
}
|
|
144
|
+
putRecipe(recipe, hash) {
|
|
145
|
+
if (this._index === null) {
|
|
146
|
+
throw new Error("DiskCache: putRecipe() called before init()");
|
|
147
|
+
}
|
|
148
|
+
// Buffer in memory only — no file I/O. flush() writes to disk.
|
|
149
|
+
this._pendingRecipes.set(recipe.uid, recipe);
|
|
150
|
+
// Update index immediately so diffRecipes() reflects the new hash
|
|
151
|
+
// without requiring flush() first (AC6.1).
|
|
152
|
+
this._index.recipes[recipe.uid] = hash;
|
|
153
|
+
}
|
|
154
|
+
async removeRecipe(uid) {
|
|
155
|
+
if (this._index === null) {
|
|
156
|
+
throw new Error("DiskCache: removeRecipe() called before init()");
|
|
157
|
+
}
|
|
158
|
+
// Delete file from disk if present. ENOENT is fine — idempotent.
|
|
159
|
+
const filePath = join(this._recipesDir, `${uid}.json`);
|
|
160
|
+
try {
|
|
161
|
+
await unlink(filePath);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Remove from index and pending map.
|
|
169
|
+
delete this._index.recipes[uid];
|
|
170
|
+
this._pendingRecipes.delete(uid);
|
|
171
|
+
}
|
|
172
|
+
async getAllRecipes() {
|
|
173
|
+
if (this._index === null) {
|
|
174
|
+
throw new Error("DiskCache: getAllRecipes() called before init()");
|
|
175
|
+
}
|
|
176
|
+
// Start with pending entries. Pending shadows disk for the same UID.
|
|
177
|
+
const result = new Map(this._pendingRecipes);
|
|
178
|
+
// Read all .json files from recipesDir and add those not already in pending.
|
|
179
|
+
let files;
|
|
180
|
+
try {
|
|
181
|
+
files = await readdir(this._recipesDir);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
185
|
+
return [...result.values()];
|
|
186
|
+
}
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
190
|
+
await Promise.all(jsonFiles.map(async (filename) => {
|
|
191
|
+
const uid = filename.slice(0, -5); // strip ".json"
|
|
192
|
+
if (result.has(uid))
|
|
193
|
+
return; // pending entry shadows disk
|
|
194
|
+
const raw = await readFile(join(this._recipesDir, filename), "utf-8");
|
|
195
|
+
const recipe = RecipeStoredSchema.parse(JSON.parse(raw));
|
|
196
|
+
result.set(uid, recipe);
|
|
197
|
+
}));
|
|
198
|
+
return [...result.values()];
|
|
199
|
+
}
|
|
200
|
+
async getCategory(uid) {
|
|
201
|
+
const pending = this._pendingCategories.get(uid);
|
|
202
|
+
if (pending !== undefined) {
|
|
203
|
+
return pending;
|
|
204
|
+
}
|
|
205
|
+
const filePath = join(this._categoriesDir, `${uid}.json`);
|
|
206
|
+
let raw;
|
|
207
|
+
try {
|
|
208
|
+
raw = await readFile(filePath, "utf-8");
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
return CategoryStoredSchema.parse(JSON.parse(raw));
|
|
217
|
+
}
|
|
218
|
+
putCategory(category, hash) {
|
|
219
|
+
if (this._index === null) {
|
|
220
|
+
throw new Error("DiskCache: putCategory() called before init()");
|
|
221
|
+
}
|
|
222
|
+
this._pendingCategories.set(category.uid, category);
|
|
223
|
+
this._index.categories[category.uid] = hash;
|
|
224
|
+
}
|
|
225
|
+
// Private synchronous helper. Classifies remote entries against the local
|
|
226
|
+
// uid → hash map into added/changed/removed. Uses a Set for O(1) remote
|
|
227
|
+
// UID lookup so the algorithm is O(n + m), not O(n × m).
|
|
228
|
+
_diffEntries(remote, local) {
|
|
229
|
+
const added = [];
|
|
230
|
+
const changed = [];
|
|
231
|
+
const remoteUids = new Set();
|
|
232
|
+
for (const entry of remote) {
|
|
233
|
+
remoteUids.add(entry.uid);
|
|
234
|
+
// noUncheckedIndexedAccess: local[uid] is string | undefined
|
|
235
|
+
const localHash = local[entry.uid];
|
|
236
|
+
if (localHash === undefined) {
|
|
237
|
+
added.push(entry.uid);
|
|
238
|
+
}
|
|
239
|
+
else if (localHash !== entry.hash) {
|
|
240
|
+
changed.push(entry.uid);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const removed = Object.keys(local).filter((uid) => !remoteUids.has(uid));
|
|
244
|
+
return { added, changed, removed };
|
|
245
|
+
}
|
|
246
|
+
diffRecipes(entries) {
|
|
247
|
+
if (this._index === null) {
|
|
248
|
+
throw new Error("DiskCache: diffRecipes() called before init()");
|
|
249
|
+
}
|
|
250
|
+
return this._diffEntries(entries, this._index.recipes);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Recipe, Category, RecipeUid, CategoryUid } from "../paprika/types.js";
|
|
2
|
+
export type SearchOptions = {
|
|
3
|
+
readonly fields?: "all" | "name" | "ingredients" | "description";
|
|
4
|
+
readonly offset?: number;
|
|
5
|
+
readonly limit?: number;
|
|
6
|
+
};
|
|
7
|
+
export type ScoredResult = {
|
|
8
|
+
readonly recipe: Recipe;
|
|
9
|
+
readonly score: number;
|
|
10
|
+
};
|
|
11
|
+
export type TimeConstraints = {
|
|
12
|
+
readonly maxPrepTime?: number;
|
|
13
|
+
readonly maxCookTime?: number;
|
|
14
|
+
readonly maxTotalTime?: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class RecipeStore {
|
|
17
|
+
private readonly recipes;
|
|
18
|
+
private readonly categories;
|
|
19
|
+
load(recipes: ReadonlyArray<Recipe>, categories: ReadonlyArray<Category>): void;
|
|
20
|
+
get(uid: RecipeUid): Recipe | undefined;
|
|
21
|
+
getAll(): Array<Recipe>;
|
|
22
|
+
set(recipe: Recipe): void;
|
|
23
|
+
delete(uid: RecipeUid): void;
|
|
24
|
+
get size(): number;
|
|
25
|
+
getCategory(uid: CategoryUid): Category | undefined;
|
|
26
|
+
getAllCategories(): Array<Category>;
|
|
27
|
+
setCategories(categories: ReadonlyArray<Category>): void;
|
|
28
|
+
resolveCategories(categoryUids: ReadonlyArray<CategoryUid>): Array<string>;
|
|
29
|
+
search(query: string, options?: SearchOptions): Array<ScoredResult>;
|
|
30
|
+
filterByIngredients(terms: ReadonlyArray<string>, mode: "all" | "any", limit?: number): Array<Recipe>;
|
|
31
|
+
filterByTime(constraints: TimeConstraints): Array<Recipe>;
|
|
32
|
+
findByName(title: string): Array<Recipe>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { parseDuration } from "../utils/duration.js";
|
|
2
|
+
export class RecipeStore {
|
|
3
|
+
recipes = new Map();
|
|
4
|
+
categories = new Map();
|
|
5
|
+
load(recipes, categories) {
|
|
6
|
+
this.recipes.clear();
|
|
7
|
+
for (const recipe of recipes) {
|
|
8
|
+
this.recipes.set(recipe.uid, recipe);
|
|
9
|
+
}
|
|
10
|
+
this.categories.clear();
|
|
11
|
+
for (const category of categories) {
|
|
12
|
+
this.categories.set(category.uid, category);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
get(uid) {
|
|
16
|
+
return this.recipes.get(uid);
|
|
17
|
+
}
|
|
18
|
+
getAll() {
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const recipe of this.recipes.values()) {
|
|
21
|
+
if (!recipe.inTrash) {
|
|
22
|
+
results.push(recipe);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
set(recipe) {
|
|
28
|
+
this.recipes.set(recipe.uid, recipe);
|
|
29
|
+
}
|
|
30
|
+
delete(uid) {
|
|
31
|
+
this.recipes.delete(uid);
|
|
32
|
+
}
|
|
33
|
+
get size() {
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const recipe of this.recipes.values()) {
|
|
36
|
+
if (!recipe.inTrash) {
|
|
37
|
+
count++;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
}
|
|
42
|
+
getCategory(uid) {
|
|
43
|
+
return this.categories.get(uid);
|
|
44
|
+
}
|
|
45
|
+
getAllCategories() {
|
|
46
|
+
return [...this.categories.values()];
|
|
47
|
+
}
|
|
48
|
+
setCategories(categories) {
|
|
49
|
+
this.categories.clear();
|
|
50
|
+
for (const category of categories) {
|
|
51
|
+
this.categories.set(category.uid, category);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
resolveCategories(categoryUids) {
|
|
55
|
+
const names = [];
|
|
56
|
+
for (const uid of categoryUids) {
|
|
57
|
+
const category = this.categories.get(uid);
|
|
58
|
+
if (category) {
|
|
59
|
+
names.push(category.name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return names;
|
|
63
|
+
}
|
|
64
|
+
search(query, options) {
|
|
65
|
+
const fields = options?.fields ?? "all";
|
|
66
|
+
const offset = options?.offset ?? 0;
|
|
67
|
+
const limit = options?.limit;
|
|
68
|
+
const lowerQuery = query.toLowerCase();
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const recipe of this.recipes.values()) {
|
|
71
|
+
if (recipe.inTrash)
|
|
72
|
+
continue;
|
|
73
|
+
if (lowerQuery === "") {
|
|
74
|
+
results.push({ recipe, score: 0 });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const lowerName = recipe.name.toLowerCase();
|
|
78
|
+
let score = -1;
|
|
79
|
+
if (fields === "all" || fields === "name") {
|
|
80
|
+
if (lowerName === lowerQuery) {
|
|
81
|
+
score = 3;
|
|
82
|
+
}
|
|
83
|
+
else if (lowerName.startsWith(lowerQuery)) {
|
|
84
|
+
score = 2;
|
|
85
|
+
}
|
|
86
|
+
else if (lowerName.includes(lowerQuery)) {
|
|
87
|
+
score = 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (score === -1 && (fields === "all" || fields === "ingredients")) {
|
|
91
|
+
if (recipe.ingredients.toLowerCase().includes(lowerQuery)) {
|
|
92
|
+
score = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (score === -1 && (fields === "all" || fields === "description")) {
|
|
96
|
+
if (recipe.description?.toLowerCase().includes(lowerQuery)) {
|
|
97
|
+
score = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (score === -1 && fields === "all") {
|
|
101
|
+
if (recipe.notes?.toLowerCase().includes(lowerQuery)) {
|
|
102
|
+
score = 0;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (score >= 0) {
|
|
106
|
+
results.push({ recipe, score });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
results.sort((a, b) => {
|
|
110
|
+
if (a.score !== b.score)
|
|
111
|
+
return b.score - a.score;
|
|
112
|
+
return a.recipe.name.localeCompare(b.recipe.name);
|
|
113
|
+
});
|
|
114
|
+
const sliced = limit !== undefined ? results.slice(offset, offset + limit) : results.slice(offset);
|
|
115
|
+
return sliced;
|
|
116
|
+
}
|
|
117
|
+
filterByIngredients(terms, mode, limit) {
|
|
118
|
+
const recipes = this.getAll();
|
|
119
|
+
if (terms.length === 0) {
|
|
120
|
+
return limit !== undefined ? recipes.slice(0, limit) : recipes;
|
|
121
|
+
}
|
|
122
|
+
const lowerTerms = terms.map((t) => t.toLowerCase());
|
|
123
|
+
const matched = recipes.filter((recipe) => {
|
|
124
|
+
const lowerIngredients = recipe.ingredients.toLowerCase();
|
|
125
|
+
if (mode === "all") {
|
|
126
|
+
return lowerTerms.every((term) => lowerIngredients.includes(term));
|
|
127
|
+
}
|
|
128
|
+
return lowerTerms.some((term) => lowerIngredients.includes(term));
|
|
129
|
+
});
|
|
130
|
+
return limit !== undefined ? matched.slice(0, limit) : matched;
|
|
131
|
+
}
|
|
132
|
+
filterByTime(constraints) {
|
|
133
|
+
const recipes = this.getAll();
|
|
134
|
+
const hasConstraints = constraints.maxPrepTime !== undefined ||
|
|
135
|
+
constraints.maxCookTime !== undefined ||
|
|
136
|
+
constraints.maxTotalTime !== undefined;
|
|
137
|
+
const filtered = hasConstraints
|
|
138
|
+
? recipes.filter((recipe) => {
|
|
139
|
+
if (constraints.maxPrepTime !== undefined && recipe.prepTime !== null) {
|
|
140
|
+
const maxPrepTime = constraints.maxPrepTime;
|
|
141
|
+
if (parseDuration(recipe.prepTime).match((d) => d.as("minutes") > maxPrepTime, () => false)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (constraints.maxCookTime !== undefined && recipe.cookTime !== null) {
|
|
146
|
+
const maxCookTime = constraints.maxCookTime;
|
|
147
|
+
if (parseDuration(recipe.cookTime).match((d) => d.as("minutes") > maxCookTime, () => false)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (constraints.maxTotalTime !== undefined && recipe.totalTime !== null) {
|
|
152
|
+
const maxTotalTime = constraints.maxTotalTime;
|
|
153
|
+
if (parseDuration(recipe.totalTime).match((d) => d.as("minutes") > maxTotalTime, () => false)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
})
|
|
159
|
+
: recipes;
|
|
160
|
+
return filtered.toSorted((a, b) => {
|
|
161
|
+
const aMinutes = parseTotalTimeMinutes(a.totalTime);
|
|
162
|
+
const bMinutes = parseTotalTimeMinutes(b.totalTime);
|
|
163
|
+
if (aMinutes === null && bMinutes === null)
|
|
164
|
+
return 0;
|
|
165
|
+
if (aMinutes === null)
|
|
166
|
+
return 1;
|
|
167
|
+
if (bMinutes === null)
|
|
168
|
+
return -1;
|
|
169
|
+
return aMinutes - bMinutes;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
findByName(title) {
|
|
173
|
+
const recipes = this.getAll();
|
|
174
|
+
const lowerTitle = title.toLowerCase();
|
|
175
|
+
const exact = recipes.filter((r) => r.name.toLowerCase() === lowerTitle);
|
|
176
|
+
if (exact.length > 0)
|
|
177
|
+
return exact;
|
|
178
|
+
const startsWith = recipes.filter((r) => r.name.toLowerCase().startsWith(lowerTitle));
|
|
179
|
+
if (startsWith.length > 0)
|
|
180
|
+
return startsWith;
|
|
181
|
+
const contains = recipes.filter((r) => r.name.toLowerCase().includes(lowerTitle));
|
|
182
|
+
return contains;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function parseTotalTimeMinutes(totalTime) {
|
|
186
|
+
if (totalTime === null)
|
|
187
|
+
return null;
|
|
188
|
+
return parseDuration(totalTime).match((d) => d.as("minutes"), () => null);
|
|
189
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { ServerContext } from "../types/server-context.js";
|
|
3
|
+
import type { SyncEngine } from "../paprika/sync.js";
|
|
4
|
+
import type { PaprikaConfig } from "../utils/config.js";
|
|
5
|
+
export declare function setupDiscoverFeature(server: McpServer, ctx: ServerContext, sync: SyncEngine, config: PaprikaConfig): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EmbeddingClient } from "./embeddings.js";
|
|
2
|
+
import { VectorStore } from "./vector-store.js";
|
|
3
|
+
import { registerDiscoverTool } from "../tools/discover.js";
|
|
4
|
+
import { getCacheDir } from "../utils/xdg.js";
|
|
5
|
+
export async function setupDiscoverFeature(server, ctx, sync, config) {
|
|
6
|
+
const embeddingsConfig = config.features?.embeddings;
|
|
7
|
+
if (!embeddingsConfig) {
|
|
8
|
+
process.stderr.write("[mcp-paprika] Semantic search: disabled\n");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const embedder = new EmbeddingClient(embeddingsConfig);
|
|
12
|
+
const vectorStore = new VectorStore(getCacheDir(), embedder);
|
|
13
|
+
await vectorStore.init();
|
|
14
|
+
registerDiscoverTool(server, ctx, vectorStore);
|
|
15
|
+
// Cold-start initial indexing: the initial sync.syncOnce() in index.ts fires
|
|
16
|
+
// sync:complete BEFORE this subscription exists. On first-ever startup (empty
|
|
17
|
+
// vector index), explicitly index all recipes already in the store.
|
|
18
|
+
if (vectorStore.size === 0 && ctx.store.size > 0) {
|
|
19
|
+
await vectorStore.indexRecipes(ctx.store.getAll(), (uids) => ctx.store.resolveCategories(uids));
|
|
20
|
+
}
|
|
21
|
+
sync.events.on("sync:complete", async (result) => {
|
|
22
|
+
try {
|
|
23
|
+
const changed = [...result.added, ...result.updated];
|
|
24
|
+
if (changed.length === 0 && result.removedUids.length === 0) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (changed.length > 0) {
|
|
28
|
+
await vectorStore.indexRecipes(changed, (uids) => ctx.store.resolveCategories(uids));
|
|
29
|
+
}
|
|
30
|
+
for (const uid of result.removedUids) {
|
|
31
|
+
await vectorStore.removeRecipe(uid);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
process.stderr.write(`[mcp-paprika] Vector index error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
process.stderr.write("[mcp-paprika] Semantic search: enabled\n");
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class hierarchy for embedding operations.
|
|
3
|
+
*
|
|
4
|
+
* Two-class structure:
|
|
5
|
+
* - EmbeddingError: base class for all embedding-related errors
|
|
6
|
+
* - EmbeddingAPIError: HTTP errors with status and endpoint (extends EmbeddingError)
|
|
7
|
+
*
|
|
8
|
+
* All classes support ES2024 ErrorOptions for cause chaining.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Base error class for all embedding-related operations.
|
|
12
|
+
*/
|
|
13
|
+
export declare class EmbeddingError extends Error {
|
|
14
|
+
constructor(message: string, options?: ErrorOptions);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Error thrown when an HTTP request to the embedding API fails.
|
|
18
|
+
* Captures the HTTP status code and endpoint for debugging.
|
|
19
|
+
*
|
|
20
|
+
* The error message is formatted as: "message (HTTP status from endpoint)"
|
|
21
|
+
*/
|
|
22
|
+
export declare class EmbeddingAPIError extends EmbeddingError {
|
|
23
|
+
readonly status: number;
|
|
24
|
+
readonly endpoint: string;
|
|
25
|
+
constructor(message: string, status: number, endpoint: string, options?: ErrorOptions);
|
|
26
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error class hierarchy for embedding operations.
|
|
3
|
+
*
|
|
4
|
+
* Two-class structure:
|
|
5
|
+
* - EmbeddingError: base class for all embedding-related errors
|
|
6
|
+
* - EmbeddingAPIError: HTTP errors with status and endpoint (extends EmbeddingError)
|
|
7
|
+
*
|
|
8
|
+
* All classes support ES2024 ErrorOptions for cause chaining.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Base error class for all embedding-related operations.
|
|
12
|
+
*/
|
|
13
|
+
export class EmbeddingError extends Error {
|
|
14
|
+
constructor(message, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.name = "EmbeddingError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Error thrown when an HTTP request to the embedding API fails.
|
|
21
|
+
* Captures the HTTP status code and endpoint for debugging.
|
|
22
|
+
*
|
|
23
|
+
* The error message is formatted as: "message (HTTP status from endpoint)"
|
|
24
|
+
*/
|
|
25
|
+
export class EmbeddingAPIError extends EmbeddingError {
|
|
26
|
+
status;
|
|
27
|
+
endpoint;
|
|
28
|
+
constructor(message, status, endpoint, options) {
|
|
29
|
+
super(`${message} (HTTP ${status} from ${endpoint})`, options);
|
|
30
|
+
this.name = "EmbeddingAPIError";
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.endpoint = endpoint;
|
|
33
|
+
}
|
|
34
|
+
}
|