@mhalder/qdrant-mcp-server 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 (100) hide show
  1. package/.env.example +92 -0
  2. package/.github/workflows/ci.yml +61 -0
  3. package/.github/workflows/claude-code-review.yml +57 -0
  4. package/.github/workflows/claude.yml +50 -0
  5. package/.github/workflows/release.yml +52 -0
  6. package/.husky/commit-msg +1 -0
  7. package/.husky/pre-commit +1 -0
  8. package/.releaserc.json +59 -0
  9. package/.yamlfmt +4 -0
  10. package/CHANGELOG.md +73 -0
  11. package/CONTRIBUTING.md +176 -0
  12. package/LICENSE +21 -0
  13. package/README.md +714 -0
  14. package/build/embeddings/base.d.ts +23 -0
  15. package/build/embeddings/base.d.ts.map +1 -0
  16. package/build/embeddings/base.js +2 -0
  17. package/build/embeddings/base.js.map +1 -0
  18. package/build/embeddings/cohere.d.ts +17 -0
  19. package/build/embeddings/cohere.d.ts.map +1 -0
  20. package/build/embeddings/cohere.js +102 -0
  21. package/build/embeddings/cohere.js.map +1 -0
  22. package/build/embeddings/cohere.test.d.ts +2 -0
  23. package/build/embeddings/cohere.test.d.ts.map +1 -0
  24. package/build/embeddings/cohere.test.js +279 -0
  25. package/build/embeddings/cohere.test.js.map +1 -0
  26. package/build/embeddings/factory.d.ts +10 -0
  27. package/build/embeddings/factory.d.ts.map +1 -0
  28. package/build/embeddings/factory.js +98 -0
  29. package/build/embeddings/factory.js.map +1 -0
  30. package/build/embeddings/factory.test.d.ts +2 -0
  31. package/build/embeddings/factory.test.d.ts.map +1 -0
  32. package/build/embeddings/factory.test.js +329 -0
  33. package/build/embeddings/factory.test.js.map +1 -0
  34. package/build/embeddings/ollama.d.ts +18 -0
  35. package/build/embeddings/ollama.d.ts.map +1 -0
  36. package/build/embeddings/ollama.js +135 -0
  37. package/build/embeddings/ollama.js.map +1 -0
  38. package/build/embeddings/ollama.test.d.ts +2 -0
  39. package/build/embeddings/ollama.test.d.ts.map +1 -0
  40. package/build/embeddings/ollama.test.js +399 -0
  41. package/build/embeddings/ollama.test.js.map +1 -0
  42. package/build/embeddings/openai.d.ts +16 -0
  43. package/build/embeddings/openai.d.ts.map +1 -0
  44. package/build/embeddings/openai.js +108 -0
  45. package/build/embeddings/openai.js.map +1 -0
  46. package/build/embeddings/openai.test.d.ts +2 -0
  47. package/build/embeddings/openai.test.d.ts.map +1 -0
  48. package/build/embeddings/openai.test.js +283 -0
  49. package/build/embeddings/openai.test.js.map +1 -0
  50. package/build/embeddings/voyage.d.ts +19 -0
  51. package/build/embeddings/voyage.d.ts.map +1 -0
  52. package/build/embeddings/voyage.js +113 -0
  53. package/build/embeddings/voyage.js.map +1 -0
  54. package/build/embeddings/voyage.test.d.ts +2 -0
  55. package/build/embeddings/voyage.test.d.ts.map +1 -0
  56. package/build/embeddings/voyage.test.js +371 -0
  57. package/build/embeddings/voyage.test.js.map +1 -0
  58. package/build/index.d.ts +3 -0
  59. package/build/index.d.ts.map +1 -0
  60. package/build/index.js +534 -0
  61. package/build/index.js.map +1 -0
  62. package/build/index.test.d.ts +2 -0
  63. package/build/index.test.d.ts.map +1 -0
  64. package/build/index.test.js +241 -0
  65. package/build/index.test.js.map +1 -0
  66. package/build/qdrant/client.d.ts +37 -0
  67. package/build/qdrant/client.d.ts.map +1 -0
  68. package/build/qdrant/client.js +142 -0
  69. package/build/qdrant/client.js.map +1 -0
  70. package/build/qdrant/client.test.d.ts +2 -0
  71. package/build/qdrant/client.test.d.ts.map +1 -0
  72. package/build/qdrant/client.test.js +340 -0
  73. package/build/qdrant/client.test.js.map +1 -0
  74. package/commitlint.config.js +25 -0
  75. package/docker-compose.yml +22 -0
  76. package/docs/test_report.md +259 -0
  77. package/examples/README.md +315 -0
  78. package/examples/basic/README.md +111 -0
  79. package/examples/filters/README.md +262 -0
  80. package/examples/knowledge-base/README.md +207 -0
  81. package/examples/rate-limiting/README.md +376 -0
  82. package/package.json +59 -0
  83. package/scripts/verify-providers.js +238 -0
  84. package/src/embeddings/base.ts +25 -0
  85. package/src/embeddings/cohere.test.ts +408 -0
  86. package/src/embeddings/cohere.ts +152 -0
  87. package/src/embeddings/factory.test.ts +453 -0
  88. package/src/embeddings/factory.ts +163 -0
  89. package/src/embeddings/ollama.test.ts +543 -0
  90. package/src/embeddings/ollama.ts +196 -0
  91. package/src/embeddings/openai.test.ts +402 -0
  92. package/src/embeddings/openai.ts +158 -0
  93. package/src/embeddings/voyage.test.ts +520 -0
  94. package/src/embeddings/voyage.ts +168 -0
  95. package/src/index.test.ts +304 -0
  96. package/src/index.ts +614 -0
  97. package/src/qdrant/client.test.ts +456 -0
  98. package/src/qdrant/client.ts +195 -0
  99. package/tsconfig.json +19 -0
  100. package/vitest.config.ts +37 -0
