@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.
- package/CHANGELOG.md +12 -0
- package/README.md +68 -0
- package/build/index.js +581 -517
- package/build/index.js.map +1 -1
- package/build/prompts/index.d.ts +7 -0
- package/build/prompts/index.d.ts.map +1 -0
- package/build/prompts/index.js +7 -0
- package/build/prompts/index.js.map +1 -0
- package/build/prompts/index.test.d.ts +2 -0
- package/build/prompts/index.test.d.ts.map +1 -0
- package/build/prompts/index.test.js +25 -0
- package/build/prompts/index.test.js.map +1 -0
- package/build/prompts/loader.d.ts +25 -0
- package/build/prompts/loader.d.ts.map +1 -0
- package/build/prompts/loader.js +81 -0
- package/build/prompts/loader.js.map +1 -0
- package/build/prompts/loader.test.d.ts +2 -0
- package/build/prompts/loader.test.d.ts.map +1 -0
- package/build/prompts/loader.test.js +417 -0
- package/build/prompts/loader.test.js.map +1 -0
- package/build/prompts/template.d.ts +20 -0
- package/build/prompts/template.d.ts.map +1 -0
- package/build/prompts/template.js +52 -0
- package/build/prompts/template.js.map +1 -0
- package/build/prompts/template.test.d.ts +2 -0
- package/build/prompts/template.test.d.ts.map +1 -0
- package/build/prompts/template.test.js +163 -0
- package/build/prompts/template.test.js.map +1 -0
- package/build/prompts/types.d.ts +34 -0
- package/build/prompts/types.d.ts.map +1 -0
- package/build/prompts/types.js +5 -0
- package/build/prompts/types.js.map +1 -0
- package/package.json +1 -1
- package/prompts.example.json +96 -0
- package/src/index.ts +639 -547
- package/src/prompts/index.test.ts +29 -0
- package/src/prompts/index.ts +7 -0
- package/src/prompts/loader.test.ts +494 -0
- package/src/prompts/loader.ts +90 -0
- package/src/prompts/template.test.ts +212 -0
- package/src/prompts/template.ts +69 -0
- 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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
.
|
|
131
|
-
.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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: `
|
|
400
|
+
text: `Successfully added ${documents.length} document(s) to collection "${collection}".`,
|
|
380
401
|
},
|
|
381
402
|
],
|
|
382
|
-
isError: true,
|
|
383
403
|
};
|
|
384
404
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
})
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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: `
|
|
451
|
+
text: `Collection "${name}" deleted successfully.`,
|
|
435
452
|
},
|
|
436
453
|
],
|
|
437
|
-
isError: true,
|
|
438
454
|
};
|
|
439
455
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
463
|
+
text: JSON.stringify(info, null, 2),
|
|
510
464
|
},
|
|
511
465
|
],
|
|
512
|
-
isError: true,
|
|
513
466
|
};
|
|
514
467
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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: `
|
|
475
|
+
text: `Successfully deleted ${ids.length} document(s) from collection "${collection}".`,
|
|
523
476
|
},
|
|
524
477
|
],
|
|
525
|
-
isError: true,
|
|
526
478
|
};
|
|
527
479
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
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: "
|
|
620
|
-
text:
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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:
|
|
652
|
-
reservoirRefreshAmount:
|
|
653
|
-
reservoirRefreshInterval:
|
|
654
|
-
maxConcurrent:
|
|
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 >
|
|
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
|
-
},
|
|
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
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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,
|
|
804
|
+
sessionIdGenerator: undefined,
|
|
725
805
|
enableJsonResponse: true,
|
|
726
806
|
});
|
|
727
|
-
//
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
//
|
|
762
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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
|
|
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
|
-
},
|
|
872
|
+
}, SHUTDOWN_GRACE_PERIOD_MS);
|
|
809
873
|
httpServer.close(() => {
|
|
810
874
|
clearTimeout(forceTimeout);
|
|
811
875
|
console.error("HTTP server closed");
|