@mhalder/qdrant-mcp-server 1.2.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
@@ -4,7 +4,10 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
8
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
9
+ import Bottleneck from "bottleneck";
10
+ import express from "express";
8
11
  import { z } from "zod";
9
12
  import { EmbeddingProviderFactory } from "./embeddings/factory.js";
10
13
  import { BM25SparseVectorGenerator } from "./embeddings/sparse.js";
@@ -15,6 +18,15 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8")
15
18
  // Validate environment variables
16
19
  const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
17
20
  const EMBEDDING_PROVIDER = (process.env.EMBEDDING_PROVIDER || "ollama").toLowerCase();
21
+ const TRANSPORT_MODE = (process.env.TRANSPORT_MODE || "stdio").toLowerCase();
22
+ const HTTP_PORT = parseInt(process.env.HTTP_PORT || "3000", 10);
23
+ // Validate HTTP_PORT when HTTP mode is selected
24
+ if (TRANSPORT_MODE === "http") {
25
+ if (Number.isNaN(HTTP_PORT) || HTTP_PORT < 1 || HTTP_PORT > 65535) {
26
+ console.error(`Error: Invalid HTTP_PORT "${process.env.HTTP_PORT}". Must be a number between 1 and 65535.`);
27
+ process.exit(1);
28
+ }
29
+ }
18
30
  // Check for required API keys based on provider
