@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/dist/cache/disk-cache.d.ts +21 -0
  4. package/dist/cache/disk-cache.js +252 -0
  5. package/dist/cache/recipe-store.d.ts +33 -0
  6. package/dist/cache/recipe-store.js +189 -0
  7. package/dist/features/discover-feature.d.ts +5 -0
  8. package/dist/features/discover-feature.js +39 -0
  9. package/dist/features/embedding-errors.d.ts +26 -0
  10. package/dist/features/embedding-errors.js +34 -0
  11. package/dist/features/embeddings.d.ts +70 -0
  12. package/dist/features/embeddings.js +186 -0
  13. package/dist/features/vector-store-errors.d.ts +12 -0
  14. package/dist/features/vector-store-errors.js +15 -0
  15. package/dist/features/vector-store.d.ts +63 -0
  16. package/dist/features/vector-store.js +202 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +100 -0
  19. package/dist/paprika/client.d.ts +27 -0
  20. package/dist/paprika/client.js +183 -0
  21. package/dist/paprika/errors.d.ts +37 -0
  22. package/dist/paprika/errors.js +48 -0
  23. package/dist/paprika/sync.d.ts +27 -0
  24. package/dist/paprika/sync.js +150 -0
  25. package/dist/paprika/types.d.ts +324 -0
  26. package/dist/paprika/types.js +116 -0
  27. package/dist/resources/recipes.d.ts +3 -0
  28. package/dist/resources/recipes.js +34 -0
  29. package/dist/tools/categories.d.ts +3 -0
  30. package/dist/tools/categories.js +38 -0
  31. package/dist/tools/create.d.ts +3 -0
  32. package/dist/tools/create.js +79 -0
  33. package/dist/tools/delete.d.ts +3 -0
  34. package/dist/tools/delete.js +33 -0
  35. package/dist/tools/discover.d.ts +4 -0
  36. package/dist/tools/discover.js +60 -0
  37. package/dist/tools/filter.d.ts +3 -0
  38. package/dist/tools/filter.js +101 -0
  39. package/dist/tools/helpers.d.ts +31 -0
  40. package/dist/tools/helpers.js +112 -0
  41. package/dist/tools/list.d.ts +3 -0
  42. package/dist/tools/list.js +34 -0
  43. package/dist/tools/read.d.ts +3 -0
  44. package/dist/tools/read.js +42 -0
  45. package/dist/tools/search.d.ts +3 -0
  46. package/dist/tools/search.js +46 -0
  47. package/dist/tools/update.d.ts +3 -0
  48. package/dist/tools/update.js +77 -0
  49. package/dist/types/server-context.d.ts +10 -0
  50. package/dist/types/server-context.js +1 -0
  51. package/dist/utils/config.d.ts +115 -0
  52. package/dist/utils/config.js +197 -0
  53. package/dist/utils/duration.d.ts +10 -0
  54. package/dist/utils/duration.js +86 -0
  55. package/dist/utils/xdg.d.ts +5 -0
  56. package/dist/utils/xdg.js +17 -0
  57. package/package.json +64 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * HTTP client for embedding services with OpenAI-compatible `/v1/embeddings` endpoints.
