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