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