3
+ *
4
+ * Includes:
5
+ * - EmbeddingClient: cockatiel-resilient HTTP client with retry and circuit breaker
6
+ * - recipeToEmbeddingText: pure function for converting recipes to embedding text
7
+ */
8
+ import type { Recipe } from "../paprika/types.js";
9
+ import type { EmbeddingConfig } from "../utils/config.js";
10
+ /**
11
+ * HTTP client for embedding services.
12
+ *
13
+ * Uses cockatiel for resilience:
14
+ * - Retries on transient failures (429, 500, 502, 503) with exponential backoff
15
+ * - Circuit breaker opens after 5 consecutive transient failures
16
+ *
17
+ * Validates responses with Zod schemas at the boundary.
18
+ *
19
+ * Per-instance resilience stack (not shared between instances) to avoid
20
+ * circuit breaker state leaking between tests or concurrent clients.
21
+ */
22
+ export declare class EmbeddingClient {
23
+ private readonly _baseUrl;
24
+ private readonly _apiKey;
25
+ private readonly _model;
26
+ private readonly _retryPolicy;
27
+ private readonly _breakerPolicy;
28
+ private readonly _resilience;
29
+ private _dimensions;
30
+ constructor(config: Readonly<EmbeddingConfig>);
31
+ /**
32
+ * Get the dimensionality of the embedding vectors.
33
+ * Must be called after at least one successful embedding call.
34
+ *
35
+ * @throws EmbeddingError if no embedding call has been made yet
36
+ */
37
+ get dimensions(): number;
38
+ /**
39
+ * Embed multiple texts in a single batch.
40
+ * Returns an array of embedding vectors, one per input text.
41
+ *
42
+ * @param texts - Array of texts to embed
43
+ * @returns Array of embedding vectors (each is an array of numbers)
44
+ * @throws EmbeddingAPIError on permanent HTTP errors or circuit breaker open
45
+ * @throws ZodError on response validation failure
46
+ * @throws TransientHTTPError (internally caught by resilience) on transient failures
47
+ */
48
+ embedBatch(texts: ReadonlyArray<string>): Promise<Array<Array<number>>>;
49
+ /**
50
+ * Embed a single text.
51
+ * Delegates to embedBatch() and returns the first (and only) embedding.
52
+ *
53
+ * @param text - Text to embed
54
+ * @returns Single embedding vector
55
+ * @throws Same as embedBatch()
56
+ */
57
+ embed(text: string): Promise<Array<number>>;
58
+ }
59
+ /**
60
+ * Convert a recipe to text suitable for embedding.
61
+ *
62
+ * Includes recipe name, description, category names, ingredients, and notes.
63
+ * Excludes directions and nutritional info.
64
+ * Omits any null or empty fields to avoid unnecessary text.
65
+ *
66
+ * @param recipe - Recipe to convert
67
+ * @param categoryNames - Array of resolved category names
68
+ * @returns Multi-line text representation of the recipe
69
+ */
70
+ export declare function recipeToEmbeddingText(recipe: Readonly<Recipe>, categoryNames: ReadonlyArray<string>): string;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * HTTP client for embedding services with OpenAI-compatible `/v1/embeddings` endpoints.
3
+ *
4
+ * Includes:
5
+ * - EmbeddingClient: cockatiel-resilient HTTP client with retry and circuit breaker
6
+ * - recipeToEmbeddingText: pure function for converting recipes to embedding text
7
+ */
8
+ import { ExponentialBackoff, ConsecutiveBreaker, retry, circuitBreaker, handleType, wrap, BrokenCircuitError, } from "cockatiel";
9
+ import { z } from "zod";
10
+ import { EmbeddingError, EmbeddingAPIError } from "./embedding-errors.js";
11
+ /**
12
+ * Internal error class to signal transient HTTP errors for cockatiel.
13
+ * Not exported — used only within this module for resilience signaling.
14
+ */
15
+ class TransientHTTPError extends Error {
16
+ status;
17
+ constructor(status) {
18
+ super(`Transient HTTP error (${status.toString()})`);
19
+ this.status = status;
20
+ this.name = "TransientHTTPError";
21
+ }
22
+ }
23
+ /**
24
+ * HTTP status codes that should trigger retry.
25
+ */
26
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503]);
27
+ /**
28
+ * Zod schema for validating embedding API responses.
29
+ * Validates the OpenAI-compatible `/v1/embeddings` response format.
30
+ */
31
+ const EmbeddingResponseSchema = z.object({
32
+ data: z.array(z.object({
33
+ index: z.number(),
34
+ embedding: z.array(z.number()),
35
+ })),
36
+ model: z.string(),
37
+ usage: z.object({
38
+ prompt_tokens: z.number(),
39
+ total_tokens: z.number(),
40
+ }),
41
+ });
42
+ /**
43
+ * HTTP client for embedding services.
44
+ *
45
+ * Uses cockatiel for resilience:
46
+ * - Retries on transient failures (429, 500, 502, 503) with exponential backoff
47
+ * - Circuit breaker opens after 5 consecutive transient failures
48
+ *
49
+ * Validates responses with Zod schemas at the boundary.
50
+ *
51
+ * Per-instance resilience stack (not shared between instances) to avoid
52
+ * circuit breaker state leaking between tests or concurrent clients.
53
+ */
54
+ export class EmbeddingClient {
55
+ _baseUrl;
56
+ _apiKey;
57
+ _model;
58
+ _retryPolicy;
59
+ _breakerPolicy;
60
+ _resilience;
61
+ _dimensions = null;
62
+ constructor(config) {
63
+ this._baseUrl = config.baseUrl.replace(/\/+$/, "");
64
+ this._apiKey = config.apiKey;
65
+ this._model = config.model;
66
+ // Per-instance resilience stack
67
+ this._retryPolicy = retry(handleType(TransientHTTPError), {
68
+ maxAttempts: 3,
69
+ backoff: new ExponentialBackoff({
70
+ initialDelay: 500,
71
+ maxDelay: 10_000,
72
+ }),
73
+ });
74
+ this._breakerPolicy = circuitBreaker(handleType(TransientHTTPError), {
75
+ halfOpenAfter: 30_000,
76
+ breaker: new ConsecutiveBreaker(5),
77
+ });
78
+ this._resilience = wrap(this._retryPolicy, this._breakerPolicy);
79
+ }
80
+ /**
81
+ * Get the dimensionality of the embedding vectors.
82
+ * Must be called after at least one successful embedding call.
83
+ *
84
+ * @throws EmbeddingError if no embedding call has been made yet
85
+ */
86
+ get dimensions() {
87
+ if (this._dimensions === null) {
88
+ throw new EmbeddingError("Dimensions unknown: no embedding call has been made yet");
89
+ }
90
+ return this._dimensions;
91
+ }
92
+ /**
93
+ * Embed multiple texts in a single batch.
94
+ * Returns an array of embedding vectors, one per input text.
95
+ *
96
+ * @param texts - Array of texts to embed
97
+ * @returns Array of embedding vectors (each is an array of numbers)
98
+ * @throws EmbeddingAPIError on permanent HTTP errors or circuit breaker open
99
+ * @throws ZodError on response validation failure
100
+ * @throws TransientHTTPError (internally caught by resilience) on transient failures
101
+ */
102
+ async embedBatch(texts) {
103
+ const endpoint = `${this._baseUrl}/embeddings`;
104
+ const execute = async () => {
105
+ const response = await fetch(endpoint, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ Authorization: `Bearer ${this._apiKey}`,
110
+ },
111
+ body: JSON.stringify({
112
+ model: this._model,
113
+ input: texts,
114
+ }),
115
+ });
116
+ // Check for transient vs permanent errors
117
+ if (!response.ok) {
118
+ if (RETRYABLE_STATUSES.has(response.status)) {
119
+ throw new TransientHTTPError(response.status);
120
+ }
121
+ throw new EmbeddingAPIError("Embedding API error", response.status, endpoint);
122
+ }
123
+ // Parse and validate response
124
+ const json = await response.json();
125
+ const parsed = EmbeddingResponseSchema.parse(json);
126
+ // Cache dimensions from first embedding
127
+ if (parsed.data.length > 0) {
128
+ this._dimensions = parsed.data[0].embedding.length;
129
+ }
130
+ return parsed.data.map((d) => d.embedding);
131
+ };
132
+ try {
133
+ const result = await this._resilience.execute(execute);
134
+ return result;
135
+ }
136
+ catch (error) {
137
+ if (error instanceof BrokenCircuitError) {
138
+ throw new EmbeddingAPIError("Service unavailable (circuit open)", 503, endpoint);
139
+ }
140
+ throw error;
141
+ }
142
+ }
143
+ /**
144
+ * Embed a single text.
145
+ * Delegates to embedBatch() and returns the first (and only) embedding.
146
+ *
147
+ * @param text - Text to embed
148
+ * @returns Single embedding vector
149
+ * @throws Same as embedBatch()
150
+ */
151
+ async embed(text) {
152
+ const embeddings = await this.embedBatch([text]);
153
+ const first = embeddings[0];
154
+ if (first === undefined) {
155
+ throw new EmbeddingError("Empty embedding response");
156
+ }
157
+ return first;
158
+ }
159
+ }
160
+ /**
161
+ * Convert a recipe to text suitable for embedding.
162
+ *
163
+ * Includes recipe name, description, category names, ingredients, and notes.
164
+ * Excludes directions and nutritional info.
165
+ * Omits any null or empty fields to avoid unnecessary text.
166
+ *
167
+ * @param recipe - Recipe to convert
168
+ * @param categoryNames - Array of resolved category names
169
+ * @returns Multi-line text representation of the recipe
170
+ */
171
+ export function recipeToEmbeddingText(recipe, categoryNames) {
172
+ const sections = [recipe.name];
173
+ if (recipe.description) {
174
+ sections.push(`Description: ${recipe.description}`);
175
+ }
176
+ if (categoryNames.length > 0) {
177
+ sections.push(`Categories: ${categoryNames.join(", ")}`);
178
+ }
179
+ if (recipe.ingredients) {
180
+ sections.push(`Ingredients: ${recipe.ingredients}`);
181
+ }
182
+ if (recipe.notes) {
183
+ sections.push(`Notes: ${recipe.notes}`);
184
+ }
185
+ return sections.join("\n\n");
186
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Error class for vector store operations.
3
+ *
4
+ * Covers initialization failures, corruption recovery, and indexing errors.
5
+ * Supports ES2024 ErrorOptions for cause chaining.
6
+ */
7
+ /**
8
+ * Base error class for all vector store operations.
9
+ */
10
+ export declare class VectorStoreError extends Error {
11
+ constructor(message: string, options?: ErrorOptions);
12
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Error class for vector store operations.
3
+ *
4
+ * Covers initialization failures, corruption recovery, and indexing errors.
5
+ * Supports ES2024 ErrorOptions for cause chaining.
6
+ */
7
+ /**
8
+ * Base error class for all vector store operations.
9
+ */
10
+ export class VectorStoreError extends Error {
11
+ constructor(message, options) {
12
+ super(message, options);
13
+ this.name = "VectorStoreError";
14
+ }
15
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Vector store implementation using Vectra for semantic search.
3
+ *
4
+ * Provides recipe-aware vector operations with:
5
+ * - Embedding lifecycle management (when/what to embed)
6
+ * - Vector storage via Vectra LocalIndex
7
+ * - Change detection via persisted content hash map
8
+ * - Corruption recovery for both Vectra index and hash map
9
+ */
10
+ /**
11
+ * Semantic search result from the vector store.
12
+ *
13
+ * Includes the recipe UID, similarity score (0-1), and recipe name for display.
14
+ */
15
+ export type SemanticResult = {
16
+ readonly uid: string;
17
+ readonly score: number;
18
+ readonly recipeName: string;
19
+ };
20
+ /**
21
+ * Result of a batch indexing operation.
22
+ *
23
+ * Tracks how many recipes were indexed (content changed), skipped (unchanged),
24
+ * and the total count for reference.
25
+ */
26
+ export type IndexingResult = {
27
+ readonly indexed: number;
28
+ readonly skipped: number;
29
+ readonly total: number;
30
+ };
31
+ /**
32
+ * Produce a stable SHA-256 hex digest of the given text.
33
+ *
34
+ * Used to detect whether a recipe's embeddable fields have changed
35
+ * since the last indexing run. The input text is typically the output
36
+ * of `recipeToEmbeddingText()`, which includes only fields that should
37
+ * trigger re-embedding (name, description, categories, ingredients, notes)
38
+ * and excludes fields like directions and nutritional info that don't
39
+ * affect semantic search relevance.
40
+ *
41
+ * @param text The text to hash (typically embedding text)
42
+ * @returns A stable SHA-256 hex digest
43
+ */
44
+ export declare function contentHash(text: string): string;
45
+ import type { EmbeddingClient } from "./embeddings.js";
46
+ import type { Recipe, CategoryUid } from "../paprika/types.js";
47
+ export declare class VectorStore {
48
+ private readonly _vectorsDir;
49
+ private readonly _hashIndexPath;
50
+ private readonly _index;
51
+ private readonly _embedder;
52
+ private _hashes;
53
+ constructor(cacheDir: string, embedder: EmbeddingClient);
54
+ init(): Promise<void>;
55
+ private _loadHashIndex;
56
+ private _backupFile;
57
+ indexRecipes(recipes: ReadonlyArray<Recipe>, resolveCats: (uids: ReadonlyArray<CategoryUid>) => ReadonlyArray<string>): Promise<IndexingResult>;
58
+ indexRecipe(recipe: Readonly<Recipe>, categoryNames: ReadonlyArray<string>): Promise<IndexingResult>;
59
+ get size(): number;
60
+ private _persistHashes;
61
+ search(query: string, topK?: number): Promise<ReadonlyArray<SemanticResult>>;
62
+ removeRecipe(uid: string): Promise<void>;
63
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Vector store implementation using Vectra for semantic search.
3
+ *
4
+ * Provides recipe-aware vector operations with:
5
+ * - Embedding lifecycle management (when/what to embed)
6
+ * - Vector storage via Vectra LocalIndex
7
+ * - Change detection via persisted content hash map
8
+ * - Corruption recovery for both Vectra index and hash map
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ /**
12
+ * Produce a stable SHA-256 hex digest of the given text.
13
+ *
14
+ * Used to detect whether a recipe's embeddable fields have changed
15
+ * since the last indexing run. The input text is typically the output
16
+ * of `recipeToEmbeddingText()`, which includes only fields that should
17
+ * trigger re-embedding (name, description, categories, ingredients, notes)
18
+ * and excludes fields like directions and nutritional info that don't
19
+ * affect semantic search relevance.
20
+ *
21
+ * @param text The text to hash (typically embedding text)
22
+ * @returns A stable SHA-256 hex digest
23
+ */
24
+ export function contentHash(text) {
25
+ return createHash("sha256").update(text).digest("hex");
26
+ }
27
+ import { mkdir, readFile, rename, cp, open } from "node:fs/promises";
28
+ import { join } from "node:path";
29
+ import { z } from "zod";
30
+ import { LocalIndex } from "vectra";
31
+ import { recipeToEmbeddingText } from "./embeddings.js";
32
+ import { VectorStoreError } from "./vector-store-errors.js";
33
+ const HashIndexSchema = z.record(z.string(), z.string());
34
+ /** Maximum number of texts to embed in a single batch call. */
35
+ const BATCH_SIZE = 500;
36
+ function isNodeError(error) {
37
+ return error instanceof Error && "code" in error;
38
+ }
39
+ function log(msg) {
40
+ process.stderr.write(`[mcp-paprika:vectors] ${msg}\n`);
41
+ }
42
+ export class VectorStore {
43
+ _vectorsDir;
44
+ _hashIndexPath;
45
+ _index;
46
+ _embedder;
47
+ _hashes = {};
48
+ constructor(cacheDir, embedder) {
49
+ this._vectorsDir = join(cacheDir, "vectors");
50
+ this._hashIndexPath = join(this._vectorsDir, "hash-index.json");
51
+ this._index = new LocalIndex(this._vectorsDir);
52
+ this._embedder = embedder;
53
+ }
54
+ async init() {
55
+ await mkdir(this._vectorsDir, { recursive: true });
56
+ // Create or open Vectra index, with corruption recovery (AC1.4)
57
+ try {
58
+ const created = await this._index.isIndexCreated();
59
+ if (!created) {
60
+ await this._index.createIndex();
61
+ }
62
+ }
63
+ catch {
64
+ log("corrupt Vectra index, backing up and recreating");
65
+ const backupDir = `${this._vectorsDir}.bak`;
66
+ await cp(this._vectorsDir, backupDir, { recursive: true, force: true });
67
+ await this._index.createIndex({ version: 1, deleteIfExists: true });
68
+ this._hashes = {};
69
+ return; // Skip loading hash index — just cleared everything
70
+ }
71
+ // Load hash map — follows DiskCache pattern (disk-cache.ts:60-88)
72
+ await this._loadHashIndex();
73
+ }
74
+ async _loadHashIndex() {
75
+ let raw;
76
+ try {
77
+ raw = await readFile(this._hashIndexPath, "utf-8");
78
+ }
79
+ catch (error) {
80
+ if (isNodeError(error) && error.code === "ENOENT") {
81
+ this._hashes = {};
82
+ return;
83
+ }
84
+ throw error;
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ }
90
+ catch {
91
+ log("corrupt hash-index.json (invalid JSON), backing up and resetting");
92
+ await this._backupFile(this._hashIndexPath, `${this._hashIndexPath}.bak`);
93
+ this._hashes = {};
94
+ return;
95
+ }
96
+ const result = HashIndexSchema.safeParse(parsed);
97
+ if (!result.success) {
98
+ log("corrupt hash-index.json (schema mismatch), backing up and resetting");
99
+ await this._backupFile(this._hashIndexPath, `${this._hashIndexPath}.bak`);
100
+ this._hashes = {};
101
+ return;
102
+ }
103
+ this._hashes = result.data;
104
+ }
105
+ async _backupFile(src, dest) {
106
+ try {
107
+ await rename(src, dest);
108
+ }
109
+ catch (error) {
110
+ if (!isNodeError(error) || error.code !== "ENOENT") {
111
+ throw error;
112
+ }
113
+ }
114
+ }
115
+ async indexRecipes(recipes, resolveCats) {
116
+ if (recipes.length === 0) {
117
+ return { indexed: 0, skipped: 0, total: 0 };
118
+ }
119
+ // Compute embedding texts and hashes, filter unchanged
120
+ const toEmbed = [];
121
+ let skipped = 0;
122
+ for (const recipe of recipes) {
123
+ const cats = resolveCats(recipe.categories);
124
+ const text = recipeToEmbeddingText(recipe, cats);
125
+ const hash = contentHash(text);
126
+ if (this._hashes[recipe.uid] === hash) {
127
+ skipped++;
128
+ continue;
129
+ }
130
+ toEmbed.push({ recipe, text, hash });
131
+ }
132
+ if (toEmbed.length === 0) {
133
+ return { indexed: 0, skipped, total: recipes.length };
134
+ }
135
+ // Batch embed in chunks of BATCH_SIZE to avoid API limits on large collections
136
+ const allVectors = [];
137
+ for (let offset = 0; offset < toEmbed.length; offset += BATCH_SIZE) {
138
+ const chunk = toEmbed.slice(offset, offset + BATCH_SIZE);
139
+ const vectors = await this._embedder.embedBatch(chunk.map((e) => e.text));
140
+ allVectors.push(...vectors);
141
+ }
142
+ // Upsert into Vectra
143
+ await this._index.beginUpdate();
144
+ try {
145
+ for (let i = 0; i < toEmbed.length; i++) {
146
+ const entry = toEmbed[i];
147
+ await this._index.upsertItem({
148
+ id: entry.recipe.uid,
149
+ vector: allVectors[i],
150
+ metadata: { recipeName: entry.recipe.name },
151
+ });
152
+ }
153
+ await this._index.endUpdate();
154
+ }
155
+ catch (error) {
156
+ this._index.cancelUpdate();
157
+ throw new VectorStoreError("Failed to upsert items into vector index", {
158
+ cause: error instanceof Error ? error : undefined,
159
+ });
160
+ }
161
+ // Update hash map
162
+ for (const entry of toEmbed) {
163
+ this._hashes[entry.recipe.uid] = entry.hash;
164
+ }
165
+ await this._persistHashes();
166
+ return { indexed: toEmbed.length, skipped, total: recipes.length };
167
+ }
168
+ async indexRecipe(recipe, categoryNames) {
169
+ return this.indexRecipes([recipe], () => [...categoryNames]);
170
+ }
171
+ get size() {
172
+ return Object.keys(this._hashes).length;
173
+ }
174
+ async _persistHashes() {
175
+ const tmpPath = join(this._vectorsDir, `.hash-index-${Date.now().toString()}.tmp`);
176
+ const fh = await open(tmpPath, "w");
177
+ try {
178
+ await fh.writeFile(JSON.stringify(this._hashes, null, 2));
179
+ await fh.sync();
180
+ }
181
+ finally {
182
+ await fh.close();
183
+ }
184
+ await rename(tmpPath, this._hashIndexPath);
185
+ }
186
+ async search(query, topK = 10) {
187
+ const vector = await this._embedder.embed(query);
188
+ const results = await this._index.queryItems(vector, topK);
189
+ return results.map((r) => ({
190
+ uid: r.item.id,
191
+ score: r.score,
192
+ recipeName: r.item.metadata?.["recipeName"] ?? "",
193
+ }));
194
+ }
195
+ async removeRecipe(uid) {
196
+ await this._index.deleteItem(uid);
197
+ if (uid in this._hashes) {
198
+ delete this._hashes[uid];
199
+ await this._persistHashes();
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,100 @@
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";
8
+ 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
+ function log(msg) {
21
+ process.stderr.write(`[mcp-paprika] ${msg}\n`);
22
+ }
23
+ async function main() {
24
+ // 1. Load and validate config
25
+ log("Loading configuration...");
26
+ const configResult = loadConfig();
27
+ const config = configResult.match((cfg) => cfg, (err) => {
28
+ throw err;
29
+ });
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,
57
+ };
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
+ process.on("SIGINT", () => {
87
+ log("SIGINT received, shutting down...");
88
+ sync.stop();
89
+ process.exit(0);
90
+ });
91
+ // 11. Connect stdio transport
92
+ log("Connecting stdio transport...");
93
+ await server.connect(new StdioServerTransport());
94
+ log("Server ready.");
95
+ }
96
+ main().catch((err) => {
97
+ /* oxlint-disable-next-line no-console */
98
+ console.error(err instanceof Error ? err.message : String(err));
99
+ process.exit(1);
100
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Typed HTTP client for the Paprika Cloud Sync API.
3
+ *
4
+ * Encapsulates authentication against the v1 login endpoint
5
+ * and resilient request execution against the v2 data endpoint.
6
+ *
7
+ * Provides recipe and category read methods, plus write methods
8
+ * added in P1-U07 (saveRecipe, deleteRecipe, notifySync).
9
+ */
10
+ import type { Category, Recipe, RecipeEntry, RecipeUid } from "./types.js";
11
+ export declare class PaprikaClient {
12
+ private readonly email;
13
+ private readonly password;
14
+ private token;
15
+ private readonly _recipesBulkhead;
16
+ constructor(email: string, password: string);
17
+ authenticate(): Promise<void>;
18
+ listRecipes(): Promise<Array<RecipeEntry>>;
19
+ getRecipe(uid: string): Promise<Recipe>;
20
+ getRecipes(uids: ReadonlyArray<string>): Promise<Array<Recipe>>;
21
+ listCategories(): Promise<Array<Category>>;
22
+ saveRecipe(recipe: Readonly<Recipe>): Promise<Recipe>;
23
+ notifySync(): Promise<void>;
24
+ deleteRecipe(uid: RecipeUid): Promise<void>;
25
+ private buildRecipeFormData;
26
+ private request;
27
+ }