19
31
  if (EMBEDDING_PROVIDER !== "ollama") {
20
32
  let apiKey;
@@ -101,532 +113,720 @@ async function checkOllamaAvailability() {
101
113
  // Initialize clients
102
114
  const qdrant = new QdrantManager(QDRANT_URL);
103
115
  const embeddings = EmbeddingProviderFactory.createFromEnv();
104
- // Create MCP server
105
- const server = new Server({
106
- name: pkg.name,
107
- version: pkg.version,
108
- }, {
109
- capabilities: {
110
- tools: {},
111
- resources: {},
112
- },
113
- });
114
- // Tool schemas
115
- const CreateCollectionSchema = z.object({
116
- name: z.string().describe("Name of the collection"),
117
- distance: z
118
- .enum(["Cosine", "Euclid", "Dot"])
119
- .optional()
120
- .describe("Distance metric (default: Cosine)"),
121
- enableHybrid: z
122
- .boolean()
123
- .optional()
124
- .describe("Enable hybrid search with sparse vectors (default: false)"),
125
- });
126
- const AddDocumentsSchema = z.object({
127
- collection: z.string().describe("Name of the collection"),
128
- documents: z
129
- .array(z.object({
130
- id: z.union([z.string(), z.number()]).describe("Unique identifier for the document"),
131
- text: z.string().describe("Text content to embed and store"),
132
- metadata: z
133
- .record(z.any())
134
- .optional()
135
- .describe("Optional metadata to store with the document"),
136
- }))
137
- .describe("Array of documents to add"),
138
- });
139
- const SemanticSearchSchema = z.object({
140
- collection: z.string().describe("Name of the collection to search"),
141
- query: z.string().describe("Search query text"),
142
- limit: z.number().optional().describe("Maximum number of results (default: 5)"),
143
- filter: z.record(z.any()).optional().describe("Optional metadata filter"),
144
- });
145
- const DeleteCollectionSchema = z.object({
146
- name: z.string().describe("Name of the collection to delete"),
147
- });
148
- const GetCollectionInfoSchema = z.object({
149
- name: z.string().describe("Name of the collection"),
150
- });
151
- const DeleteDocumentsSchema = z.object({
152
- collection: z.string().describe("Name of the collection"),
153
- ids: z.array(z.union([z.string(), z.number()])).describe("Array of document IDs to delete"),
154
- });
155
- const HybridSearchSchema = z.object({
156
- collection: z.string().describe("Name of the collection to search"),
157
- query: z.string().describe("Search query text"),
158
- limit: z.number().optional().describe("Maximum number of results (default: 5)"),
159
- filter: z.record(z.any()).optional().describe("Optional metadata filter"),
160
- });
161
- // List available tools
162
- server.setRequestHandler(ListToolsRequestSchema, async () => {
163
- return {
164
- tools: [
165
- {
166
- name: "create_collection",
167
- 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.",
168
- inputSchema: {
169
- type: "object",
170
- properties: {
171
- name: {
172
- type: "string",
173
- description: "Name of the collection",
174
- },
175
- distance: {
176
- type: "string",
177
- enum: ["Cosine", "Euclid", "Dot"],
178
- description: "Distance metric (default: Cosine)",
179
- },
180
- enableHybrid: {
181
- type: "boolean",
182
- 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
+ },
183
156
  },
157
+ required: ["name"],
184
158
  },
185
- required: ["name"],
186
159
  },
187
- },
188
- {
189
- name: "add_documents",
190
- description: "Add documents to a collection. Documents will be automatically embedded using the configured embedding provider.",
191
- inputSchema: {
192
- type: "object",
193
- properties: {
194
- collection: {
195
- type: "string",
196
- description: "Name of the collection",
197
- },
198
- documents: {
199
- type: "array",
200
- description: "Array of documents to add",
201
- items: {
202
- type: "object",
203
- properties: {
204
- id: {
205
- type: ["string", "number"],
206
- description: "Unique identifier for the document",
207
- },
208
- text: {
209
- type: "string",
210
- description: "Text content to embed and store",
211
- },
212
- metadata: {
213
- type: "object",
214
- 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
+ },
215
188
  },
189
+ required: ["id", "text"],
216
190
  },
217
- required: ["id", "text"],
218
191
  },
219
192
  },
193
+ required: ["collection", "documents"],
220
194
  },
221
- required: ["collection", "documents"],
222
195
  },
223
- },
224
- {
225
- name: "semantic_search",
226
- description: "Search for documents using natural language queries. Returns the most semantically similar documents.",
227
- inputSchema: {
228
- type: "object",
229
- properties: {
230
- collection: {
231
- type: "string",
232
- description: "Name of the collection to search",
233
- },
234
- query: {
235
- type: "string",
236
- description: "Search query text",
237
- },
238
- limit: {
239
- type: "number",
240
- description: "Maximum number of results (default: 5)",
241
- },
242
- filter: {
243
- type: "object",
244
- 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
+ },
245
218
  },
219
+ required: ["collection", "query"],
246
220
  },
247
- required: ["collection", "query"],
248
221
  },
249
- },
250
- {
251
- name: "list_collections",
252
- description: "List all available collections in Qdrant.",
253
- inputSchema: {
254
- type: "object",
255
- properties: {},
222
+ {
223
+ name: "list_collections",
224
+ description: "List all available collections in Qdrant.",
225
+ inputSchema: {
226
+ type: "object",
227
+ properties: {},
228
+ },
256
229
  },
257
- },
258
- {
259
- name: "delete_collection",
260
- description: "Delete a collection and all its documents.",
261
- inputSchema: {
262
- type: "object",
263
- properties: {
264
- name: {
265
- type: "string",
266
- 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
+ },
267
240
  },
241
+ required: ["name"],
268
242
  },
269
- required: ["name"],
270
243
  },
271
- },
272
- {
273
- name: "get_collection_info",
274
- description: "Get detailed information about a collection including vector size, point count, and distance metric.",
275
- inputSchema: {
276
- type: "object",
277
- properties: {
278
- name: {
279
- type: "string",
280
- 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
+ },
281
254
  },
255
+ required: ["name"],
282
256
  },
283
- required: ["name"],
284
257
  },
285
- },
286
- {
287
- name: "delete_documents",
288
- description: "Delete specific documents from a collection by their IDs.",
289
- inputSchema: {
290
- type: "object",
291
- properties: {
292
- collection: {
293
- type: "string",
294
- description: "Name of the collection",
295
- },
296
- ids: {
297
- type: "array",
298
- description: "Array of document IDs to delete",
299
- items: {
300
- 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
+ },
301
274
  },
302
275
  },
276
+ required: ["collection", "ids"],
303
277
  },
304
- required: ["collection", "ids"],
305
278
  },
306
- },
307
- {
308
- name: "hybrid_search",
309
- 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.",
310
- inputSchema: {
311
- type: "object",
312
- properties: {
313
- collection: {
314
- type: "string",
315
- description: "Name of the collection to search",
316
- },
317
- query: {
318
- type: "string",
319
- description: "Search query text",
320
- },
321
- limit: {
322
- type: "number",
323
- description: "Maximum number of results (default: 5)",
324
- },
325
- filter: {
326
- type: "object",
327
- 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
+ },
328
301
  },
302
+ required: ["collection", "query"],
329
303
  },
330
- required: ["collection", "query"],
331
304
  },
332
- },
333
- ],
334
- };
335
- });
336
- // Handle tool calls
337
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
338
- const { name, arguments: args } = request.params;
339
- try {
340
- switch (name) {
341
- case "create_collection": {
342
- const { name, distance, enableHybrid } = CreateCollectionSchema.parse(args);
343
- const vectorSize = embeddings.getDimensions();
344
- await qdrant.createCollection(name, vectorSize, distance, enableHybrid || false);
345
- let message = `Collection "${name}" created successfully with ${vectorSize} dimensions and ${distance || "Cosine"} distance metric.`;
346
- if (enableHybrid) {
347
- 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
+ };
348
329
  }
349
- return {
350
- content: [
351
- {
352
- type: "text",
353
- text: message,
354
- },
355
- ],
356
- };
357
- }
358
- case "add_documents": {
359
- const { collection, documents } = AddDocumentsSchema.parse(args);
360
- // Check if collection exists and get info
361
- const exists = await qdrant.collectionExists(collection);
362
- 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
+ }
363
376
  return {
364
377
  content: [
365
378
  {
366
379
  type: "text",
367
- text: `Error: Collection "${collection}" does not exist. Create it first using create_collection.`,
380
+ text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
368
381
  },
369
382
  ],
370
- isError: true,
371
383
  };
372
384
  }