package/src/index.ts ADDED
@@ -0,0 +1,614 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListResourcesRequestSchema,
8
+ ListToolsRequestSchema,
9
+ ReadResourceRequestSchema,
10
+ } from "@modelcontextprotocol/sdk/types.js";
11
+ import { QdrantManager } from "./qdrant/client.js";
12
+ import { EmbeddingProviderFactory } from "./embeddings/factory.js";
13
+ import { z } from "zod";
14
+ import { readFileSync } from "fs";
15
+ import { fileURLToPath } from "url";
16
+ import { dirname, join } from "path";
17
+
18
+ // Read package.json for version
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(
21
+ readFileSync(join(__dirname, "../package.json"), "utf-8"),
22
+ );
23
+
24
+ // Validate environment variables
25
+ const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
26
+ const EMBEDDING_PROVIDER = (
27
+ process.env.EMBEDDING_PROVIDER || "ollama"
28
+ ).toLowerCase();
29
+
30
+ // Check for required API keys based on provider
31
+ if (EMBEDDING_PROVIDER !== "ollama") {
32
+ let apiKey: string | undefined;
33
+ let requiredKeyName: string;
34
+
35
+ switch (EMBEDDING_PROVIDER) {
36
+ case "openai":
37
+ apiKey = process.env.OPENAI_API_KEY;
38
+ requiredKeyName = "OPENAI_API_KEY";
39
+ break;
40
+ case "cohere":
41
+ apiKey = process.env.COHERE_API_KEY;
42
+ requiredKeyName = "COHERE_API_KEY";
43
+ break;
44
+ case "voyage":
45
+ apiKey = process.env.VOYAGE_API_KEY;
46
+ requiredKeyName = "VOYAGE_API_KEY";
47
+ break;
48
+ default:
49
+ console.error(
50
+ `Error: Unknown embedding provider "${EMBEDDING_PROVIDER}". Supported providers: openai, cohere, voyage, ollama.`,
51
+ );
52
+ process.exit(1);
53
+ }
54
+
55
+ if (!apiKey) {
56
+ console.error(
57
+ `Error: ${requiredKeyName} is required for ${EMBEDDING_PROVIDER} provider.`,
58
+ );
59
+ process.exit(1);
60
+ }
61
+ }
62
+
63
+ // Check if Ollama is running when using Ollama provider
64
+ async function checkOllamaAvailability() {
65
+ if (EMBEDDING_PROVIDER === "ollama") {
66
+ const baseUrl = process.env.EMBEDDING_BASE_URL || "http://localhost:11434";
67
+ const isLocalhost =
68
+ baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1");
69
+
70
+ try {
71
+ const response = await fetch(`${baseUrl}/api/version`);
72
+ if (!response.ok) {
73
+ throw new Error(`Ollama returned status ${response.status}`);
74
+ }
75
+
76
+ // Check if the required embedding model exists
77
+ const tagsResponse = await fetch(`${baseUrl}/api/tags`);
78
+ const { models } = await tagsResponse.json();
79
+ const modelName = process.env.EMBEDDING_MODEL || "nomic-embed-text";
80
+ const modelExists = models.some(
81
+ (m: any) => m.name === modelName || m.name.startsWith(`${modelName}:`),
82
+ );
83
+
84
+ if (!modelExists) {
85
+ let errorMessage = `Error: Model '${modelName}' not found in Ollama.\n`;
86
+
87
+ if (isLocalhost) {
88
+ errorMessage +=
89
+ `Pull it with:\n` +
90
+ ` - Using Docker: docker exec ollama ollama pull ${modelName}\n` +
91
+ ` - Or locally: ollama pull ${modelName}`;
92
+ } else {
93
+ errorMessage +=
94
+ `Please ensure the model is available on your Ollama instance:\n` +
95
+ ` ollama pull ${modelName}`;
96
+ }
97
+
98
+ console.error(errorMessage);
99
+ process.exit(1);
100
+ }
101
+ } catch (error) {
102
+ const errorMessage =
103
+ error instanceof Error
104
+ ? `Error: ${error.message}`
105
+ : `Error: Ollama is not running at ${baseUrl}.\n`;
106
+
107
+ let helpText = "";
108
+ if (isLocalhost) {
109
+ helpText =
110
+ `Please start Ollama:\n` +
111
+ ` - Using Docker: docker compose up -d\n` +
112
+ ` - Or install locally: curl -fsSL https://ollama.ai/install.sh | sh\n` +
113
+ `\nThen pull the embedding model:\n` +
114
+ ` ollama pull nomic-embed-text`;
115
+ } else {
116
+ helpText =
117
+ `Please ensure:\n` +
118
+ ` - Ollama is running at the specified URL\n` +
119
+ ` - The URL is accessible from this machine\n` +
120
+ ` - The embedding model is available (e.g., nomic-embed-text)`;
121
+ }
122
+
123
+ console.error(`${errorMessage}\n${helpText}`);
124
+ process.exit(1);
125
+ }
126
+ }
127
+ }
128
+
129
+ // Initialize clients
130
+ const qdrant = new QdrantManager(QDRANT_URL);
131
+ const embeddings = EmbeddingProviderFactory.createFromEnv();
132
+
133
+ // Create MCP server
134
+ const server = new Server(
135
+ {
136
+ name: pkg.name,
137
+ version: pkg.version,
138
+ },
139
+ {
140
+ capabilities: {
141
+ tools: {},
142
+ resources: {},
143
+ },
144
+ },
145
+ );
146
+
147
+ // Tool schemas
148
+ const CreateCollectionSchema = z.object({
149
+ name: z.string().describe("Name of the collection"),
150
+ distance: z
151
+ .enum(["Cosine", "Euclid", "Dot"])
152
+ .optional()
153
+ .describe("Distance metric (default: Cosine)"),
154
+ });
155
+
156
+ const AddDocumentsSchema = z.object({
157
+ collection: z.string().describe("Name of the collection"),
158
+ documents: z
159
+ .array(
160
+ z.object({
161
+ id: z
162
+ .union([z.string(), z.number()])
163
+ .describe("Unique identifier for the document"),
164
+ text: z.string().describe("Text content to embed and store"),
165
+ metadata: z
166
+ .record(z.any())
167
+ .optional()
168
+ .describe("Optional metadata to store with the document"),
169
+ }),
170
+ )
171
+ .describe("Array of documents to add"),
172
+ });
173
+
174
+ const SemanticSearchSchema = z.object({
175
+ collection: z.string().describe("Name of the collection to search"),
176
+ query: z.string().describe("Search query text"),
177
+ limit: z
178
+ .number()
179
+ .optional()
180
+ .describe("Maximum number of results (default: 5)"),
181
+ filter: z.record(z.any()).optional().describe("Optional metadata filter"),
182
+ });
183
+
184
+ const DeleteCollectionSchema = z.object({
185
+ name: z.string().describe("Name of the collection to delete"),
186
+ });
187
+
188
+ const GetCollectionInfoSchema = z.object({
189
+ name: z.string().describe("Name of the collection"),
190
+ });
191
+
192
+ const DeleteDocumentsSchema = z.object({
193
+ collection: z.string().describe("Name of the collection"),
194
+ ids: z
195
+ .array(z.union([z.string(), z.number()]))
196
+ .describe("Array of document IDs to delete"),
197
+ });
198
+
199
+ // List available tools
200
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
201
+ return {
202
+ tools: [
203
+ {
204
+ name: "create_collection",
205
+ description:
206
+ "Create a new vector collection in Qdrant. The collection will be configured with the embedding provider's dimensions automatically.",
207
+ inputSchema: {
208
+ type: "object",
209
+ properties: {
210
+ name: {
211
+ type: "string",
212
+ description: "Name of the collection",
213
+ },
214
+ distance: {
215
+ type: "string",
216
+ enum: ["Cosine", "Euclid", "Dot"],
217
+ description: "Distance metric (default: Cosine)",
218
+ },
219
+ },
220
+ required: ["name"],
221
+ },
222
+ },
223
+ {
224
+ name: "add_documents",
225
+ description:
226
+ "Add documents to a collection. Documents will be automatically embedded using the configured embedding provider.",
227
+ inputSchema: {
228
+ type: "object",
229
+ properties: {
230
+ collection: {
231
+ type: "string",
232
+ description: "Name of the collection",
233
+ },
234
+ documents: {
235
+ type: "array",
236
+ description: "Array of documents to add",
237
+ items: {
238
+ type: "object",
239
+ properties: {
240
+ id: {
241
+ type: ["string", "number"],
242
+ description: "Unique identifier for the document",
243
+ },
244
+ text: {
245
+ type: "string",
246
+ description: "Text content to embed and store",
247
+ },
248
+ metadata: {
249
+ type: "object",
250
+ description: "Optional metadata to store with the document",
251
+ },
252
+ },
253
+ required: ["id", "text"],
254
+ },
255
+ },
256
+ },
257
+ required: ["collection", "documents"],
258
+ },
259
+ },
260
+ {
261
+ name: "semantic_search",
262
+ description:
263
+ "Search for documents using natural language queries. Returns the most semantically similar documents.",
264
+ inputSchema: {
265
+ type: "object",
266
+ properties: {
267
+ collection: {
268
+ type: "string",
269
+ description: "Name of the collection to search",
270
+ },
271
+ query: {
272
+ type: "string",
273
+ description: "Search query text",
274
+ },
275
+ limit: {
276
+ type: "number",
277
+ description: "Maximum number of results (default: 5)",
278
+ },
279
+ filter: {
280
+ type: "object",
281
+ description: "Optional metadata filter",
282
+ },
283
+ },
284
+ required: ["collection", "query"],
285
+ },
286
+ },
287
+ {
288
+ name: "list_collections",
289
+ description: "List all available collections in Qdrant.",
290
+ inputSchema: {
291
+ type: "object",
292
+ properties: {},
293
+ },
294
+ },
295
+ {
296
+ name: "delete_collection",
297
+ description: "Delete a collection and all its documents.",
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ name: {
302
+ type: "string",
303
+ description: "Name of the collection to delete",
304
+ },
305
+ },
306
+ required: ["name"],
307
+ },
308
+ },
309
+ {
310
+ name: "get_collection_info",
311
+ description:
312
+ "Get detailed information about a collection including vector size, point count, and distance metric.",
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ name: {
317
+ type: "string",
318
+ description: "Name of the collection",
319
+ },
320
+ },
321
+ required: ["name"],
322
+ },
323
+ },
324
+ {
325
+ name: "delete_documents",
326
+ description:
327
+ "Delete specific documents from a collection by their IDs.",
328
+ inputSchema: {
329
+ type: "object",
330
+ properties: {
331
+ collection: {
332
+ type: "string",
333
+ description: "Name of the collection",
334
+ },
335
+ ids: {
336
+ type: "array",
337
+ description: "Array of document IDs to delete",
338
+ items: {
339
+ type: ["string", "number"],
340
+ },
341
+ },
342
+ },
343
+ required: ["collection", "ids"],
344
+ },
345
+ },
346
+ ],
347
+ };
348
+ });
349
+
350
+ // Handle tool calls
351
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
352
+ const { name, arguments: args } = request.params;
353
+
354
+ try {
355
+ switch (name) {
356
+ case "create_collection": {
357
+ const { name, distance } = CreateCollectionSchema.parse(args);
358
+ const vectorSize = embeddings.getDimensions();
359
+ await qdrant.createCollection(name, vectorSize, distance);
360
+ return {
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`,
365
+ },
366
+ ],
367
+ };
368
+ }
369
+
370
+ case "add_documents": {
371
+ const { collection, documents } = AddDocumentsSchema.parse(args);
372
+
373
+ // Check if collection exists
374
+ const exists = await qdrant.collectionExists(collection);
375
+ if (!exists) {
376
+ return {
377
+ content: [
378
+ {
379
+ type: "text",
380
+ text: `Error: Collection "${collection}" does not exist. Create it first using create_collection.`,
381
+ },
382
+ ],
383
+ isError: true,
384
+ };
385
+ }
386
+
387
+ // Generate embeddings for all documents
388
+ const texts = documents.map((doc) => doc.text);
389
+ const embeddingResults = await embeddings.embedBatch(texts);
390
+
391
+ // Prepare points for insertion
392
+ const points = documents.map((doc, index) => ({
393
+ id: doc.id,
394
+ vector: embeddingResults[index].embedding,
395
+ payload: {
396
+ text: doc.text,
397
+ ...doc.metadata,
398
+ },
399
+ }));
400
+
401
+ await qdrant.addPoints(collection, points);
402
+
403
+ return {
404
+ content: [
405
+ {
406
+ type: "text",
407
+ text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
408
+ },
409
+ ],
410
+ };
411
+ }
412
+
413
+ case "semantic_search": {
414
+ const { collection, query, limit, filter } =
415
+ SemanticSearchSchema.parse(args);
416
+
417
+ // Check if collection exists
418
+ const exists = await qdrant.collectionExists(collection);
419
+ if (!exists) {
420
+ return {
421
+ content: [
422
+ {
423
+ type: "text",
424
+ text: `Error: Collection "${collection}" does not exist.`,
425
+ },
426
+ ],
427
+ isError: true,
428
+ };
429
+ }
430
+
431
+ // Generate embedding for query
432
+ const { embedding } = await embeddings.embed(query);
433
+
434
+ // Search
435
+ const results = await qdrant.search(
436
+ collection,
437
+ embedding,
438
+ limit || 5,
439
+ filter,
440
+ );
441
+
442
+ return {
443
+ content: [
444
+ {
445
+ type: "text",
446
+ text: JSON.stringify(results, null, 2),
447
+ },
448
+ ],
449
+ };
450
+ }
451
+
452
+ case "list_collections": {
453
+ const collections = await qdrant.listCollections();
454
+ return {
455
+ content: [
456
+ {
457
+ type: "text",
458
+ text: JSON.stringify(collections, null, 2),
459
+ },
460
+ ],
461
+ };
462
+ }
463
+
464
+ case "delete_collection": {
465
+ const { name } = DeleteCollectionSchema.parse(args);
466
+ await qdrant.deleteCollection(name);
467
+ return {
468
+ content: [
469
+ {
470
+ type: "text",
471
+ text: `Collection "${name}" deleted successfully.`,
472
+ },
473
+ ],
474
+ };
475
+ }
476
+
477
+ case "get_collection_info": {
478
+ const { name } = GetCollectionInfoSchema.parse(args);
479
+ const info = await qdrant.getCollectionInfo(name);
480
+ return {
481
+ content: [
482
+ {
483
+ type: "text",
484
+ text: JSON.stringify(info, null, 2),
485
+ },
486
+ ],
487
+ };
488
+ }
489
+
490
+ case "delete_documents": {
491
+ const { collection, ids } = DeleteDocumentsSchema.parse(args);
492
+ await qdrant.deletePoints(collection, ids);
493
+ return {
494
+ content: [
495
+ {
496
+ type: "text",
497
+ text: `Successfully deleted ${ids.length} document(s) from collection "${collection}".`,
498
+ },
499
+ ],
500
+ };
501
+ }
502
+
503
+ default:
504
+ return {
505
+ content: [
506
+ {
507
+ type: "text",
508
+ text: `Unknown tool: ${name}`,
509
+ },
510
+ ],
511
+ isError: true,
512
+ };
513
+ }
514
+ } catch (error: any) {
515
+ // Enhanced error details for debugging
516
+ const errorDetails =
517
+ error instanceof Error ? error.message : JSON.stringify(error, null, 2);
518
+
519
+ console.error("Tool execution error:", {
520
+ tool: name,
521
+ error: errorDetails,
522
+ stack: error?.stack,
523
+ data: error?.data,
524
+ });
525
+
526
+ return {
527
+ content: [
528
+ {
529
+ type: "text",
530
+ text: `Error: ${errorDetails}`,
531
+ },
532
+ ],
533
+ isError: true,
534
+ };
535
+ }
536
+ });
537
+
538
+ // List available resources
539
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
540
+ const collections = await qdrant.listCollections();
541
+
542
+ return {
543
+ resources: [
544
+ {
545
+ uri: "qdrant://collections",
546
+ name: "All Collections",
547
+ description: "List of all vector collections in Qdrant",
548
+ mimeType: "application/json",
549
+ },
550
+ ...collections.map((name) => ({
551
+ uri: `qdrant://collection/${name}`,
552
+ name: `Collection: ${name}`,
553
+ description: `Details and statistics for collection "${name}"`,
554
+ mimeType: "application/json",
555
+ })),
556
+ ],
557
+ };
558
+ });
559
+
560
+ // Read resource content
561
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
562
+ const { uri } = request.params;
563
+
564
+ if (uri === "qdrant://collections") {
565
+ const collections = await qdrant.listCollections();
566
+ return {
567
+ contents: [
568
+ {
569
+ uri,
570
+ mimeType: "application/json",
571
+ text: JSON.stringify(collections, null, 2),
572
+ },
573
+ ],
574
+ };
575
+ }
576
+
577
+ const collectionMatch = uri.match(/^qdrant:\/\/collection\/(.+)$/);
578
+ if (collectionMatch) {
579
+ const name = collectionMatch[1];
580
+ const info = await qdrant.getCollectionInfo(name);
581
+ return {
582
+ contents: [
583
+ {
584
+ uri,
585
+ mimeType: "application/json",
586
+ text: JSON.stringify(info, null, 2),
587
+ },
588
+ ],
589
+ };
590
+ }
591
+
592
+ return {
593
+ contents: [
594
+ {
595
+ uri,
596
+ mimeType: "text/plain",
597
+ text: `Unknown resource: ${uri}`,
598
+ },
599
+ ],
600
+ };
601
+ });
602
+
603
+ // Start server
604
+ async function main() {
605
+ await checkOllamaAvailability();
606
+ const transport = new StdioServerTransport();
607
+ await server.connect(transport);
608
+ console.error("Qdrant MCP server running on stdio");
609
+ }
610
+
611
+ main().catch((error) => {
612
+ console.error("Fatal error:", error);
613
+ process.exit(1);
614
+ });