@mhalder/qdrant-mcp-server 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -139,584 +139,596 @@ async function checkOllamaAvailability() {
139
139
  const qdrant = new QdrantManager(QDRANT_URL);
140
140
  const embeddings = EmbeddingProviderFactory.createFromEnv();
141
141
 
142
- // Create MCP server
143
- const server = new Server(
144
- {
145
- name: pkg.name,
146
- version: pkg.version,
147
- },
148
- {
149
- capabilities: {
150
- tools: {},
151
- resources: {},
142
+ // Function to create a new MCP server instance
143
+ // This is needed for HTTP transport in stateless mode where each request gets its own server
144
+ function createServer() {
145
+ return new Server(
146
+ {
147
+ name: pkg.name,
148
+ version: pkg.version,
152
149
  },
153
- }
154
- );
155
-
156
- // Tool schemas
157
- const CreateCollectionSchema = z.object({
158
- name: z.string().describe("Name of the collection"),
159
- distance: z
160
- .enum(["Cosine", "Euclid", "Dot"])
161
- .optional()
162
- .describe("Distance metric (default: Cosine)"),
163
- enableHybrid: z
164
- .boolean()
165
- .optional()
166
- .describe("Enable hybrid search with sparse vectors (default: false)"),
167
- });
168
-
169
- const AddDocumentsSchema = z.object({
170
- collection: z.string().describe("Name of the collection"),
171
- documents: z
172
- .array(
173
- z.object({
174
- id: z.union([z.string(), z.number()]).describe("Unique identifier for the document"),
175
- text: z.string().describe("Text content to embed and store"),
176
- metadata: z
177
- .record(z.any())
178
- .optional()
179
- .describe("Optional metadata to store with the document"),
180
- })
181
- )
182
- .describe("Array of documents to add"),
183
- });
184
-
185
- const SemanticSearchSchema = z.object({
186
- collection: z.string().describe("Name of the collection to search"),
187
- query: z.string().describe("Search query text"),
188
- limit: z.number().optional().describe("Maximum number of results (default: 5)"),
189
- filter: z.record(z.any()).optional().describe("Optional metadata filter"),
190
- });
191
-
192
- const DeleteCollectionSchema = z.object({
193
- name: z.string().describe("Name of the collection to delete"),
194
- });
195
-
196
- const GetCollectionInfoSchema = z.object({
197
- name: z.string().describe("Name of the collection"),
198
- });
199
-
200
- const DeleteDocumentsSchema = z.object({
201
- collection: z.string().describe("Name of the collection"),
202
- ids: z.array(z.union([z.string(), z.number()])).describe("Array of document IDs to delete"),
203
- });
150
+ {
151
+ capabilities: {
152
+ tools: {},
153
+ resources: {},
154
+ },
155
+ }
156
+ );
157
+ }
204
158
 
205
- const HybridSearchSchema = z.object({
206
- collection: z.string().describe("Name of the collection to search"),
207
- query: z.string().describe("Search query text"),
208
- limit: z.number().optional().describe("Maximum number of results (default: 5)"),
209
- filter: z.record(z.any()).optional().describe("Optional metadata filter"),
210
- });
159
+ // Create a shared MCP server for stdio mode
160
+ const server = createServer();
211
161
 
212
- // List available tools
213
- server.setRequestHandler(ListToolsRequestSchema, async () => {
214
- return {
215
- tools: [
216
- {
217
- name: "create_collection",
218
- description:
219
- "Create a new vector collection in Qdrant. The collection will be configured with the embedding provider's dimensions automatically. Set enableHybrid to true to enable hybrid search combining semantic and keyword search.",
220
- inputSchema: {
221
- type: "object",
222
- properties: {
223
- name: {
224
- type: "string",
225
- description: "Name of the collection",
226
- },
227
- distance: {
228
- type: "string",
229
- enum: ["Cosine", "Euclid", "Dot"],
230
- description: "Distance metric (default: Cosine)",
231
- },
232
- enableHybrid: {
233
- type: "boolean",
234
- description: "Enable hybrid search with sparse vectors (default: false)",
162
+ // Function to register all handlers on a server instance
163
+ function registerHandlers(server: Server) {
164
+ // List available tools
165
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
166
+ return {
167
+ tools: [
168
+ {
169
+ name: "create_collection",
170
+ description:
171
+ "Create a new vector collection in Qdrant. The collection will be configured with the embedding provider's dimensions automatically. Set enableHybrid to true to enable hybrid search combining semantic and keyword search.",
172
+ inputSchema: {
173
+ type: "object",
174
+ properties: {
175
+ name: {
176
+ type: "string",
177
+ description: "Name of the collection",
178
+ },
179
+ distance: {
180
+ type: "string",
181
+ enum: ["Cosine", "Euclid", "Dot"],
182
+ description: "Distance metric (default: Cosine)",
183
+ },
184
+ enableHybrid: {
185
+ type: "boolean",
186
+ description: "Enable hybrid search with sparse vectors (default: false)",
187
+ },
235
188
  },
189
+ required: ["name"],
236
190
  },
237
- required: ["name"],
238
191
  },
239
- },
240
- {
241
- name: "add_documents",
242
- description:
243
- "Add documents to a collection. Documents will be automatically embedded using the configured embedding provider.",
244
- inputSchema: {
245
- type: "object",
246
- properties: {
247
- collection: {
248
- type: "string",
249
- description: "Name of the collection",
250
- },
251
- documents: {
252
- type: "array",
253
- description: "Array of documents to add",
254
- items: {
255
- type: "object",
256
- properties: {
257
- id: {
258
- type: ["string", "number"],
259
- description: "Unique identifier for the document",
260
- },
261
- text: {
262
- type: "string",
263
- description: "Text content to embed and store",
264
- },
265
- metadata: {
266
- type: "object",
267
- description: "Optional metadata to store with the document",
192
+ {
193
+ name: "add_documents",
194
+ description:
195
+ "Add documents to a collection. Documents will be automatically embedded using the configured embedding provider.",
196
+ inputSchema: {
197
+ type: "object",
198
+ properties: {
199
+ collection: {
200
+ type: "string",
201
+ description: "Name of the collection",
202
+ },
203
+ documents: {
204
+ type: "array",
205
+ description: "Array of documents to add",
206
+ items: {
207
+ type: "object",
208
+ properties: {
209
+ id: {
210
+ type: ["string", "number"],
211
+ description: "Unique identifier for the document",
212
+ },
213
+ text: {
214
+ type: "string",
215
+ description: "Text content to embed and store",
216
+ },
217
+ metadata: {
218
+ type: "object",
219
+ description: "Optional metadata to store with the document",
220
+ },
268
221
  },
222
+ required: ["id", "text"],
269
223
  },
270
- required: ["id", "text"],
271
224
  },
272
225
  },
226
+ required: ["collection", "documents"],
273
227
  },
274
- required: ["collection", "documents"],
275
228
  },
276
- },
277
- {
278
- name: "semantic_search",
279
- description:
280
- "Search for documents using natural language queries. Returns the most semantically similar documents.",
281
- inputSchema: {
282
- type: "object",
283
- properties: {
284
- collection: {
285
- type: "string",
286
- description: "Name of the collection to search",
287
- },
288
- query: {
289
- type: "string",
290
- description: "Search query text",
291
- },
292
- limit: {
293
- type: "number",
294
- description: "Maximum number of results (default: 5)",
295
- },
296
- filter: {
297
- type: "object",
298
- description: "Optional metadata filter",
229
+ {
230
+ name: "semantic_search",
231
+ description:
232
+ "Search for documents using natural language queries. Returns the most semantically similar documents.",
233
+ inputSchema: {
234
+ type: "object",
235
+ properties: {
236
+ collection: {
237
+ type: "string",
238
+ description: "Name of the collection to search",
239
+ },
240
+ query: {
241
+ type: "string",
242
+ description: "Search query text",
243
+ },
244
+ limit: {
245
+ type: "number",
246
+ description: "Maximum number of results (default: 5)",
247
+ },
248
+ filter: {
249
+ type: "object",
250
+ description: "Optional metadata filter",
251
+ },
299
252
  },
253
+ required: ["collection", "query"],
300
254
  },
301
- required: ["collection", "query"],
302
255
  },
303
- },
304
- {
305
- name: "list_collections",
306
- description: "List all available collections in Qdrant.",
307
- inputSchema: {
308
- type: "object",
309
- properties: {},
256
+ {
257
+ name: "list_collections",
258
+ description: "List all available collections in Qdrant.",
259
+ inputSchema: {
260
+ type: "object",
261
+ properties: {},
262
+ },
310
263
  },
311
- },
312
- {
313
- name: "delete_collection",
314
- description: "Delete a collection and all its documents.",
315
- inputSchema: {
316
- type: "object",
317
- properties: {
318
- name: {
319
- type: "string",
320
- description: "Name of the collection to delete",
264
+ {
265
+ name: "delete_collection",
266
+ description: "Delete a collection and all its documents.",
267
+ inputSchema: {
268
+ type: "object",
269
+ properties: {
270
+ name: {
271
+ type: "string",
272
+ description: "Name of the collection to delete",
273
+ },
321
274
  },
275
+ required: ["name"],
322
276
  },
323
- required: ["name"],
324
277
  },
325
- },
326
- {
327
- name: "get_collection_info",
328
- description:
329
- "Get detailed information about a collection including vector size, point count, and distance metric.",
330
- inputSchema: {
331
- type: "object",
332
- properties: {
333
- name: {
334
- type: "string",
335
- description: "Name of the collection",
278
+ {
279
+ name: "get_collection_info",
280
+ description:
281
+ "Get detailed information about a collection including vector size, point count, and distance metric.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ name: {
286
+ type: "string",
287
+ description: "Name of the collection",
288
+ },
336
289
  },
290
+ required: ["name"],
337
291
  },
338
- required: ["name"],
339
292
  },
340
- },
341
- {
342
- name: "delete_documents",
343
- description: "Delete specific documents from a collection by their IDs.",
344
- inputSchema: {
345
- type: "object",
346
- properties: {
347
- collection: {
348
- type: "string",
349
- description: "Name of the collection",
350
- },
351
- ids: {
352
- type: "array",
353
- description: "Array of document IDs to delete",
354
- items: {
355
- type: ["string", "number"],
293
+ {
294
+ name: "delete_documents",
295
+ description: "Delete specific documents from a collection by their IDs.",
296
+ inputSchema: {
297
+ type: "object",
298
+ properties: {
299
+ collection: {
300
+ type: "string",
301
+ description: "Name of the collection",
302
+ },
303
+ ids: {
304
+ type: "array",
305
+ description: "Array of document IDs to delete",
306
+ items: {
307
+ type: ["string", "number"],
308
+ },
356
309
  },
357
310
  },
311
+ required: ["collection", "ids"],
358
312
  },
359
- required: ["collection", "ids"],
360
313
  },
361
- },
362
- {
363
- name: "hybrid_search",
364
- description:
365
- "Perform hybrid search combining semantic vector search with keyword search using BM25. This provides better results by combining the strengths of both approaches. The collection must be created with enableHybrid set to true.",
366
- inputSchema: {
367
- type: "object",
368
- properties: {
369
- collection: {
370
- type: "string",
371
- description: "Name of the collection to search",
372
- },
373
- query: {
374
- type: "string",
375
- description: "Search query text",
376
- },
377
- limit: {
378
- type: "number",
379
- description: "Maximum number of results (default: 5)",
380
- },
381
- filter: {
382
- type: "object",
383
- description: "Optional metadata filter",
314
+ {
315
+ name: "hybrid_search",
316
+ description:
317
+ "Perform hybrid search combining semantic vector search with keyword search using BM25. This provides better results by combining the strengths of both approaches. The collection must be created with enableHybrid set to true.",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {
321
+ collection: {
322
+ type: "string",
323
+ description: "Name of the collection to search",
324
+ },
325
+ query: {
326
+ type: "string",
327
+ description: "Search query text",
328
+ },
329
+ limit: {
330
+ type: "number",
331
+ description: "Maximum number of results (default: 5)",
332
+ },
333
+ filter: {
334
+ type: "object",
335
+ description: "Optional metadata filter",
336
+ },
384
337
  },
338
+ required: ["collection", "query"],
385
339
  },
386
- required: ["collection", "query"],
387
340
  },
388
- },
389
- ],
390
- };
391
- });
341
+ ],
342
+ };
343
+ });
344
+
345
+ // Handle tool calls
346
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
347
+ const { name, arguments: args } = request.params;
392
348
 
393
- // Handle tool calls
394
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
395
- const { name, arguments: args } = request.params;
349
+ try {
350
+ switch (name) {
351
+ case "create_collection": {
352
+ const { name, distance, enableHybrid } = CreateCollectionSchema.parse(args);
353
+ const vectorSize = embeddings.getDimensions();
354
+ await qdrant.createCollection(name, vectorSize, distance, enableHybrid || false);
396
355
 
397
- try {
398
- switch (name) {
399
- case "create_collection": {
400
- const { name, distance, enableHybrid } = CreateCollectionSchema.parse(args);
401
- const vectorSize = embeddings.getDimensions();
402
- await qdrant.createCollection(name, vectorSize, distance, enableHybrid || false);
356
+ let message = `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`;
357
+ if (enableHybrid) {
358
+ message += " Hybrid search is enabled for this collection.";
359
+ }
403
360
 
404
- let message = `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`;
405
- if (enableHybrid) {
406
- message += " Hybrid search is enabled for this collection.";
361
+ return {
362
+ content: [
363
+ {
364
+ type: "text",
365
+ text: message,
366
+ },
367
+ ],
368
+ };
407
369
  }
408
370
 
409
- return {
410
- content: [
411
- {
412
- type: "text",
413
- text: message,
414
- },
415
- ],
416
- };
417
- }
371
+ case "add_documents": {
372
+ const { collection, documents } = AddDocumentsSchema.parse(args);
373
+
374
+ // Check if collection exists and get info
375
+ const exists = await qdrant.collectionExists(collection);
376
+ if (!exists) {
377
+ return {
378
+ content: [
379
+ {
380
+ type: "text",
381
+ text: `Error: Collection "${collection}" does not exist. Create it first using create_collection.`,
382
+ },
383
+ ],
384
+ isError: true,
385
+ };
386
+ }
387
+
388
+ const collectionInfo = await qdrant.getCollectionInfo(collection);
389
+
390
+ // Generate embeddings for all documents
391
+ const texts = documents.map((doc) => doc.text);
392
+ const embeddingResults = await embeddings.embedBatch(texts);
393
+
394
+ // If hybrid search is enabled, generate sparse vectors and use appropriate method
395
+ if (collectionInfo.hybridEnabled) {
396
+ const sparseGenerator = new BM25SparseVectorGenerator();
397
+
398
+ // Prepare points with both dense and sparse vectors
399
+ const points = documents.map((doc, index) => ({
400
+ id: doc.id,
401
+ vector: embeddingResults[index].embedding,
402
+ sparseVector: sparseGenerator.generate(doc.text),
403
+ payload: {
404
+ text: doc.text,
405
+ ...doc.metadata,
406
+ },
407
+ }));
408
+
409
+ await qdrant.addPointsWithSparse(collection, points);
410
+ } else {
411
+ // Standard dense-only vectors
412
+ const points = documents.map((doc, index) => ({
413
+ id: doc.id,
414
+ vector: embeddingResults[index].embedding,
415
+ payload: {
416
+ text: doc.text,
417
+ ...doc.metadata,
418
+ },
419
+ }));
418
420
 
419
- case "add_documents": {
420
- const { collection, documents } = AddDocumentsSchema.parse(args);
421
+ await qdrant.addPoints(collection, points);
422
+ }
421
423
 
422
- // Check if collection exists and get info
423
- const exists = await qdrant.collectionExists(collection);
424
- if (!exists) {
425
424
  return {
426
425
  content: [
427
426
  {
428
427
  type: "text",
429
- text: `Error: Collection "${collection}" does not exist. Create it first using create_collection.`,
428
+ text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
430
429
  },
431
430
  ],
432
- isError: true,
433
431
  };
434
432
  }
435
433
 
436
- const collectionInfo = await qdrant.getCollectionInfo(collection);
437
-
438
- // Generate embeddings for all documents
439
- const texts = documents.map((doc) => doc.text);
440
- const embeddingResults = await embeddings.embedBatch(texts);
441
-
442
- // If hybrid search is enabled, generate sparse vectors and use appropriate method
443
- if (collectionInfo.hybridEnabled) {
444
- const sparseGenerator = new BM25SparseVectorGenerator();
434
+ case "semantic_search": {
435
+ const { collection, query, limit, filter } = SemanticSearchSchema.parse(args);
436
+
437
+ // Check if collection exists
438
+ const exists = await qdrant.collectionExists(collection);
439
+ if (!exists) {
440
+ return {
441
+ content: [
442
+ {
443
+ type: "text",
444
+ text: `Error: Collection "${collection}" does not exist.`,
445
+ },
446
+ ],
447
+ isError: true,
448
+ };
449
+ }
445
450
 
446
- // Prepare points with both dense and sparse vectors
447
- const points = documents.map((doc, index) => ({
448
- id: doc.id,
449
- vector: embeddingResults[index].embedding,
450
- sparseVector: sparseGenerator.generate(doc.text),
451
- payload: {
452
- text: doc.text,
453
- ...doc.metadata,
454
- },
455
- }));
451
+ // Generate embedding for query
452
+ const { embedding } = await embeddings.embed(query);
456
453
 
457
- await qdrant.addPointsWithSparse(collection, points);
458
- } else {
459
- // Standard dense-only vectors
460
- const points = documents.map((doc, index) => ({
461
- id: doc.id,
462
- vector: embeddingResults[index].embedding,
463
- payload: {
464
- text: doc.text,
465
- ...doc.metadata,
466
- },
467
- }));
454
+ // Search
455
+ const results = await qdrant.search(collection, embedding, limit || 5, filter);
468
456
 
469
- await qdrant.addPoints(collection, points);
457
+ return {
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: JSON.stringify(results, null, 2),
462
+ },
463
+ ],
464
+ };
470
465
  }
471
466
 
472
- return {
473
- content: [
474
- {
475
- type: "text",
476
- text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
477
- },
478
- ],
479
- };
480
- }
481
-
482
- case "semantic_search": {
483
- const { collection, query, limit, filter } = SemanticSearchSchema.parse(args);
484
-
485
- // Check if collection exists
486
- const exists = await qdrant.collectionExists(collection);
487
- if (!exists) {
467
+ case "list_collections": {
468
+ const collections = await qdrant.listCollections();
488
469
  return {
489
470
  content: [
490
471
  {
491
472
  type: "text",
492
- text: `Error: Collection "${collection}" does not exist.`,
473
+ text: JSON.stringify(collections, null, 2),
493
474
  },
494
475
  ],
495
- isError: true,
496
476
  };
497
477
  }
498
478
 
499
- // Generate embedding for query
500
- const { embedding } = await embeddings.embed(query);
501
-
502
- // Search
503
- const results = await qdrant.search(collection, embedding, limit || 5, filter);
479
+ case "delete_collection": {
480
+ const { name } = DeleteCollectionSchema.parse(args);
481
+ await qdrant.deleteCollection(name);
482
+ return {
483
+ content: [
484
+ {
485
+ type: "text",
486
+ text: `Collection "${name}" deleted successfully.`,
487
+ },
488
+ ],
489
+ };
490
+ }
504
491
 
505
- return {
506
- content: [
507
- {
508
- type: "text",
509
- text: JSON.stringify(results, null, 2),
510
- },
511
- ],
512
- };
513
- }
492
+ case "get_collection_info": {
493
+ const { name } = GetCollectionInfoSchema.parse(args);
494
+ const info = await qdrant.getCollectionInfo(name);
495
+ return {
496
+ content: [
497
+ {
498
+ type: "text",
499
+ text: JSON.stringify(info, null, 2),
500
+ },
501
+ ],
502
+ };
503
+ }
514
504
 
515
- case "list_collections": {
516
- const collections = await qdrant.listCollections();
517
- return {
518
- content: [
519
- {
520
- type: "text",
521
- text: JSON.stringify(collections, null, 2),
522
- },
523
- ],
524
- };
525
- }
505
+ case "delete_documents": {
506
+ const { collection, ids } = DeleteDocumentsSchema.parse(args);
507
+ await qdrant.deletePoints(collection, ids);
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: `Successfully deleted ${ids.length} document(s) from collection "${collection}".`,
513
+ },
514
+ ],
515
+ };
516
+ }
526
517
 
527
- case "delete_collection": {
528
- const { name } = DeleteCollectionSchema.parse(args);
529
- await qdrant.deleteCollection(name);
530
- return {
531
- content: [
532
- {
533
- type: "text",
534
- text: `Collection "${name}" deleted successfully.`,
535
- },
536
- ],
537
- };
538
- }
518
+ case "hybrid_search": {
519
+ const { collection, query, limit, filter } = HybridSearchSchema.parse(args);
520
+
521
+ // Check if collection exists
522
+ const exists = await qdrant.collectionExists(collection);
523
+ if (!exists) {
524
+ return {
525
+ content: [
526
+ {
527
+ type: "text",
528
+ text: `Error: Collection "${collection}" does not exist.`,
529
+ },
530
+ ],
531
+ isError: true,
532
+ };
533
+ }
534
+
535
+ // Check if collection has hybrid search enabled
536
+ const collectionInfo = await qdrant.getCollectionInfo(collection);
537
+ if (!collectionInfo.hybridEnabled) {
538
+ return {
539
+ content: [
540
+ {
541
+ type: "text",
542
+ text: `Error: Collection "${collection}" does not have hybrid search enabled. Create a new collection with enableHybrid set to true.`,
543
+ },
544
+ ],
545
+ isError: true,
546
+ };
547
+ }
539
548
 
540
- case "get_collection_info": {
541
- const { name } = GetCollectionInfoSchema.parse(args);
542
- const info = await qdrant.getCollectionInfo(name);
543
- return {
544
- content: [
545
- {
546
- type: "text",
547
- text: JSON.stringify(info, null, 2),
548
- },
549
- ],
550
- };
551
- }
549
+ // Generate dense embedding for query
550
+ const { embedding } = await embeddings.embed(query);
552
551
 
553
- case "delete_documents": {
554
- const { collection, ids } = DeleteDocumentsSchema.parse(args);
555
- await qdrant.deletePoints(collection, ids);
556
- return {
557
- content: [
558
- {
559
- type: "text",
560
- text: `Successfully deleted ${ids.length} document(s) from collection "${collection}".`,
561
- },
562
- ],
563
- };
564
- }
552
+ // Generate sparse vector for query
553
+ const sparseGenerator = new BM25SparseVectorGenerator();
554
+ const sparseVector = sparseGenerator.generate(query);
565
555
 
566
- case "hybrid_search": {
567
- const { collection, query, limit, filter } = HybridSearchSchema.parse(args);
556
+ // Perform hybrid search
557
+ const results = await qdrant.hybridSearch(
558
+ collection,
559
+ embedding,
560
+ sparseVector,
561
+ limit || 5,
562
+ filter
563
+ );
568
564
 
569
- // Check if collection exists
570
- const exists = await qdrant.collectionExists(collection);
571
- if (!exists) {
572
565
  return {
573
566
  content: [
574
567
  {
575
568
  type: "text",
576
- text: `Error: Collection "${collection}" does not exist.`,
569
+ text: JSON.stringify(results, null, 2),
577
570
  },
578
571
  ],
579
- isError: true,
580
572
  };
581
573
  }
582
574
 
583
- // Check if collection has hybrid search enabled
584
- const collectionInfo = await qdrant.getCollectionInfo(collection);
585
- if (!collectionInfo.hybridEnabled) {
575
+ default:
586
576
  return {
587
577
  content: [
588
578
  {
589
579
  type: "text",
590
- text: `Error: Collection "${collection}" does not have hybrid search enabled. Create a new collection with enableHybrid set to true.`,
580
+ text: `Unknown tool: ${name}`,
591
581
  },
592
582
  ],
593
583
  isError: true,
594
584
  };
595
- }
596
-
597
- // Generate dense embedding for query
598
- const { embedding } = await embeddings.embed(query);
599
-
600
- // Generate sparse vector for query
601
- const sparseGenerator = new BM25SparseVectorGenerator();
602
- const sparseVector = sparseGenerator.generate(query);
603
-
604
- // Perform hybrid search
605
- const results = await qdrant.hybridSearch(
606
- collection,
607
- embedding,
608
- sparseVector,
609
- limit || 5,
610
- filter
611
- );
612
-
613
- return {
614
- content: [
615
- {
616
- type: "text",
617
- text: JSON.stringify(results, null, 2),
618
- },
619
- ],
620
- };
621
585
  }
586
+ } catch (error: any) {
587
+ // Enhanced error details for debugging
588
+ const errorDetails = error instanceof Error ? error.message : JSON.stringify(error, null, 2);
589
+
590
+ console.error("Tool execution error:", {
591
+ tool: name,
592
+ error: errorDetails,
593
+ stack: error?.stack,
594
+ data: error?.data,
595
+ });
622
596
 
623
- default:
624
- return {
625
- content: [
626
- {
627
- type: "text",
628
- text: `Unknown tool: ${name}`,
629
- },
630
- ],
631
- isError: true,
632
- };
597
+ return {
598
+ content: [
599
+ {
600
+ type: "text",
601
+ text: `Error: ${errorDetails}`,
602
+ },
603
+ ],
604
+ isError: true,
605
+ };
633
606
  }
634
- } catch (error: any) {
635
- // Enhanced error details for debugging
636
- const errorDetails = error instanceof Error ? error.message : JSON.stringify(error, null, 2);
637
-
638
- console.error("Tool execution error:", {
639
- tool: name,
640
- error: errorDetails,
641
- stack: error?.stack,
642
- data: error?.data,
643
- });
607
+ });
608
+
609
+ // List available resources
610
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
611
+ const collections = await qdrant.listCollections();
644
612
 
645
613
  return {
646
- content: [
614
+ resources: [
647
615
  {
648
- type: "text",
649
- text: `Error: ${errorDetails}`,
616
+ uri: "qdrant://collections",
617
+ name: "All Collections",
618
+ description: "List of all vector collections in Qdrant",
619
+ mimeType: "application/json",
650
620
  },
621
+ ...collections.map((name) => ({
622
+ uri: `qdrant://collection/${name}`,
623
+ name: `Collection: ${name}`,
624
+ description: `Details and statistics for collection "${name}"`,
625
+ mimeType: "application/json",
626
+ })),
651
627
  ],
652
- isError: true,
653
628
  };
654
- }
655
- });
629
+ });
656
630
 
657
- // List available resources
658
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
659
- const collections = await qdrant.listCollections();
660
-
661
- return {
662
- resources: [
663
- {
664
- uri: "qdrant://collections",
665
- name: "All Collections",
666
- description: "List of all vector collections in Qdrant",
667
- mimeType: "application/json",
668
- },
669
- ...collections.map((name) => ({
670
- uri: `qdrant://collection/${name}`,
671
- name: `Collection: ${name}`,
672
- description: `Details and statistics for collection "${name}"`,
673
- mimeType: "application/json",
674
- })),
675
- ],
676
- };
677
- });
631
+ // Read resource content
632
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
633
+ const { uri } = request.params;
634
+
635
+ if (uri === "qdrant://collections") {
636
+ const collections = await qdrant.listCollections();
637
+ return {
638
+ contents: [
639
+ {
640
+ uri,
641
+ mimeType: "application/json",
642
+ text: JSON.stringify(collections, null, 2),
643
+ },
644
+ ],
645
+ };
646
+ }
678
647
 
679
- // Read resource content
680
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
681
- const { uri } = request.params;
648
+ const collectionMatch = uri.match(/^qdrant:\/\/collection\/(.+)$/);
649
+ if (collectionMatch) {
650
+ const name = collectionMatch[1];
651
+ const info = await qdrant.getCollectionInfo(name);
652
+ return {
653
+ contents: [
654
+ {
655
+ uri,
656
+ mimeType: "application/json",
657
+ text: JSON.stringify(info, null, 2),
658
+ },
659
+ ],
660
+ };
661
+ }
682
662
 
683
- if (uri === "qdrant://collections") {
684
- const collections = await qdrant.listCollections();
685
663
  return {
686
664
  contents: [
687
665
  {
688
666
  uri,
689
- mimeType: "application/json",
690
- text: JSON.stringify(collections, null, 2),
667
+ mimeType: "text/plain",
668
+ text: `Unknown resource: ${uri}`,
691
669
  },
692
670
  ],
693
671
  };
694
- }
672
+ });
673
+ }
695
674
 
696
- const collectionMatch = uri.match(/^qdrant:\/\/collection\/(.+)$/);
697
- if (collectionMatch) {
698
- const name = collectionMatch[1];
699
- const info = await qdrant.getCollectionInfo(name);
700
- return {
701
- contents: [
702
- {
703
- uri,
704
- mimeType: "application/json",
705
- text: JSON.stringify(info, null, 2),
706
- },
707
- ],
708
- };
709
- }
675
+ // Register handlers on the shared server for stdio mode
676
+ registerHandlers(server);
710
677
 
711
- return {
712
- contents: [
713
- {
714
- uri,
715
- mimeType: "text/plain",
716
- text: `Unknown resource: ${uri}`,
717
- },
718
- ],
719
- };
678
+ // Tool schemas
679
+ const CreateCollectionSchema = z.object({
680
+ name: z.string().describe("Name of the collection"),
681
+ distance: z
682
+ .enum(["Cosine", "Euclid", "Dot"])
683
+ .optional()
684
+ .describe("Distance metric (default: Cosine)"),
685
+ enableHybrid: z
686
+ .boolean()
687
+ .optional()
688
+ .describe("Enable hybrid search with sparse vectors (default: false)"),
689
+ });
690
+
691
+ const AddDocumentsSchema = z.object({
692
+ collection: z.string().describe("Name of the collection"),
693
+ documents: z
694
+ .array(
695
+ z.object({
696
+ id: z.union([z.string(), z.number()]).describe("Unique identifier for the document"),
697
+ text: z.string().describe("Text content to embed and store"),
698
+ metadata: z
699
+ .record(z.any())
700
+ .optional()
701
+ .describe("Optional metadata to store with the document"),
702
+ })
703
+ )
704
+ .describe("Array of documents to add"),
705
+ });
706
+
707
+ const SemanticSearchSchema = z.object({
708
+ collection: z.string().describe("Name of the collection to search"),
709
+ query: z.string().describe("Search query text"),
710
+ limit: z.number().optional().describe("Maximum number of results (default: 5)"),
711
+ filter: z.record(z.any()).optional().describe("Optional metadata filter"),
712
+ });
713
+
714
+ const DeleteCollectionSchema = z.object({
715
+ name: z.string().describe("Name of the collection to delete"),
716
+ });
717
+
718
+ const GetCollectionInfoSchema = z.object({
719
+ name: z.string().describe("Name of the collection"),
720
+ });
721
+
722
+ const DeleteDocumentsSchema = z.object({
723
+ collection: z.string().describe("Name of the collection"),
724
+ ids: z.array(z.union([z.string(), z.number()])).describe("Array of document IDs to delete"),
725
+ });
726
+
727
+ const HybridSearchSchema = z.object({
728
+ collection: z.string().describe("Name of the collection to search"),
729
+ query: z.string().describe("Search query text"),
730
+ limit: z.number().optional().describe("Maximum number of results (default: 5)"),
731
+ filter: z.record(z.any()).optional().describe("Optional metadata filter"),
720
732
  });
721
733
 
722
734
  // Start server with stdio transport
@@ -727,6 +739,14 @@ async function startStdioServer() {
727
739
  console.error("Qdrant MCP server running on stdio");
728
740
  }
729
741
 
742
+ // Constants for HTTP server configuration
743
+ const RATE_LIMIT_MAX_REQUESTS = 100; // Max requests per window
744
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
745
+ const RATE_LIMIT_MAX_CONCURRENT = 10; // Max concurrent requests per IP
746
+ const RATE_LIMITER_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
747
+ const REQUEST_TIMEOUT_MS = 30 * 1000; // 30 seconds per request
748
+ const SHUTDOWN_GRACE_PERIOD_MS = 10 * 1000; // 10 seconds
749
+
730
750
  // Start server with HTTP transport
731
751
  async function startHttpServer() {
732
752
  await checkOllamaAvailability();
@@ -739,23 +759,38 @@ async function startHttpServer() {
739
759
 
740
760
  // Rate limiter group: max 100 requests per 15 minutes per IP, max 10 concurrent per IP
741
761
  const rateLimiterGroup = new Bottleneck.Group({
742
- reservoir: 100, // initial capacity per IP
743
- reservoirRefreshAmount: 100, // refresh back to 100
744
- reservoirRefreshInterval: 15 * 60 * 1000, // every 15 minutes
745
- maxConcurrent: 10, // max concurrent requests per IP
762
+ reservoir: RATE_LIMIT_MAX_REQUESTS,
763
+ reservoirRefreshAmount: RATE_LIMIT_MAX_REQUESTS,
764
+ reservoirRefreshInterval: RATE_LIMIT_WINDOW_MS,
765
+ maxConcurrent: RATE_LIMIT_MAX_CONCURRENT,
746
766
  });
747
767
 
768
+ // Helper function to send JSON-RPC error responses
769
+ const sendErrorResponse = (
770
+ res: express.Response,
771
+ code: number,
772
+ message: string,
773
+ httpStatus: number = 500
774
+ ) => {
775
+ if (!res.headersSent) {
776
+ res.status(httpStatus).json({
777
+ jsonrpc: "2.0",
778
+ error: { code, message },
779
+ id: null,
780
+ });
781
+ }
782
+ };
783
+
748
784
  // Periodic cleanup of inactive rate limiters to prevent memory leaks
749
785
  // Track last access time for each IP
750
786
  const ipLastAccess = new Map<string, number>();
751
- const INACTIVE_TIMEOUT = 60 * 60 * 1000; // 1 hour
752
787
 
753
788
  const cleanupIntervalId = setInterval(() => {
754
789
  const now = Date.now();
755
790
  const keysToDelete: string[] = [];
756
791
 
757
792
  ipLastAccess.forEach((lastAccess, ip) => {
758
- if (now - lastAccess > INACTIVE_TIMEOUT) {
793
+ if (now - lastAccess > RATE_LIMITER_CLEANUP_INTERVAL_MS) {
759
794
  keysToDelete.push(ip);
760
795
  }
761
796
  });
@@ -768,7 +803,7 @@ async function startHttpServer() {
768
803
  if (keysToDelete.length > 0) {
769
804
  console.error(`Cleaned up ${keysToDelete.length} inactive rate limiters`);
770
805
  }
771
- }, INACTIVE_TIMEOUT);
806
+ }, RATE_LIMITER_CLEANUP_INTERVAL_MS);
772
807
 
773
808
  // Rate limiting middleware
774
809
  const rateLimitMiddleware = async (
@@ -789,20 +824,11 @@ async function startHttpServer() {
789
824
  } catch (error) {
790
825
  // Differentiate between rate limit errors and unexpected errors
791
826
  if (error instanceof Bottleneck.BottleneckError) {
792
- // Rate limit exceeded or Bottleneck operational error
793
827
  console.error(`Rate limit exceeded for IP ${clientIp}:`, error.message);
794
828
  } else {
795
- // Unexpected error in rate limiting logic
796
829
  console.error("Unexpected rate limiting error:", error);
797
830
  }
798
- res.status(429).json({
799
- jsonrpc: "2.0",
800
- error: {
801
- code: -32000,
802
- message: "Too many requests",
803
- },
804
- id: null,
805
- });
831
+ sendErrorResponse(res, -32000, "Too many requests", 429);
806
832
  }
807
833
  };
808
834
 
@@ -817,76 +843,61 @@ async function startHttpServer() {
817
843
  });
818
844
 
819
845
  app.post("/mcp", rateLimitMiddleware, async (req, res) => {
820
- const REQUEST_TIMEOUT = 60000; // 60 seconds
821
- let timeoutId: NodeJS.Timeout | undefined;
822
- let isTimedOut = false;
823
- let transportClosed = false;
846
+ // Create a new server for each request
847
+ const requestServer = createServer();
848
+ registerHandlers(requestServer);
824
849
 
825
- // Create a new transport for each request in stateless mode.
826
- // This prevents request ID collisions when different clients use the same JSON-RPC request IDs.
850
+ // Create transport with enableJsonResponse
827
851
  const transport = new StreamableHTTPServerTransport({
828
- sessionIdGenerator: undefined, // Stateless mode
852
+ sessionIdGenerator: undefined,
829
853
  enableJsonResponse: true,
830
854
  });
831
855
 
832
- // Helper to safely close transport only once
833
- const closeTransport = async () => {
834
- if (!transportClosed) {
835
- transportClosed = true;
836
- await transport.close().catch((e) => console.error("Error closing transport:", e));
837
- }
856
+ // Track cleanup state to prevent double cleanup
857
+ let cleanedUp = false;
858
+ const cleanup = async () => {
859
+ if (cleanedUp) return;
860
+ cleanedUp = true;
861
+ await transport.close().catch(() => {});
862
+ await requestServer.close().catch(() => {});
838
863
  };
839
864
 
840
- try {
841
- // Set request timeout
842
- timeoutId = setTimeout(async () => {
843
- isTimedOut = true;
844
- // Close transport on timeout to prevent resource leaks
845
- await closeTransport();
846
- if (!res.headersSent) {
847
- res.status(408).json({
848
- jsonrpc: "2.0",
849
- error: {
850
- code: -32000,
851
- message: "Request timeout",
852
- },
853
- id: null,
854
- });
855
- }
856
- }, REQUEST_TIMEOUT);
857
-
858
- // Clean up transport when response closes
859
- res.on("close", async () => {
860
- await closeTransport();
861
- if (timeoutId) clearTimeout(timeoutId);
865
+ // Set a timeout for the request to prevent hanging
866
+ const timeoutId = setTimeout(() => {
867
+ sendErrorResponse(res, -32000, "Request timeout", 504);
868
+ cleanup().catch((err) => {
869
+ console.error("Error during timeout cleanup:", err);
862
870
  });
871
+ }, REQUEST_TIMEOUT_MS);
863
872
 
864
- // Connect the transport to the shared server instance.
865
- // In stateless mode, each request gets a new transport connection.
866
- await server.connect(transport);
873
+ try {
874
+ // Connect server to transport
875
+ await requestServer.connect(transport);
876
+
877
+ // Handle the request - this triggers message processing
878
+ // The response will be sent asynchronously when the server calls transport.send()
867
879
  await transport.handleRequest(req, res, req.body);
868
880
 
869
- // Clear timeout immediately on success to prevent race condition
870
- if (timeoutId) {
881
+ // Clean up AFTER the response finishes
882
+ // Listen to multiple events to ensure cleanup happens in all scenarios
883
+ const cleanupHandler = () => {
871
884
  clearTimeout(timeoutId);
872
- timeoutId = undefined;
873
- }
885
+ cleanup().catch((err) => {
886
+ console.error("Error during response cleanup:", err);
887
+ });
888
+ };
889
+
890
+ res.on("finish", cleanupHandler);
891
+ res.on("close", cleanupHandler);
892
+ res.on("error", (err) => {
893
+ console.error("Response stream error:", err);
894
+ cleanupHandler();
895
+ });
874
896
  } catch (error) {
897
+ clearTimeout(timeoutId);
875
898
  console.error("Error handling MCP request:", error);
876
- if (!res.headersSent && !isTimedOut) {
877
- res.status(500).json({
878
- jsonrpc: "2.0",
879
- error: {
880
- code: -32603,
881
- message: "Internal server error",
882
- },
883
- id: null,
884
- });
885
- }
886
- } finally {
887
- if (timeoutId) clearTimeout(timeoutId);
888
- // Ensure transport is closed even if an error occurs
889
- await closeTransport();
899
+ sendErrorResponse(res, -32603, "Internal server error");
900
+ await cleanup();
890
901
  }
891
902
  });
892
903
 
@@ -911,11 +922,11 @@ async function startHttpServer() {
911
922
  // Clear the cleanup interval to allow graceful shutdown
912
923
  clearInterval(cleanupIntervalId);
913
924
 
914
- // Force shutdown after 10 seconds
925
+ // Force shutdown after grace period
915
926
  const forceTimeout = setTimeout(() => {
916
927
  console.error("Forcing shutdown after timeout");
917
928
  process.exit(1);
918
- }, 10000);
929
+ }, SHUTDOWN_GRACE_PERIOD_MS);
919
930
 
920
931
  httpServer.close(() => {
921
932
  clearTimeout(forceTimeout);