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