@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.
Files changed (46) hide show
  1. package/README.md +103 -7
  2. package/dist/cache/disk-cache.d.ts +6 -1
  3. package/dist/cache/disk-cache.js +70 -4
  4. package/dist/cache/pantry-store.d.ts +20 -0
  5. package/dist/cache/pantry-store.js +81 -0
  6. package/dist/features/discover-feature.d.ts +28 -4
  7. package/dist/features/discover-feature.js +25 -13
  8. package/dist/index.js +15 -84
  9. package/dist/paprika/client.d.ts +4 -1
  10. package/dist/paprika/client.js +41 -1
  11. package/dist/paprika/dates.d.ts +25 -0
  12. package/dist/paprika/dates.js +57 -0
  13. package/dist/paprika/sync.d.ts +2 -2
  14. package/dist/paprika/sync.js +55 -24
  15. package/dist/paprika/types.d.ts +119 -10
  16. package/dist/paprika/types.js +64 -4
  17. package/dist/resources/pantry.d.ts +3 -0
  18. package/dist/resources/pantry.js +32 -0
  19. package/dist/server/app-context.d.ts +32 -0
  20. package/dist/server/app-context.js +1 -0
  21. package/dist/server/build.d.ts +45 -0
  22. package/dist/server/build.js +164 -0
  23. package/dist/server/notifier.d.ts +47 -0
  24. package/dist/server/notifier.js +76 -0
  25. package/dist/tools/helpers.d.ts +1 -1
  26. package/dist/tools/helpers.js +2 -2
  27. package/dist/tools/pantry-add.d.ts +3 -0
  28. package/dist/tools/pantry-add.js +70 -0
  29. package/dist/tools/pantry-delete.d.ts +3 -0
  30. package/dist/tools/pantry-delete.js +44 -0
  31. package/dist/tools/pantry-get.d.ts +3 -0
  32. package/dist/tools/pantry-get.js +39 -0
  33. package/dist/tools/pantry-helpers.d.ts +20 -0
  34. package/dist/tools/pantry-helpers.js +62 -0
  35. package/dist/tools/pantry-list.d.ts +3 -0
  36. package/dist/tools/pantry-list.js +23 -0
  37. package/dist/tools/pantry-update.d.ts +3 -0
  38. package/dist/tools/pantry-update.js +71 -0
  39. package/dist/transport/http.d.ts +29 -0
  40. package/dist/transport/http.js +146 -0
  41. package/dist/transport/stdio.d.ts +10 -0
  42. package/dist/transport/stdio.js +56 -0
  43. package/dist/types/server-context.d.ts +16 -10
  44. package/dist/utils/config.d.ts +21 -0
  45. package/dist/utils/config.js +23 -0
  46. 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
- - **10 tools** for recipe management — search, filter, CRUD, categories, pagination
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
- ## Quick start
14
+ ## Transports
13
15
 
14
- Add to your MCP client config (e.g. Claude Desktop):
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
- See [configuration](docs/configuration.md) for all available options including background sync and semantic search.
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/)** — all 10 tools with parameters and examples
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;
@@ -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 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";
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
- export declare function setupDiscoverFeature(server: McpServer, ctx: ServerContext, sync: SyncEngine, config: PaprikaConfig): Promise<void>;
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
- export async function setupDiscoverFeature(server, ctx, sync, config) {
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
- registerDiscoverTool(server, ctx, vectorStore);
15
- // Cold-start initial indexing: the initial sync.syncOnce() in index.ts fires
16
- // sync:complete BEFORE this subscription exists. Re-index all recipes when
17
- // the vector store is empty or significantly out of sync with the recipe
18
- // store (e.g. stale test data, orphaned entries from a prior crash, or
19
- // a model/dimension change that invalidated the old vectors).
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(ctx.store.getAll(), (uids) => ctx.store.resolveCategories(uids));
33
+ await vectorStore.indexRecipes(store.getAll(), (uids) => store.resolveCategories(uids));
23
34
  }
24
- sync.events.on("sync:complete", async (result) => {
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) => ctx.store.resolveCategories(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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
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 configResult = loadConfig();
27
- const config = configResult.match((cfg) => cfg, (err) => {
10
+ const config = loadConfig().match((cfg) => cfg, (err) => {
28
11
  throw err;
29
12
  });
30
- // 2. Construct PaprikaClient and authenticate
31
- log("Authenticating with Paprika...");
32
- const client = new PaprikaClient(config.paprika.email, config.paprika.password);
33
- await client.authenticate();
34
- log("Authenticated successfully.");
35
- // 3. Construct DiskCache and initialize
36
- log("Initializing disk cache...");
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
- log("SIGINT received, shutting down...");
88
- sync.stop();
89
- process.exit(0);
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
- /* oxlint-disable-next-line no-console */
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
  });
@@ -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
  }