373
- const collectionInfo = await qdrant.getCollectionInfo(collection);
374
- // Generate embeddings for all documents
375
- const texts = documents.map((doc) => doc.text);
376
- const embeddingResults = await embeddings.embedBatch(texts);
377
- // If hybrid search is enabled, generate sparse vectors and use appropriate method
378
- if (collectionInfo.hybridEnabled) {
379
- const sparseGenerator = new BM25SparseVectorGenerator();
380
- // Prepare points with both dense and sparse vectors
381
- const points = documents.map((doc, index) => ({
382
- id: doc.id,
383
- vector: embeddingResults[index].embedding,
384
- sparseVector: sparseGenerator.generate(doc.text),
385
- payload: {
386
- text: doc.text,
387
- ...doc.metadata,
388
- },
389
- }));
390
- 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
+ };
391
412
  }
392
- else {
393
- // Standard dense-only vectors
394
- const points = documents.map((doc, index) => ({
395
- id: doc.id,
396
- vector: embeddingResults[index].embedding,
397
- payload: {
398
- text: doc.text,
399
- ...doc.metadata,
400
- },
401
- }));
402
- 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
+ };
403
423
  }
404
- return {
405
- content: [
406
- {
407
- type: "text",
408
- text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
409
- },
410
- ],
411
- };
412
- }
413
- case "semantic_search": {
414
- const { collection, query, limit, filter } = SemanticSearchSchema.parse(args);
415
- // Check if collection exists
416
- const exists = await qdrant.collectionExists(collection);
417
- if (!exists) {
424
+ case "delete_collection": {
425
+ const { name } = DeleteCollectionSchema.parse(args);
426
+ await qdrant.deleteCollection(name);
418
427
  return {
419
428
  content: [
420
429
  {
421
430
  type: "text",
422
- text: `Error: Collection "${collection}" does not exist.`,
431
+ text: `Collection "${name}" deleted successfully.`,
423
432
  },
424
433
  ],
425
- isError: true,
426
434
  };
427
435
  }
428
- // Generate embedding for query
429
- const { embedding } = await embeddings.embed(query);
430
- // Search
431
- const results = await qdrant.search(collection, embedding, limit || 5, filter);
432
- return {
433
- content: [
434
- {
435
- type: "text",
436
- text: JSON.stringify(results, null, 2),
437
- },
438
- ],
439
- };
440
- }
441
- case "list_collections": {
442
- const collections = await qdrant.listCollections();
443
- return {
444
- content: [
445
- {
446
- type: "text",
447
- text: JSON.stringify(collections, null, 2),
448
- },
449
- ],
450
- };
451
- }
452
- case "delete_collection": {
453
- const { name } = DeleteCollectionSchema.parse(args);
454
- await qdrant.deleteCollection(name);
455
- return {
456
- content: [
457
- {
458
- type: "text",
459
- text: `Collection "${name}" deleted successfully.`,
460
- },
461
- ],
462
- };
463
- }
464
- case "get_collection_info": {
465
- const { name } = GetCollectionInfoSchema.parse(args);
466
- const info = await qdrant.getCollectionInfo(name);
467
- return {
468
- content: [
469
- {
470
- type: "text",
471
- text: JSON.stringify(info, null, 2),
472
- },
473
- ],
474
- };
475
- }
476
- case "delete_documents": {
477
- const { collection, ids } = DeleteDocumentsSchema.parse(args);
478
- await qdrant.deletePoints(collection, ids);
479
- return {
480
- content: [
481
- {
482
- type: "text",
483
- text: `Successfully deleted ${ids.length} document(s) from collection "${collection}".`,
484
- },
485
- ],
486
- };
487
- }
488
- case "hybrid_search": {
489
- const { collection, query, limit, filter } = HybridSearchSchema.parse(args);
490
- // Check if collection exists
491
- const exists = await qdrant.collectionExists(collection);
492
- if (!exists) {
436
+ case "get_collection_info": {
437
+ const { name } = GetCollectionInfoSchema.parse(args);
438
+ const info = await qdrant.getCollectionInfo(name);
493
439
  return {
494
440
  content: [
495
441
  {
496
442
  type: "text",
497
- text: `Error: Collection "${collection}" does not exist.`,
443
+ text: JSON.stringify(info, null, 2),
498
444
  },
499
445
  ],
500
- isError: true,
501
446
  };
502
447
  }
503
- // Check if collection has hybrid search enabled
504
- const collectionInfo = await qdrant.getCollectionInfo(collection);
505
- if (!collectionInfo.hybridEnabled) {
448
+ case "delete_documents": {
449
+ const { collection, ids } = DeleteDocumentsSchema.parse(args);
450
+ await qdrant.deletePoints(collection, ids);
506
451
  return {
507
452
  content: [
508
453
  {
509
454
  type: "text",
510
- 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}".`,
511
456
  },
512
457
  ],
513
- isError: true,
514
458
  };
515
459
  }
516
- // Generate dense embedding for query
517
- const { embedding } = await embeddings.embed(query);
518
- // Generate sparse vector for query
519
- const sparseGenerator = new BM25SparseVectorGenerator();
520
- const sparseVector = sparseGenerator.generate(query);
521
- // Perform hybrid search
522
- const results = await qdrant.hybridSearch(collection, embedding, sparseVector, limit || 5, filter);
523
- return {
524
- content: [
525
- {
526
- type: "text",
527
- text: JSON.stringify(results, null, 2),
528
- },
529
- ],
530
- };
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
+ };
531
514
  }
532
- default:
533
- return {
534
- content: [
535
- {
536
- type: "text",
537
- text: `Unknown tool: ${name}`,
538
- },
539
- ],
540
- isError: true,
541
- };
542
515
  }
