@mhalder/qdrant-mcp-server 1.3.0 → 1.4.0

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