@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,197 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import dotenv from "dotenv";
5
+ import { ok, err } from "neverthrow";
6
+ import { parseDuration } from "./duration.js";
7
+ import { getConfigDir } from "./xdg.js";
8
+ const ENV_VAR_HINTS = {
9
+ "paprika.email": "PAPRIKA_EMAIL",
10
+ "paprika.password": "PAPRIKA_PASSWORD",
11
+ "sync.interval": "PAPRIKA_SYNC_INTERVAL",
12
+ "sync.enabled": "PAPRIKA_SYNC_ENABLED",
13
+ "features.replicateApiToken": "REPLICATE_API_TOKEN",
14
+ "features.embeddings.apiKey": "OPENAI_API_KEY",
15
+ "features.embeddings.baseUrl": "OPENAI_BASE_URL",
16
+ "features.embeddings.model": "EMBEDDING_MODEL",
17
+ };
18
+ export class ConfigError extends Error {
19
+ reason;
20
+ kind;
21
+ constructor(reason, kind) {
22
+ super(reason);
23
+ this.name = "ConfigError";
24
+ this.reason = reason;
25
+ this.kind = kind;
26
+ }
27
+ static invalidJson(path, cause) {
28
+ const detail = cause instanceof Error ? cause.message : String(cause);
29
+ return new ConfigError(`Invalid JSON in ${path}: ${detail}`, "invalid_json");
30
+ }
31
+ static fileReadError(path, cause) {
32
+ const detail = cause instanceof Error ? cause.message : String(cause);
33
+ return new ConfigError(`Cannot read ${path}: ${detail}`, "file_read_error");
34
+ }
35
+ static validation(issues) {
36
+ const lines = issues.map((issue) => {
37
+ const path = issue.path.join(".");
38
+ const hint = ENV_VAR_HINTS[path];
39
+ const suffix = hint ? ` (set via ${hint})` : "";
40
+ return ` - ${path}: ${issue.message}${suffix}`;
41
+ });
42
+ const reason = `Configuration validation failed:\n${lines.join("\n")}`;
43
+ return new ConfigError(reason, "validation");
44
+ }
45
+ }
46
+ const durationField = z.union([z.string(), z.number()]).transform((val, ctx) => {
47
+ return parseDuration(val).match((duration) => duration.as("milliseconds"), (parseErr) => {
48
+ ctx.addIssue({
49
+ code: z.ZodIssueCode.custom,
50
+ message: parseErr.reason,
51
+ });
52
+ return z.NEVER;
53
+ });
54
+ });
55
+ const BOOLEAN_STRINGS = {
56
+ true: true,
57
+ false: false,
58
+ "1": true,
59
+ "0": false,
60
+ };
61
+ const booleanField = z.union([z.boolean(), z.string()]).transform((val, ctx) => {
62
+ if (typeof val === "boolean") {
63
+ return val;
64
+ }
65
+ const mapped = BOOLEAN_STRINGS[val];
66
+ if (mapped === undefined) {
67
+ ctx.addIssue({
68
+ code: z.ZodIssueCode.custom,
69
+ message: `expected "true", "false", "1", or "0", got ${JSON.stringify(val)}`,
70
+ });
71
+ return z.NEVER;
72
+ }
73
+ return mapped;
74
+ });
75
+ const embeddingConfigSchema = z.object({
76
+ apiKey: z.string().min(1),
77
+ baseUrl: z.string().min(1),
78
+ model: z.string().min(1),
79
+ });
80
+ export const paprikaConfigSchema = z.object({
81
+ paprika: z.preprocess((val) => val ?? {}, z.object({
82
+ email: z.string().min(1),
83
+ password: z.string().min(1),
84
+ })),
85
+ sync: z
86
+ .object({
87
+ enabled: booleanField.default(true),
88
+ interval: durationField.default("15m"),
89
+ })
90
+ .default({}),
91
+ features: z
92
+ .object({
93
+ replicateApiToken: z.string().min(1).optional(),
94
+ embeddings: embeddingConfigSchema.optional(),
95
+ })
96
+ .optional(),
97
+ });
98
+ // Type guard for NodeJS.ErrnoException
99
+ function isNodeError(error) {
100
+ return error instanceof Error && "code" in error;
101
+ }
102
+ // Reads config.json from configDir. ENOENT returns ok({}). Invalid JSON and permission errors return err.
103
+ function readConfigFile(configDir) {
104
+ const filePath = join(configDir, "config.json");
105
+ let content;
106
+ try {
107
+ content = readFileSync(filePath, "utf-8");
108
+ }
109
+ catch (error) {
110
+ if (isNodeError(error) && error.code === "ENOENT") {
111
+ return ok({});
112
+ }
113
+ return err(ConfigError.fileReadError(filePath, error));
114
+ }
115
+ try {
116
+ const parsed = JSON.parse(content);
117
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
118
+ return err(ConfigError.invalidJson(filePath, new Error("expected a JSON object")));
119
+ }
120
+ return ok(parsed);
121
+ }
122
+ catch (error) {
123
+ return err(ConfigError.invalidJson(filePath, error));
124
+ }
125
+ }
126
+ // Loads .env file from configDir into process.env. Missing .env is silently ignored.
127
+ function loadDotEnv(configDir) {
128
+ dotenv.config({ path: join(configDir, ".env") });
129
+ }
130
+ // Maps known env vars to the nested config object structure.
131
+ function buildEnvOverrides(env) {
132
+ const overrides = {};
133
+ const paprika = {};
134
+ const sync = {};
135
+ const features = {};
136
+ const embeddings = {};
137
+ if (env["PAPRIKA_EMAIL"] !== undefined)
138
+ paprika["email"] = env["PAPRIKA_EMAIL"];
139
+ if (env["PAPRIKA_PASSWORD"] !== undefined)
140
+ paprika["password"] = env["PAPRIKA_PASSWORD"];
141
+ if (env["PAPRIKA_SYNC_INTERVAL"] !== undefined)
142
+ sync["interval"] = env["PAPRIKA_SYNC_INTERVAL"];
143
+ if (env["PAPRIKA_SYNC_ENABLED"] !== undefined)
144
+ sync["enabled"] = env["PAPRIKA_SYNC_ENABLED"];
145
+ if (env["REPLICATE_API_TOKEN"] !== undefined)
146
+ features["replicateApiToken"] = env["REPLICATE_API_TOKEN"];
147
+ if (env["OPENAI_API_KEY"] !== undefined)
148
+ embeddings["apiKey"] = env["OPENAI_API_KEY"];
149
+ if (env["OPENAI_BASE_URL"] !== undefined)
150
+ embeddings["baseUrl"] = env["OPENAI_BASE_URL"];
151
+ if (env["EMBEDDING_MODEL"] !== undefined)
152
+ embeddings["model"] = env["EMBEDDING_MODEL"];
153
+ if (Object.keys(embeddings).length > 0)
154
+ features["embeddings"] = embeddings;
155
+ if (Object.keys(features).length > 0)
156
+ overrides["features"] = features;
157
+ if (Object.keys(paprika).length > 0)
158
+ overrides["paprika"] = paprika;
159
+ if (Object.keys(sync).length > 0)
160
+ overrides["sync"] = sync;
161
+ return overrides;
162
+ }
163
+ // Recursively merges base config with overrides. Override values win for non-object fields.
164
+ /** @internal Pure helper for config merging. Exported for property-based testing only. */
165
+ export function deepMerge(base, overrides) {
166
+ const result = { ...base };
167
+ for (const key of Object.keys(overrides)) {
168
+ const baseVal = base[key];
169
+ const overVal = overrides[key];
170
+ if (typeof baseVal === "object" &&
171
+ baseVal !== null &&
172
+ !Array.isArray(baseVal) &&
173
+ typeof overVal === "object" &&
174
+ overVal !== null &&
175
+ !Array.isArray(overVal)) {
176
+ result[key] = deepMerge(baseVal, overVal);
177
+ }
178
+ else {
179
+ result[key] = overVal;
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+ // Orchestrates the full config loading pipeline. Accepts optional configDir for testability.
185
+ export function loadConfig(configDir) {
186
+ const dir = configDir ?? getConfigDir();
187
+ loadDotEnv(dir);
188
+ return readConfigFile(dir).andThen((fileConfig) => {
189
+ const envOverrides = buildEnvOverrides(process.env);
190
+ const merged = deepMerge(fileConfig, envOverrides);
191
+ const parseResult = paprikaConfigSchema.safeParse(merged);
192
+ if (!parseResult.success) {
193
+ return err(ConfigError.validation(parseResult.error.issues));
194
+ }
195
+ return ok(parseResult.data);
196
+ });
197
+ }
@@ -0,0 +1,10 @@
1
+ import { Duration } from "luxon";
2
+ import { type Result } from "neverthrow";
3
+ export declare class DurationParseError extends Error {
4
+ readonly input: string | number;
5
+ readonly reason: string;
6
+ private constructor();
7
+ static fromInput(input: string | number, reason: string): DurationParseError;
8
+ }
9
+ export declare function parseDuration(input: string | number): Result<Duration, DurationParseError>;
10
+ export declare function formatDuration(duration: Duration): string;
@@ -0,0 +1,86 @@
1
+ import { Duration } from "luxon";
2
+ import { ok, err } from "neverthrow";
3
+ import parseDurationLib from "parse-duration";
4
+ export class DurationParseError extends Error {
5
+ input;
6
+ reason;
7
+ constructor(input, reason) {
8
+ super(`Invalid duration ${JSON.stringify(input)}: ${reason}`);
9
+ this.name = "DurationParseError";
10
+ this.input = input;
11
+ this.reason = reason;
12
+ }
13
+ static fromInput(input, reason) {
14
+ return new DurationParseError(input, reason);
15
+ }
16
+ }
17
+ function numericParser(input) {
18
+ if (!/^\d+(\.\d+)?$/.test(input)) {
19
+ return null;
20
+ }
21
+ const minutes = Number(input);
22
+ return ok(Duration.fromObject({ minutes }));
23
+ }
24
+ function colonParser(input) {
25
+ const match = /^(\d+):(\d{1,2})$/.exec(input);
26
+ if (!match) {
27
+ return null;
28
+ }
29
+ const hours = Number(match[1]);
30
+ const minutes = Number(match[2]);
31
+ if (minutes >= 60) {
32
+ return err(DurationParseError.fromInput(input, "minutes must be less than 60"));
33
+ }
34
+ return ok(Duration.fromObject({ hours, minutes }));
35
+ }
36
+ function humanAndIsoParser(input) {
37
+ const ms = parseDurationLib(input);
38
+ if (ms === null || ms === undefined) {
39
+ return null;
40
+ }
41
+ if (ms < 0) {
42
+ return err(DurationParseError.fromInput(input, "negative duration"));
43
+ }
44
+ return ok(Duration.fromMillis(ms));
45
+ }
46
+ export function parseDuration(input) {
47
+ if (typeof input === "number") {
48
+ if (!Number.isFinite(input)) {
49
+ return err(DurationParseError.fromInput(input, "input must be a finite number"));
50
+ }
51
+ if (input < 0) {
52
+ return err(DurationParseError.fromInput(input, "negative duration"));
53
+ }
54
+ return ok(Duration.fromObject({ minutes: input }));
55
+ }
56
+ const trimmed = input.trim();
57
+ if (trimmed === "") {
58
+ return err(DurationParseError.fromInput(input, "empty input"));
59
+ }
60
+ const parsers = [numericParser, colonParser, humanAndIsoParser];
61
+ for (const parser of parsers) {
62
+ const result = parser(trimmed);
63
+ if (result !== null) {
64
+ return result;
65
+ }
66
+ }
67
+ return err(DurationParseError.fromInput(input, "unrecognized duration format"));
68
+ }
69
+ export function formatDuration(duration) {
70
+ if (!duration.isValid) {
71
+ return "";
72
+ }
73
+ const totalMinutes = Math.round(duration.as("minutes"));
74
+ if (totalMinutes <= 0) {
75
+ return "";
76
+ }
77
+ const hours = Math.floor(totalMinutes / 60);
78
+ const minutes = totalMinutes % 60;
79
+ if (hours > 0 && minutes > 0) {
80
+ return `${String(hours)} hr ${String(minutes)} min`;
81
+ }
82
+ if (hours > 0) {
83
+ return `${String(hours)} hr`;
84
+ }
85
+ return `${String(minutes)} min`;
86
+ }
@@ -0,0 +1,5 @@
1
+ export declare function getConfigDir(): string;
2
+ export declare function getCacheDir(): string;
3
+ export declare function getDataDir(): string;
4
+ export declare function getLogDir(): string;
5
+ export declare function getTempDir(): string;
@@ -0,0 +1,17 @@
1
+ import envPaths from "env-paths";
2
+ const paths = envPaths("mcp-paprika", { suffix: "" });
3
+ export function getConfigDir() {
4
+ return paths.config;
5
+ }
6
+ export function getCacheDir() {
7
+ return paths.cache;
8
+ }
9
+ export function getDataDir() {
10
+ return paths.data;
11
+ }
12
+ export function getLogDir() {
13
+ return paths.log;
14
+ }
15
+ export function getTempDir() {
16
+ return paths.temp;
17
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@bojanrajkovic/mcp-paprika",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Paprika recipe manager",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "mcp-paprika": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "type": "module",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "dev": "tsx src/index.ts",
21
+ "format": "oxfmt --write .",
22
+ "format:check": "oxfmt --check .",
23
+ "lint": "oxlint --deny-warnings src/",
24
+ "lint:fix": "oxlint --fix src/",
25
+ "prepare": "lefthook install",
26
+ "test": "vitest run --passWithNoTests",
27
+ "test:watch": "vitest",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.27.1",
32
+ "cockatiel": "^3.2.1",
33
+ "dotenv": "^17.3.1",
34
+ "env-paths": "^4.0.0",
35
+ "luxon": "^3.7.2",
36
+ "mitt": "^3.0.1",
37
+ "neverthrow": "^8.2.0",
38
+ "parse-duration": "^2.1.5",
39
+ "vectra": "^0.12.3",
40
+ "zod": "^3.25.76"
41
+ },
42
+ "devDependencies": {
43
+ "@commitlint/cli": "^20.5.0",
44
+ "@commitlint/config-conventional": "^20.5.0",
45
+ "@tsconfig/node24": "^24.0.4",
46
+ "@tsconfig/strictest": "^2.0.8",
47
+ "@types/luxon": "^3.7.1",
48
+ "@types/node": "^24.12.0",
49
+ "@vitest/coverage-v8": "^4.1.0",
50
+ "fast-check": "^4.6.0",
51
+ "lefthook": "^2.1.4",
52
+ "msw": "^2.12.14",
53
+ "oxfmt": "^0.41.0",
54
+ "oxlint": "^1.56.0",
55
+ "tsx": "^4.21",
56
+ "type-fest": "^5.5.0",
57
+ "typescript": "^5.9.3",
58
+ "vitest": "^4.1.0"
59
+ },
60
+ "engines": {
61
+ "node": ">=24.14.0"
62
+ },
63
+ "packageManager": "pnpm@10.32.1"
64
+ }