@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/CHANGELOG.md +18 -0
- package/README.md +34 -0
- package/build/index.js +643 -443
- package/build/index.js.map +1 -1
- package/build/transport.test.d.ts +2 -0
- package/build/transport.test.d.ts.map +1 -0
- package/build/transport.test.js +168 -0
- package/build/transport.test.js.map +1 -0
- package/examples/hybrid-search/README.md +37 -0
- package/package.json +3 -1
- package/src/index.ts +719 -478
- package/src/transport.test.ts +202 -0
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
//
|
|
198
|
-
server
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
345
|
+
// Handle tool calls
|
|
346
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
347
|
+
const { name, arguments: args } = request.params;
|
|
381
348
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
405
|
-
|
|
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: `
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
//
|
|
432
|
-
const
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
457
|
+
return {
|
|
458
|
+
content: [
|
|
459
|
+
{
|
|
460
|
+
type: "text",
|
|
461
|
+
text: JSON.stringify(results, null, 2),
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
};
|
|
455
465
|
}
|
|
456
466
|
|
|
457
|
-
|
|
458
|
-
|
|
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:
|
|
473
|
+
text: JSON.stringify(collections, null, 2),
|
|
478
474
|
},
|
|
479
475
|
],
|
|
480
|
-
isError: true,
|
|
481
476
|
};
|
|
482
477
|
}
|
|
483
478
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
552
|
-
|
|
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:
|
|
569
|
+
text: JSON.stringify(results, null, 2),
|
|
562
570
|
},
|
|
563
571
|
],
|
|
564
|
-
isError: true,
|
|
565
572
|
};
|
|
566
573
|
}
|
|
567
574
|
|
|
568
|
-
|
|
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: `
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
631
|
-
|
|
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
|
-
|
|
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: "
|
|
690
|
-
text:
|
|
667
|
+
mimeType: "text/plain",
|
|
668
|
+
text: `Unknown resource: ${uri}`,
|
|
691
669
|
},
|
|
692
670
|
],
|
|
693
671
|
};
|
|
694
|
-
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
695
674
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
708
|
-
|
|
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);
|