543
- }
544
- catch (error) {
545
- // Enhanced error details for debugging
546
- const errorDetails = error instanceof Error ? error.message : JSON.stringify(error, null, 2);
547
- console.error("Tool execution error:", {
548
- tool: name,
549
- error: errorDetails,
550
- stack: error?.stack,
551
- data: error?.data,
552
- });
553
- return {
554
- content: [
555
- {
556
- type: "text",
557
- text: `Error: ${errorDetails}`,
558
- },
559
- ],
560
- isError: true,
561
- };
562
- }
563
- });
564
- // List available resources
565
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
566
- const collections = await qdrant.listCollections();
567
- return {
568
- resources: [
569
- {
570
- uri: "qdrant://collections",
571
- name: "All Collections",
572
- description: "List of all vector collections in Qdrant",
573
- mimeType: "application/json",
574
- },
575
- ...collections.map((name) => ({
576
- uri: `qdrant://collection/${name}`,
577
- name: `Collection: ${name}`,
578
- description: `Details and statistics for collection "${name}"`,
579
- mimeType: "application/json",
580
- })),
581
- ],
582
- };
583
- });
584
- // Read resource content
585
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
586
- const { uri } = request.params;
587
- 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 () => {
588
538
  const collections = await qdrant.listCollections();
589
539
  return {
590
- contents: [
540
+ resources: [
591
541
  {
592
- uri,
542
+ uri: "qdrant://collections",
543
+ name: "All Collections",
544
+ description: "List of all vector collections in Qdrant",
593
545
  mimeType: "application/json",
594
- text: JSON.stringify(collections, null, 2),
595
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
+ })),
596
553
  ],
597
554
  };
