@bojanrajkovic/mcp-paprika 1.0.4 → 1.1.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/README.md +103 -7
- package/dist/cache/disk-cache.d.ts +6 -1
- package/dist/cache/disk-cache.js +70 -4
- package/dist/cache/pantry-store.d.ts +20 -0
- package/dist/cache/pantry-store.js +81 -0
- package/dist/features/discover-feature.d.ts +28 -4
- package/dist/features/discover-feature.js +25 -13
- package/dist/index.js +15 -84
- package/dist/paprika/client.d.ts +4 -1
- package/dist/paprika/client.js +41 -1
- package/dist/paprika/dates.d.ts +25 -0
- package/dist/paprika/dates.js +57 -0
- package/dist/paprika/sync.d.ts +2 -2
- package/dist/paprika/sync.js +55 -24
- package/dist/paprika/types.d.ts +119 -10
- package/dist/paprika/types.js +64 -4
- package/dist/resources/pantry.d.ts +3 -0
- package/dist/resources/pantry.js +32 -0
- package/dist/server/app-context.d.ts +32 -0
- package/dist/server/app-context.js +1 -0
- package/dist/server/build.d.ts +45 -0
- package/dist/server/build.js +164 -0
- package/dist/server/notifier.d.ts +47 -0
- package/dist/server/notifier.js +76 -0
- package/dist/tools/helpers.d.ts +1 -1
- package/dist/tools/helpers.js +2 -2
- package/dist/tools/pantry-add.d.ts +3 -0
- package/dist/tools/pantry-add.js +70 -0
- package/dist/tools/pantry-delete.d.ts +3 -0
- package/dist/tools/pantry-delete.js +44 -0
- package/dist/tools/pantry-get.d.ts +3 -0
- package/dist/tools/pantry-get.js +39 -0
- package/dist/tools/pantry-helpers.d.ts +20 -0
- package/dist/tools/pantry-helpers.js +62 -0
- package/dist/tools/pantry-list.d.ts +3 -0
- package/dist/tools/pantry-list.js +23 -0
- package/dist/tools/pantry-update.d.ts +3 -0
- package/dist/tools/pantry-update.js +71 -0
- package/dist/transport/http.d.ts +29 -0
- package/dist/transport/http.js +146 -0
- package/dist/transport/stdio.d.ts +10 -0
- package/dist/transport/stdio.js +56 -0
- package/dist/types/server-context.d.ts +16 -10
- package/dist/utils/config.d.ts +21 -0
- package/dist/utils/config.js +23 -0
- package/package.json +14 -11
package/README.md
CHANGED
|
@@ -4,14 +4,30 @@ An [MCP](https://modelcontextprotocol.io/) server for [Paprika](https://www.papr
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **14 tools** for recipe and pantry management — search, filter, CRUD, categories, pagination, pantry inventory
|
|
8
8
|
- **Semantic search** via `discover_recipes` — find recipes by natural language description using any OpenAI-compatible embedding provider
|
|
9
9
|
- **Background sync** — keeps your local cache in sync with Paprika's cloud
|
|
10
|
-
- **MCP resources** — expose recipes as `paprika://recipe/{uid}` resources
|
|
10
|
+
- **MCP resources** — expose recipes as `paprika://recipe/{uid}` and pantry items as `paprika://pantry/{uid}` resources
|
|
11
|
+
- **Two transports** — stdio (default, for CLI clients) and Streamable HTTP (for mobile/web clients)
|
|
12
|
+
- **Container image** — `Dockerfile` ships a distroless runtime ready for self-hosting
|
|
11
13
|
|
|
12
|
-
##
|
|
14
|
+
## Transports
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
`mcp-paprika` can speak the MCP protocol over two transports, selected via `MCP_TRANSPORT`:
|
|
17
|
+
|
|
18
|
+
| Transport | Default? | Use it for |
|
|
19
|
+
| --------- | -------- | ----------------------------------------------------------------------------------- |
|
|
20
|
+
| `stdio` | yes | Local CLI clients: Claude Code, Claude Desktop, Cursor, mcp-cli |
|
|
21
|
+
| `http` | no | Streamable HTTP for Claude Mobile and other HTTP-based MCP clients, or self-hosting |
|
|
22
|
+
|
|
23
|
+
> **HTTP transport has no built-in authentication.** Do not expose port 3000 directly
|
|
24
|
+
> to the public internet. Put it behind Cloudflare Access, Tailscale Serve, an OAuth2
|
|
25
|
+
> proxy, or your reverse proxy of choice. OAuth 2.1 support is planned as a follow-up
|
|
26
|
+
> — until then, network-trust is the supported deployment model.
|
|
27
|
+
|
|
28
|
+
## Quick start — stdio (Claude Desktop / Claude Code / Cursor)
|
|
29
|
+
|
|
30
|
+
Add to your MCP client config:
|
|
15
31
|
|
|
16
32
|
```json
|
|
17
33
|
{
|
|
@@ -28,14 +44,94 @@ Add to your MCP client config (e.g. Claude Desktop):
|
|
|
28
44
|
}
|
|
29
45
|
```
|
|
30
46
|
|
|
31
|
-
|
|
47
|
+
## Quick start — HTTP transport
|
|
48
|
+
|
|
49
|
+
Run with env vars set:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
MCP_TRANSPORT=http \
|
|
53
|
+
MCP_HTTP_PORT=3000 \
|
|
54
|
+
PAPRIKA_EMAIL=you@example.com \
|
|
55
|
+
PAPRIKA_PASSWORD=your-password \
|
|
56
|
+
npx -y @bojanrajkovic/mcp-paprika
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The server then exposes:
|
|
60
|
+
|
|
61
|
+
- `POST /mcp` — MCP JSON-RPC over Streamable HTTP (single endpoint that multiplexes initialize, tools/list, tools/call, etc.)
|
|
62
|
+
- `GET /mcp` — long-lived SSE channel for server→client notifications (resource list changed, log messages)
|
|
63
|
+
- `DELETE /mcp` — session termination
|
|
64
|
+
- `GET /healthz` — liveness probe returning `{ "ok": true, "sessions": <n> }`
|
|
65
|
+
|
|
66
|
+
Verify locally:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -sf http://127.0.0.1:3000/healthz
|
|
70
|
+
# → {"ok":true,"sessions":0}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start — container
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
docker build -t mcp-paprika:dev .
|
|
77
|
+
|
|
78
|
+
docker run --rm \
|
|
79
|
+
-e PAPRIKA_EMAIL=you@example.com \
|
|
80
|
+
-e PAPRIKA_PASSWORD=your-password \
|
|
81
|
+
-v "$(pwd)/data:/data" \
|
|
82
|
+
-p 3000:3000 \
|
|
83
|
+
mcp-paprika:dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The image defaults to `MCP_TRANSPORT=http`, binds on `0.0.0.0:3000`, and persists the
|
|
87
|
+
disk cache and vector index under `/data` (the documented mount point). Both `/data`
|
|
88
|
+
sub-directories (`config/`, `cache/`) are pre-created with `nonroot` (UID 65532)
|
|
89
|
+
ownership in the image so writes work the first time even on a fresh bind-mount.
|
|
90
|
+
|
|
91
|
+
If you bind-mount a host directory you created as root, pre-chown it:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
mkdir -p ./data && sudo chown -R 65532:65532 ./data
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Or use a named volume (Docker handles ownership automatically):
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
docker run --rm \
|
|
101
|
+
-e PAPRIKA_EMAIL=... -e PAPRIKA_PASSWORD=... \
|
|
102
|
+
-v mcp-paprika-data:/data \
|
|
103
|
+
-p 3000:3000 \
|
|
104
|
+
mcp-paprika:dev
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The image also declares a `HEALTHCHECK` that hits `GET /healthz`; verify with:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
docker inspect --format '{{.State.Health.Status}}' <container>
|
|
111
|
+
# → healthy
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Deployment patterns (HTTP transport)
|
|
115
|
+
|
|
116
|
+
Because the HTTP transport ships without authentication, the supported deployment is
|
|
117
|
+
"behind a network-trust boundary." Some good options:
|
|
118
|
+
|
|
119
|
+
- **Cloudflare Tunnel + Cloudflare Access** (recommended for public reachability) — a
|
|
120
|
+
zero-trust front door with SSO/IdP integration, no inbound ports exposed.
|
|
121
|
+
- **Tailscale Serve** — exposes the container only over your tailnet; perfect for
|
|
122
|
+
homelab / single-user setups.
|
|
123
|
+
- **OAuth2 proxy** (e.g. [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy))
|
|
124
|
+
in front of the container.
|
|
125
|
+
- **Reverse proxy basic-auth** (nginx / Caddy `basic_auth`) for the simplest setup
|
|
126
|
+
when you really just need a password gate.
|
|
32
127
|
|
|
33
128
|
## Documentation
|
|
34
129
|
|
|
35
|
-
- **[Configuration](docs/configuration.md)** — env vars, config files, platform paths
|
|
36
|
-
- **[Tools reference](docs/tools/)** —
|
|
130
|
+
- **[Configuration](docs/configuration.md)** — env vars, config files, transport options, platform paths
|
|
131
|
+
- **[Tools reference](docs/tools/)** — every tool with parameters and examples
|
|
37
132
|
- **[Embedding providers](docs/embedding-providers.md)** — set up semantic search with Ollama, OpenAI, OpenRouter, etc.
|
|
38
133
|
- **[Architecture](docs/architecture.md)** — how it works under the hood
|
|
134
|
+
- **[Verified MCP SDK API](docs/verified-api.md)** — the authoritative reference for SDK import paths and the Streamable HTTP wiring
|
|
39
135
|
|
|
40
136
|
## License
|
|
41
137
|
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import type { Recipe, Category, RecipeEntry, DiffResult } from "../paprika/types.js";
|
|
1
|
+
import type { Recipe, Category, RecipeEntry, DiffResult, PantryItem } from "../paprika/types.js";
|
|
2
2
|
export declare class DiskCache {
|
|
3
3
|
private readonly _cacheDir;
|
|
4
4
|
private readonly _indexPath;
|
|
5
5
|
private readonly _recipesDir;
|
|
6
6
|
private readonly _categoriesDir;
|
|
7
|
+
private readonly _pantryDir;
|
|
7
8
|
private _index;
|
|
8
9
|
private readonly _pendingRecipes;
|
|
9
10
|
private readonly _pendingCategories;
|
|
11
|
+
private readonly _pendingPantryItems;
|
|
10
12
|
constructor(cacheDir: string);
|
|
11
13
|
init(): Promise<void>;
|
|
12
14
|
flush(): Promise<void>;
|
|
13
15
|
getRecipe(uid: string): Promise<Recipe | null>;
|
|
14
16
|
putRecipe(recipe: Recipe, hash: string): void;
|
|
17
|
+
putPantryItem(item: PantryItem): void;
|
|
15
18
|
removeRecipe(uid: string): Promise<void>;
|
|
19
|
+
removePantryItem(uid: string): Promise<void>;
|
|
16
20
|
getAllRecipes(): Promise<Array<Recipe>>;
|
|
21
|
+
getAllPantryItems(): Promise<Array<PantryItem>>;
|
|
17
22
|
getCategory(uid: string): Promise<Category | null>;
|
|
18
23
|
putCategory(category: Category, hash: string): void;
|
|
19
24
|
private _diffEntries;
|
package/dist/cache/disk-cache.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { RecipeStoredSchema, CategoryStoredSchema } from "../paprika/types.js";
|
|
4
|
+
import { RecipeStoredSchema, CategoryStoredSchema, PantryItemStoredSchema } from "../paprika/types.js";
|
|
5
5
|
// Type guard for NodeJS.ErrnoException. Mirrors the local helper in
|
|
6
6
|
// utils/config.ts but is intentionally not exported from there — each
|
|
7
7
|
// module defines its own copy per the existing pattern.
|
|
@@ -19,12 +19,14 @@ function isNodeError(error) {
|
|
|
19
19
|
const CacheIndexSchema = z.object({
|
|
20
20
|
recipes: z.record(z.string(), z.string()),
|
|
21
21
|
categories: z.record(z.string(), z.string()),
|
|
22
|
+
pantry: z.record(z.string(), z.string()).default({}),
|
|
22
23
|
});
|
|
23
24
|
export class DiskCache {
|
|
24
25
|
_cacheDir;
|
|
25
26
|
_indexPath;
|
|
26
27
|
_recipesDir;
|
|
27
28
|
_categoriesDir;
|
|
29
|
+
_pantryDir;
|
|
28
30
|
// Null until init() is called. diff*() and flush() assert non-null.
|
|
29
31
|
_index = null;
|
|
30
32
|
// Pending writes buffered by put*(). Drained by flush(). get*() checks
|
|
@@ -32,16 +34,19 @@ export class DiskCache {
|
|
|
32
34
|
// they just put in the same sync cycle.
|
|
33
35
|
_pendingRecipes = new Map();
|
|
34
36
|
_pendingCategories = new Map();
|
|
37
|
+
_pendingPantryItems = new Map();
|
|
35
38
|
constructor(cacheDir) {
|
|
36
39
|
this._cacheDir = cacheDir;
|
|
37
40
|
this._indexPath = join(cacheDir, "index.json");
|
|
38
41
|
this._recipesDir = join(cacheDir, "recipes");
|
|
39
42
|
this._categoriesDir = join(cacheDir, "categories");
|
|
43
|
+
this._pantryDir = join(cacheDir, "pantry");
|
|
40
44
|
}
|
|
41
45
|
async init() {
|
|
42
46
|
// Create subdirectories (idempotent — recursive: true).
|
|
43
47
|
await mkdir(this._recipesDir, { recursive: true });
|
|
44
48
|
await mkdir(this._categoriesDir, { recursive: true });
|
|
49
|
+
await mkdir(this._pantryDir, { recursive: true });
|
|
45
50
|
// Load index.json. ENOENT = first run → empty index.
|
|
46
51
|
// Parse failure = corruption → log warning + empty index.
|
|
47
52
|
// Other I/O error → rethrow.
|
|
@@ -51,7 +56,7 @@ export class DiskCache {
|
|
|
51
56
|
}
|
|
52
57
|
catch (error) {
|
|
53
58
|
if (isNodeError(error) && error.code === "ENOENT") {
|
|
54
|
-
this._index = { recipes: {}, categories: {} };
|
|
59
|
+
this._index = { recipes: {}, categories: {}, pantry: {} };
|
|
55
60
|
return;
|
|
56
61
|
}
|
|
57
62
|
throw error;
|
|
@@ -62,13 +67,13 @@ export class DiskCache {
|
|
|
62
67
|
}
|
|
63
68
|
catch {
|
|
64
69
|
process.stderr.write("DiskCache: corrupt index.json (invalid JSON), resetting to empty index\n");
|
|
65
|
-
this._index = { recipes: {}, categories: {} };
|
|
70
|
+
this._index = { recipes: {}, categories: {}, pantry: {} };
|
|
66
71
|
return;
|
|
67
72
|
}
|
|
68
73
|
const result = CacheIndexSchema.safeParse(parsed);
|
|
69
74
|
if (!result.success) {
|
|
70
75
|
process.stderr.write("DiskCache: corrupt index.json (schema mismatch), resetting to empty index\n");
|
|
71
|
-
this._index = { recipes: {}, categories: {} };
|
|
76
|
+
this._index = { recipes: {}, categories: {}, pantry: {} };
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
74
79
|
this._index = result.data;
|
|
@@ -104,6 +109,17 @@ export class DiskCache {
|
|
|
104
109
|
await fh.close();
|
|
105
110
|
}
|
|
106
111
|
}),
|
|
112
|
+
...[...this._pendingPantryItems.entries()].map(async ([uid, item]) => {
|
|
113
|
+
const filePath = join(this._pantryDir, `${uid}.json`);
|
|
114
|
+
const fh = await open(filePath, "w");
|
|
115
|
+
try {
|
|
116
|
+
await fh.writeFile(JSON.stringify(item, null, 2));
|
|
117
|
+
await fh.sync();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await fh.close();
|
|
121
|
+
}
|
|
122
|
+
}),
|
|
107
123
|
]);
|
|
108
124
|
// Write index atomically via temp-then-rename.
|
|
109
125
|
// The tmp file is written to cacheDir (same filesystem as index.json)
|
|
@@ -120,6 +136,7 @@ export class DiskCache {
|
|
|
120
136
|
await rename(tmpPath, this._indexPath);
|
|
121
137
|
this._pendingRecipes.clear();
|
|
122
138
|
this._pendingCategories.clear();
|
|
139
|
+
this._pendingPantryItems.clear();
|
|
123
140
|
}
|
|
124
141
|
async getRecipe(uid) {
|
|
125
142
|
// Pending map is checked first so callers can read back data they just
|
|
@@ -151,6 +168,13 @@ export class DiskCache {
|
|
|
151
168
|
// without requiring flush() first (AC6.1).
|
|
152
169
|
this._index.recipes[recipe.uid] = hash;
|
|
153
170
|
}
|
|
171
|
+
putPantryItem(item) {
|
|
172
|
+
if (this._index === null) {
|
|
173
|
+
throw new Error("DiskCache: putPantryItem() called before init()");
|
|
174
|
+
}
|
|
175
|
+
this._pendingPantryItems.set(item.uid, item);
|
|
176
|
+
this._index.pantry[item.uid] = "";
|
|
177
|
+
}
|
|
154
178
|
async removeRecipe(uid) {
|
|
155
179
|
if (this._index === null) {
|
|
156
180
|
throw new Error("DiskCache: removeRecipe() called before init()");
|
|
@@ -169,6 +193,22 @@ export class DiskCache {
|
|
|
169
193
|
delete this._index.recipes[uid];
|
|
170
194
|
this._pendingRecipes.delete(uid);
|
|
171
195
|
}
|
|
196
|
+
async removePantryItem(uid) {
|
|
197
|
+
if (this._index === null) {
|
|
198
|
+
throw new Error("DiskCache: removePantryItem() called before init()");
|
|
199
|
+
}
|
|
200
|
+
const filePath = join(this._pantryDir, `${uid}.json`);
|
|
201
|
+
try {
|
|
202
|
+
await unlink(filePath);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
delete this._index.pantry[uid];
|
|
210
|
+
this._pendingPantryItems.delete(uid);
|
|
211
|
+
}
|
|
172
212
|
async getAllRecipes() {
|
|
173
213
|
if (this._index === null) {
|
|
174
214
|
throw new Error("DiskCache: getAllRecipes() called before init()");
|
|
@@ -197,6 +237,32 @@ export class DiskCache {
|
|
|
197
237
|
}));
|
|
198
238
|
return [...result.values()];
|
|
199
239
|
}
|
|
240
|
+
async getAllPantryItems() {
|
|
241
|
+
if (this._index === null) {
|
|
242
|
+
throw new Error("DiskCache: getAllPantryItems() called before init()");
|
|
243
|
+
}
|
|
244
|
+
const result = new Map(this._pendingPantryItems);
|
|
245
|
+
let files;
|
|
246
|
+
try {
|
|
247
|
+
files = await readdir(this._pantryDir);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
251
|
+
return [...result.values()];
|
|
252
|
+
}
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
|
256
|
+
await Promise.all(jsonFiles.map(async (filename) => {
|
|
257
|
+
const uid = filename.slice(0, -5);
|
|
258
|
+
if (result.has(uid))
|
|
259
|
+
return;
|
|
260
|
+
const raw = await readFile(join(this._pantryDir, filename), "utf-8");
|
|
261
|
+
const item = PantryItemStoredSchema.parse(JSON.parse(raw));
|
|
262
|
+
result.set(uid, item);
|
|
263
|
+
}));
|
|
264
|
+
return [...result.values()];
|
|
265
|
+
}
|
|
200
266
|
async getCategory(uid) {
|
|
201
267
|
const pending = this._pendingCategories.get(uid);
|
|
202
268
|
if (pending !== undefined) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PantryItem, PantryItemUid } from "../paprika/types.js";
|
|
2
|
+
export declare class PantryStore {
|
|
3
|
+
private readonly _items;
|
|
4
|
+
private readonly _tombstones;
|
|
5
|
+
private _hasSynced;
|
|
6
|
+
load(items: ReadonlyArray<PantryItem>): void;
|
|
7
|
+
get(uid: PantryItemUid): PantryItem | undefined;
|
|
8
|
+
getAll(): Array<PantryItem>;
|
|
9
|
+
set(item: PantryItem): void;
|
|
10
|
+
delete(uid: PantryItemUid): void;
|
|
11
|
+
/**
|
|
12
|
+
* Returns true if `uid` was soft-deleted via this store in the current
|
|
13
|
+
* session (since the last `load()`). Used by `delete_pantry_item` to give
|
|
14
|
+
* idempotent retried-delete callers a clear "already deleted" signal.
|
|
15
|
+
*/
|
|
16
|
+
isTombstone(uid: PantryItemUid): boolean;
|
|
17
|
+
get size(): number;
|
|
18
|
+
get hasSynced(): boolean;
|
|
19
|
+
findByIngredient(query: string): Array<PantryItem>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class PantryStore {
|
|
2
|
+
_items = new Map();
|
|
3
|
+
// Tombstones track UIDs that were soft-deleted via this client, so
|
|
4
|
+
// `delete_pantry_item` can return a distinct "already deleted" message for
|
|
5
|
+
// retried calls (server upserts by UID and the live-items map alone can't
|
|
6
|
+
// distinguish "I deleted this" from "this never existed"). Tombstones
|
|
7
|
+
// persist across `load()` so delayed retries that span a sync cycle still
|
|
8
|
+
// get the idempotent signal — `load()` only un-tombstones UIDs that are
|
|
9
|
+
// now back in the live items list (i.e. resurrected by the server, e.g.,
|
|
10
|
+
// un-deleted via another client). The tombstone set therefore stays
|
|
11
|
+
// disjoint from `_items` after every load.
|
|
12
|
+
_tombstones = new Set();
|
|
13
|
+
_hasSynced = false;
|
|
14
|
+
load(items) {
|
|
15
|
+
this._items.clear();
|
|
16
|
+
for (const item of items) {
|
|
17
|
+
this._items.set(item.uid, item);
|
|
18
|
+
// Resurrection: if this UID was previously tombstoned, the new live
|
|
19
|
+
// entry supersedes the tombstone (item came back from the server).
|
|
20
|
+
this._tombstones.delete(item.uid);
|
|
21
|
+
}
|
|
22
|
+
this._hasSynced = true;
|
|
23
|
+
}
|
|
24
|
+
get(uid) {
|
|
25
|
+
return this._items.get(uid);
|
|
26
|
+
}
|
|
27
|
+
getAll() {
|
|
28
|
+
return [...this._items.values()];
|
|
29
|
+
}
|
|
30
|
+
set(item) {
|
|
31
|
+
this._items.set(item.uid, item);
|
|
32
|
+
this._tombstones.delete(item.uid);
|
|
33
|
+
}
|
|
34
|
+
delete(uid) {
|
|
35
|
+
// Always tombstone, regardless of whether `uid` is currently in `_items`.
|
|
36
|
+
// The only caller is `commitPantryItem`'s delete branch (post-successful
|
|
37
|
+
// savePantryItem), but several awaits separate the save from the local
|
|
38
|
+
// commit; SyncEngine.syncOnce() can interleave a `load(...)` that wipes
|
|
39
|
+
// the UID from `_items` before commit lands. Conditioning the tombstone
|
|
40
|
+
// on `_items.has(uid)` would silently drop the idempotent retry signal
|
|
41
|
+
// in exactly that race. Spurious tombstones from other callers are
|
|
42
|
+
// acceptable: an extra "already deleted" message is harmless; a missing
|
|
43
|
+
// one isn't.
|
|
44
|
+
this._tombstones.add(uid);
|
|
45
|
+
this._items.delete(uid);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Returns true if `uid` was soft-deleted via this store in the current
|
|
49
|
+
* session (since the last `load()`). Used by `delete_pantry_item` to give
|
|
50
|
+
* idempotent retried-delete callers a clear "already deleted" signal.
|
|
51
|
+
*/
|
|
52
|
+
isTombstone(uid) {
|
|
53
|
+
return this._tombstones.has(uid);
|
|
54
|
+
}
|
|
55
|
+
get size() {
|
|
56
|
+
return this._items.size;
|
|
57
|
+
}
|
|
58
|
+
get hasSynced() {
|
|
59
|
+
return this._hasSynced;
|
|
60
|
+
}
|
|
61
|
+
findByIngredient(query) {
|
|
62
|
+
const needle = query.toLowerCase();
|
|
63
|
+
const exact = [];
|
|
64
|
+
const prefix = [];
|
|
65
|
+
const substring = [];
|
|
66
|
+
for (const item of this._items.values()) {
|
|
67
|
+
const hay = item.ingredient.toLowerCase();
|
|
68
|
+
if (hay === needle)
|
|
69
|
+
exact.push(item);
|
|
70
|
+
else if (hay.startsWith(needle))
|
|
71
|
+
prefix.push(item);
|
|
72
|
+
else if (hay.includes(needle))
|
|
73
|
+
substring.push(item);
|
|
74
|
+
}
|
|
75
|
+
if (exact.length > 0)
|
|
76
|
+
return exact;
|
|
77
|
+
if (prefix.length > 0)
|
|
78
|
+
return prefix;
|
|
79
|
+
return substring;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -1,5 +1,29 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
1
|
+
import { VectorStore } from "./vector-store.js";
|
|
2
|
+
import type { RecipeStore } from "../cache/recipe-store.js";
|
|
3
|
+
import type { SyncResult } from "../paprika/types.js";
|
|
4
4
|
import type { PaprikaConfig } from "../utils/config.js";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* View over the SyncEngine event stream. Matches `SyncEngine.events` (a
|
|
7
|
+
* `Pick<SyncEventEmitter, "on" | "off">`). Declared locally to avoid
|
|
8
|
+
* pulling a hard dependency on `SyncEngine` itself.
|
|
9
|
+
*/
|
|
10
|
+
export interface SyncEventsView {
|
|
11
|
+
on(event: "sync:complete", handler: (data: SyncResult) => void): void;
|
|
12
|
+
on(event: "sync:error", handler: (data: Error) => void): void;
|
|
13
|
+
off(event: "sync:complete", handler?: (data: SyncResult) => void): void;
|
|
14
|
+
off(event: "sync:error", handler?: (data: Error) => void): void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the process-wide semantic-search components.
|
|
18
|
+
*
|
|
19
|
+
* - Returns `null` and logs "Semantic search: disabled" when embeddings are
|
|
20
|
+
* not configured. Callers should skip discover tool registration in that case.
|
|
21
|
+
* - Otherwise: instantiates the embedding client + vector store, performs
|
|
22
|
+
* cold-start indexing if the vector store is missing entries relative to
|
|
23
|
+
* the recipe store, and subscribes to `syncEvents.on("sync:complete", …)`
|
|
24
|
+
* for incremental re-indexing.
|
|
25
|
+
*
|
|
26
|
+
* Tool registration is intentionally NOT done here — `buildMcpServer` calls
|
|
27
|
+
* `registerDiscoverTool(server, sessionCtx, vectorStore)` per session.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildDiscoverComponents(config: PaprikaConfig, store: RecipeStore, syncEvents: SyncEventsView): Promise<VectorStore | null>;
|
|
@@ -1,34 +1,45 @@
|
|
|
1
1
|
import { EmbeddingClient, EMBEDDING_SCHEMA_VERSION } from "./embeddings.js";
|
|
2
2
|
import { VectorStore } from "./vector-store.js";
|
|
3
|
-
import { registerDiscoverTool } from "../tools/discover.js";
|
|
4
3
|
import { getCacheDir } from "../utils/xdg.js";
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Build the process-wide semantic-search components.
|
|
6
|
+
*
|
|
7
|
+
* - Returns `null` and logs "Semantic search: disabled" when embeddings are
|
|
8
|
+
* not configured. Callers should skip discover tool registration in that case.
|
|
9
|
+
* - Otherwise: instantiates the embedding client + vector store, performs
|
|
10
|
+
* cold-start indexing if the vector store is missing entries relative to
|
|
11
|
+
* the recipe store, and subscribes to `syncEvents.on("sync:complete", …)`
|
|
12
|
+
* for incremental re-indexing.
|
|
13
|
+
*
|
|
14
|
+
* Tool registration is intentionally NOT done here — `buildMcpServer` calls
|
|
15
|
+
* `registerDiscoverTool(server, sessionCtx, vectorStore)` per session.
|
|
16
|
+
*/
|
|
17
|
+
export async function buildDiscoverComponents(config, store, syncEvents) {
|
|
6
18
|
const embeddingsConfig = config.features?.embeddings;
|
|
7
19
|
if (!embeddingsConfig) {
|
|
8
20
|
process.stderr.write("[mcp-paprika] Semantic search: disabled\n");
|
|
9
|
-
return;
|
|
21
|
+
return null;
|
|
10
22
|
}
|
|
11
23
|
const embedder = new EmbeddingClient(embeddingsConfig);
|
|
12
24
|
const vectorStore = new VectorStore(getCacheDir(), embedder, embeddingsConfig.model, EMBEDDING_SCHEMA_VERSION);
|
|
13
25
|
await vectorStore.init();
|
|
14
|
-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// the
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
if (ctx.store.size > 0 && vectorStore.size < ctx.store.size * 0.9) {
|
|
26
|
+
// Cold-start initial indexing: the initial sync.syncOnce() in the entry
|
|
27
|
+
// point fires sync:complete BEFORE this subscription exists. Re-index all
|
|
28
|
+
// recipes when the vector store is empty or significantly out of sync
|
|
29
|
+
// with the recipe store (stale test data, orphaned entries from a prior
|
|
30
|
+
// crash, or a model/dimension change that invalidated the old vectors).
|
|
31
|
+
if (store.size > 0 && vectorStore.size < store.size * 0.9) {
|
|
21
32
|
vectorStore.clearHashes();
|
|
22
|
-
await vectorStore.indexRecipes(
|
|
33
|
+
await vectorStore.indexRecipes(store.getAll(), (uids) => store.resolveCategories(uids));
|
|
23
34
|
}
|
|
24
|
-
|
|
35
|
+
syncEvents.on("sync:complete", async (result) => {
|
|
25
36
|
try {
|
|
26
37
|
const changed = [...result.added, ...result.updated];
|
|
27
38
|
if (changed.length === 0 && result.removedUids.length === 0) {
|
|
28
39
|
return;
|
|
29
40
|
}
|
|
30
41
|
if (changed.length > 0) {
|
|
31
|
-
await vectorStore.indexRecipes(changed, (uids) =>
|
|
42
|
+
await vectorStore.indexRecipes(changed, (uids) => store.resolveCategories(uids));
|
|
32
43
|
}
|
|
33
44
|
for (const uid of result.removedUids) {
|
|
34
45
|
await vectorStore.removeRecipe(uid);
|
|
@@ -39,4 +50,5 @@ export async function setupDiscoverFeature(server, ctx, sync, config) {
|
|
|
39
50
|
}
|
|
40
51
|
});
|
|
41
52
|
process.stderr.write("[mcp-paprika] Semantic search: enabled\n");
|
|
53
|
+
return vectorStore;
|
|
42
54
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,100 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { PaprikaClient } from "./paprika/client.js";
|
|
5
|
-
import { SyncEngine } from "./paprika/sync.js";
|
|
6
|
-
import { DiskCache } from "./cache/disk-cache.js";
|
|
7
|
-
import { RecipeStore } from "./cache/recipe-store.js";
|
|
2
|
+
import { startHttp } from "./transport/http.js";
|
|
3
|
+
import { startStdio } from "./transport/stdio.js";
|
|
8
4
|
import { loadConfig } from "./utils/config.js";
|
|
9
|
-
import { getCacheDir } from "./utils/xdg.js";
|
|
10
|
-
import { registerSearchTool } from "./tools/search.js";
|
|
11
|
-
import { registerFilterTools } from "./tools/filter.js";
|
|
12
|
-
import { registerCategoryTools } from "./tools/categories.js";
|
|
13
|
-
import { registerReadTool } from "./tools/read.js";
|
|
14
|
-
import { registerCreateTool } from "./tools/create.js";
|
|
15
|
-
import { registerUpdateTool } from "./tools/update.js";
|
|
16
|
-
import { registerDeleteTool } from "./tools/delete.js";
|
|
17
|
-
import { registerListTool } from "./tools/list.js";
|
|
18
|
-
import { registerRecipeResources } from "./resources/recipes.js";
|
|
19
|
-
import { setupDiscoverFeature } from "./features/discover-feature.js";
|
|
20
5
|
function log(msg) {
|
|
21
6
|
process.stderr.write(`[mcp-paprika] ${msg}\n`);
|
|
22
7
|
}
|
|
23
8
|
async function main() {
|
|
24
|
-
// 1. Load and validate config
|
|
25
9
|
log("Loading configuration...");
|
|
26
|
-
const
|
|
27
|
-
const config = configResult.match((cfg) => cfg, (err) => {
|
|
10
|
+
const config = loadConfig().match((cfg) => cfg, (err) => {
|
|
28
11
|
throw err;
|
|
29
12
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const cache = new DiskCache(getCacheDir());
|
|
38
|
-
await cache.init();
|
|
39
|
-
// 4. Construct RecipeStore and hydrate from cache
|
|
40
|
-
const store = new RecipeStore();
|
|
41
|
-
const cachedRecipes = await cache.getAllRecipes();
|
|
42
|
-
for (const recipe of cachedRecipes) {
|
|
43
|
-
store.set(recipe);
|
|
44
|
-
}
|
|
45
|
-
log(`Hydrated store with ${cachedRecipes.length} cached recipes.`);
|
|
46
|
-
// 5. Construct McpServer
|
|
47
|
-
const server = new McpServer({
|
|
48
|
-
name: "mcp-paprika",
|
|
49
|
-
version: "0.0.0",
|
|
50
|
-
});
|
|
51
|
-
// 6. Assemble ServerContext
|
|
52
|
-
const ctx = {
|
|
53
|
-
client,
|
|
54
|
-
cache,
|
|
55
|
-
store,
|
|
56
|
-
server,
|
|
13
|
+
const handle = config.transport === "http" ? await startHttp(config) : await startStdio(config);
|
|
14
|
+
const onSignal = (signal) => {
|
|
15
|
+
log(`${signal} received, shutting down...`);
|
|
16
|
+
handle.shutdown().then(() => process.exit(0), (err) => {
|
|
17
|
+
process.stderr.write(`[mcp-paprika] Shutdown error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|
|
57
20
|
};
|
|
58
|
-
// 7. Register all 9 tools
|
|
59
|
-
registerSearchTool(server, ctx);
|
|
60
|
-
registerFilterTools(server, ctx);
|
|
61
|
-
registerCategoryTools(server, ctx);
|
|
62
|
-
registerListTool(server, ctx);
|
|
63
|
-
registerReadTool(server, ctx);
|
|
64
|
-
registerCreateTool(server, ctx);
|
|
65
|
-
registerUpdateTool(server, ctx);
|
|
66
|
-
registerDeleteTool(server, ctx);
|
|
67
|
-
log("Registered 9 tools.");
|
|
68
|
-
// 8. Register recipe resources
|
|
69
|
-
registerRecipeResources(server, ctx);
|
|
70
|
-
log("Registered recipe resources.");
|
|
71
|
-
// 9. Construct SyncEngine, run initial sync, then start background loop
|
|
72
|
-
const sync = new SyncEngine(ctx, config.sync.interval);
|
|
73
|
-
log("Running initial sync...");
|
|
74
|
-
await sync.syncOnce();
|
|
75
|
-
log("Initial sync complete.");
|
|
76
|
-
if (config.sync.enabled) {
|
|
77
|
-
sync.start();
|
|
78
|
-
log(`Sync engine started (interval: ${config.sync.interval}ms).`);
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
log("Background sync disabled.");
|
|
82
|
-
}
|
|
83
|
-
// Phase 3: Semantic search
|
|
84
|
-
await setupDiscoverFeature(server, ctx, sync, config);
|
|
85
|
-
// 10. Register SIGINT handler
|
|
86
21
|
process.on("SIGINT", () => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
22
|
+
onSignal("SIGINT");
|
|
23
|
+
});
|
|
24
|
+
process.on("SIGTERM", () => {
|
|
25
|
+
onSignal("SIGTERM");
|
|
90
26
|
});
|
|
91
|
-
// 11. Connect stdio transport
|
|
92
|
-
log("Connecting stdio transport...");
|
|
93
|
-
await server.connect(new StdioServerTransport());
|
|
94
|
-
log("Server ready.");
|
|
95
27
|
}
|
|
96
28
|
main().catch((err) => {
|
|
97
|
-
|
|
98
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
29
|
+
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
99
30
|
process.exit(1);
|
|
100
31
|
});
|
package/dist/paprika/client.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Provides recipe and category read methods, plus write methods
|
|
8
8
|
* added in P1-U07 (saveRecipe, deleteRecipe, notifySync).
|
|
9
9
|
*/
|
|
10
|
-
import type { Category, Recipe, RecipeEntry, RecipeUid } from "./types.js";
|
|
10
|
+
import type { Category, PantryItem, Recipe, RecipeEntry, RecipeUid } from "./types.js";
|
|
11
11
|
export declare class PaprikaClient {
|
|
12
12
|
private readonly email;
|
|
13
13
|
private readonly password;
|
|
@@ -19,9 +19,12 @@ export declare class PaprikaClient {
|
|
|
19
19
|
getRecipe(uid: string): Promise<Recipe>;
|
|
20
20
|
getRecipes(uids: ReadonlyArray<string>): Promise<Array<Recipe>>;
|
|
21
21
|
listCategories(): Promise<Array<Category>>;
|
|
22
|
+
listPantry(): Promise<Array<PantryItem>>;
|
|
22
23
|
saveRecipe(recipe: Readonly<Recipe>): Promise<Recipe>;
|
|
24
|
+
savePantryItem(item: Readonly<PantryItem>): Promise<PantryItem>;
|
|
23
25
|
notifySync(): Promise<void>;
|
|
24
26
|
deleteRecipe(uid: RecipeUid): Promise<void>;
|
|
25
27
|
private buildRecipeFormData;
|
|
28
|
+
private buildPantryFormData;
|
|
26
29
|
private request;
|
|
27
30
|
}
|