598
- }
599
- const collectionMatch = uri.match(/^qdrant:\/\/collection\/(.+)$/);
600
- if (collectionMatch) {
601
- const name = collectionMatch[1];
602
- 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
+ }
603
585
  return {
604
586
  contents: [
605
587
  {
606
588
  uri,
607
- mimeType: "application/json",
608
- text: JSON.stringify(info, null, 2),
589
+ mimeType: "text/plain",
590
+ text: `Unknown resource: ${uri}`,
609
591
  },
610
592
  ],
611
593
  };
612
- }
613
- return {
614
- contents: [
615
- {
616
- uri,
617
- mimeType: "text/plain",
618
- text: `Unknown resource: ${uri}`,
619
- },
620
- ],
621
- };
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)"),
622
609
  });
623
- // Start server
624
- async function main() {
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"),
644
+ });
645
+ // Start server with stdio transport
646
+ async function startStdioServer() {
625
647
  await checkOllamaAvailability();
626
648
  const transport = new StdioServerTransport();
627
649
  await server.connect(transport);
628
650
  console.error("Qdrant MCP server running on stdio");
629
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
659
+ // Start server with HTTP transport
660
+ async function startHttpServer() {
661
+ await checkOllamaAvailability();
662
+ const app = express();
663
+ app.use(express.json({ limit: "10mb" }));
664
+ // Configure Express to trust proxy for correct IP detection
665
+ app.set("trust proxy", true);
666
+ // Rate limiter group: max 100 requests per 15 minutes per IP, max 10 concurrent per IP
667
+ const rateLimiterGroup = new Bottleneck.Group({
668
+ reservoir: RATE_LIMIT_MAX_REQUESTS,
669
+ reservoirRefreshAmount: RATE_LIMIT_MAX_REQUESTS,
670
+ reservoirRefreshInterval: RATE_LIMIT_WINDOW_MS,
671
+ maxConcurrent: RATE_LIMIT_MAX_CONCURRENT,
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
+ };
683
+ // Periodic cleanup of inactive rate limiters to prevent memory leaks
684
+ // Track last access time for each IP
685
+ const ipLastAccess = new Map();
686
+ const cleanupIntervalId = setInterval(() => {
687
+ const now = Date.now();
688
+ const keysToDelete = [];
689
+ ipLastAccess.forEach((lastAccess, ip) => {
690
+ if (now - lastAccess > RATE_LIMITER_CLEANUP_INTERVAL_MS) {
691
+ keysToDelete.push(ip);
692
+ }
693
+ });
694
+ keysToDelete.forEach((ip) => {
695
+ rateLimiterGroup.deleteKey(ip);
696
+ ipLastAccess.delete(ip);
697
+ });
698
+ if (keysToDelete.length > 0) {
699
+ console.error(`Cleaned up ${keysToDelete.length} inactive rate limiters`);
700
+ }
701
+ }, RATE_LIMITER_CLEANUP_INTERVAL_MS);
702
+ // Rate limiting middleware
703
+ const rateLimitMiddleware = async (req, res, next) => {
704
+ const clientIp = req.ip || req.socket.remoteAddress || "unknown";
705
+ try {
706
+ // Update last access time for this IP
707
+ ipLastAccess.set(clientIp, Date.now());
708
+ // Get or create a limiter for this specific IP
709
+ const limiter = rateLimiterGroup.key(clientIp);
710
+ await limiter.schedule(() => Promise.resolve());
711
+ next();
712
+ }
713
+ catch (error) {
714
+ // Differentiate between rate limit errors and unexpected errors
715
+ if (error instanceof Bottleneck.BottleneckError) {
716
+ console.error(`Rate limit exceeded for IP ${clientIp}:`, error.message);
717
+ }
718
+ else {
719
+ console.error("Unexpected rate limiting error:", error);
720
+ }
721
+ sendErrorResponse(res, -32000, "Too many requests", 429);
722
+ }
723
+ };
724
+ // Health check endpoint
725
+ app.get("/health", (_req, res) => {
726
+ res.json({
727
+ status: "ok",
728
+ mode: TRANSPORT_MODE,
729
+ version: pkg.version,
730
+ embedding_provider: EMBEDDING_PROVIDER,
731
+ });
732
+ });
733
+ app.post("/mcp", rateLimitMiddleware, async (req, res) => {
734
+ // Create a new server for each request
735
+ const requestServer = createServer();
736
+ registerHandlers(requestServer);
737
+ // Create transport with enableJsonResponse
738
+ const transport = new StreamableHTTPServerTransport({
739
+ sessionIdGenerator: undefined,
740
+ enableJsonResponse: true,
741
+ });
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(() => { });
750
+ };
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
+ });
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()
763
+ await transport.handleRequest(req, res, req.body);
764
+ // Clean up AFTER the response finishes
765
+ // Listen to multiple events to ensure cleanup happens in all scenarios
766
+ const cleanupHandler = () => {
767
+ clearTimeout(timeoutId);
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
+ });
778
+ }
779
+ catch (error) {
780
+ clearTimeout(timeoutId);
781
+ console.error("Error handling MCP request:", error);
782
+ sendErrorResponse(res, -32603, "Internal server error");
783
+ await cleanup();
784
+ }
785
+ });
786
+ const httpServer = app
787
+ .listen(HTTP_PORT, () => {
788
+ console.error(`Qdrant MCP server running on http://localhost:${HTTP_PORT}/mcp`);
789
+ })
790
+ .on("error", (error) => {
791
+ console.error("HTTP server error:", error);
792
+ process.exit(1);
793
+ });
794
+ // Graceful shutdown handling
795
+ let isShuttingDown = false;
796
+ const shutdown = () => {
797
+ if (isShuttingDown)
798
+ return;
799
+ isShuttingDown = true;
800
+ console.error("Shutdown signal received, closing HTTP server gracefully...");
801
+ // Clear the cleanup interval to allow graceful shutdown
802
+ clearInterval(cleanupIntervalId);
803
+ // Force shutdown after grace period
804
+ const forceTimeout = setTimeout(() => {
805
+ console.error("Forcing shutdown after timeout");
806
+ process.exit(1);
807
+ }, SHUTDOWN_GRACE_PERIOD_MS);
808
+ httpServer.close(() => {
809
+ clearTimeout(forceTimeout);
810
+ console.error("HTTP server closed");
811
+ process.exit(0);
812
+ });
813
+ };
814
+ process.on("SIGTERM", shutdown);
815
+ process.on("SIGINT", shutdown);
816
+ }
817
+ // Main entry point
818
+ async function main() {
819
+ if (TRANSPORT_MODE === "http") {
820
+ await startHttpServer();
821
+ }
822
+ else if (TRANSPORT_MODE === "stdio") {
823
+ await startStdioServer();
824
+ }
825
+ else {
826
+ console.error(`Error: Invalid TRANSPORT_MODE "${TRANSPORT_MODE}". Supported modes: stdio, http.`);
827
+ process.exit(1);
828
+ }
829
+ }
630
830
  main().catch((error) => {
631
831
  console.error("Fatal error:", error);
632
832
  process.exit(1);