@mhalder/qdrant-mcp-server 1.1.1 → 1.3.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.
@@ -1,11 +1,12 @@
1
- import { QdrantClient } from '@qdrant/js-client-rest';
2
- import { createHash } from 'crypto';
1
+ import { createHash } from "node:crypto";
2
+ import { QdrantClient } from "@qdrant/js-client-rest";
3
3
 
4
4
  export interface CollectionInfo {
5
5
  name: string;
6
6
  vectorSize: number;
7
7
  pointsCount: number;
8
- distance: 'Cosine' | 'Euclid' | 'Dot';
8
+ distance: "Cosine" | "Euclid" | "Dot";
9
+ hybridEnabled?: boolean;
9
10
  }
10
11
 
11
12
  export interface SearchResult {
@@ -14,10 +15,15 @@ export interface SearchResult {
14
15
  payload?: Record<string, any>;
15
16
  }
16
17
 
18
+ export interface SparseVector {
19
+ indices: number[];
20
+ values: number[];
21
+ }
22
+
17
23
  export class QdrantManager {
18
24
  private client: QdrantClient;
19
25
 
20
- constructor(url: string = 'http://localhost:6333') {
26
+ constructor(url: string = "http://localhost:6333") {
21
27
  this.client = new QdrantClient({ url });
22
28
  }
23
29
 
@@ -26,7 +32,7 @@ export class QdrantManager {
26
32
  * Qdrant requires string IDs to be in UUID format.
27
33
  */
28
34
  private normalizeId(id: string | number): string | number {
29
- if (typeof id === 'number') {
35
+ if (typeof id === "number") {
30
36
  return id;
31
37
  }
32
38
 
@@ -37,21 +43,40 @@ export class QdrantManager {
37
43
  }
38
44
 
39
45
  // Convert arbitrary string to deterministic UUID v5-like format
40
- const hash = createHash('sha256').update(id).digest('hex');
46
+ const hash = createHash("sha256").update(id).digest("hex");
41
47
  return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`;
42
48
  }
43
49
 
44
50
  async createCollection(
45
51
  name: string,
46
52
  vectorSize: number,
47
- distance: 'Cosine' | 'Euclid' | 'Dot' = 'Cosine'
53
+ distance: "Cosine" | "Euclid" | "Dot" = "Cosine",
54
+ enableSparse: boolean = false
48
55
  ): Promise<void> {
49
- await this.client.createCollection(name, {
50
- vectors: {
56
+ const config: any = {};
57
+
58
+ // When hybrid search is enabled, use named vectors
59
+ if (enableSparse) {
60
+ config.vectors = {
61
+ dense: {
62
+ size: vectorSize,
63
+ distance,
64
+ },
65
+ };
66
+ config.sparse_vectors = {
67
+ text: {
68
+ modifier: "idf",
69
+ },
70
+ };
71
+ } else {
72
+ // Standard unnamed vector configuration
73
+ config.vectors = {
51
74
  size: vectorSize,
52
75
  distance,
53
- },
54
- });
76
+ };
77
+ }
78
+
79
+ await this.client.createCollection(name, config);
55
80
  }
56
81
 
57
82
  async collectionExists(name: string): Promise<boolean> {
@@ -74,11 +99,25 @@ export class QdrantManager {
74
99
 
75
100
  // Handle both named and unnamed vector configurations
76
101
  let size = 0;
77
- let distance: 'Cosine' | 'Euclid' | 'Dot' = 'Cosine';
102
+ let distance: "Cosine" | "Euclid" | "Dot" = "Cosine";
103
+ let hybridEnabled = false;
78
104
 
79
- if (typeof vectorConfig === 'object' && vectorConfig !== null && 'size' in vectorConfig) {
80
- size = typeof vectorConfig.size === 'number' ? vectorConfig.size : 0;
81
- distance = vectorConfig.distance as 'Cosine' | 'Euclid' | 'Dot';
105
+ // Check if sparse vectors are configured
106
+ if (info.config.params.sparse_vectors) {
107
+ hybridEnabled = true;
108
+ }
109
+
110
+ if (typeof vectorConfig === "object" && vectorConfig !== null) {
111
+ // Check for unnamed vector config (has 'size' directly)
112
+ if ("size" in vectorConfig) {
113
+ size = typeof vectorConfig.size === "number" ? vectorConfig.size : 0;
114
+ distance = vectorConfig.distance as "Cosine" | "Euclid" | "Dot";
115
+ } else if ("dense" in vectorConfig) {
116
+ // Named vector config for hybrid search
117
+ const denseConfig = vectorConfig.dense as any;
118
+ size = typeof denseConfig.size === "number" ? denseConfig.size : 0;
119
+ distance = denseConfig.distance as "Cosine" | "Euclid" | "Dot";
120
+ }
82
121
  }
83
122
 
84
123
  return {
@@ -86,6 +125,7 @@ export class QdrantManager {
86
125
  vectorSize: size,
87
126
  pointsCount: info.points_count || 0,
88
127
  distance,
128
+ hybridEnabled,
89
129
  };
90
130
  }
91
131
 
@@ -103,7 +143,7 @@ export class QdrantManager {
103
143
  ): Promise<void> {
104
144
  try {
105
145
  // Normalize all IDs to ensure string IDs are in UUID format
106
- const normalizedPoints = points.map(point => ({
146
+ const normalizedPoints = points.map((point) => ({
107
147
  ...point,
108
148
  id: this.normalizeId(point.id),
109
149
  }));
@@ -144,8 +184,11 @@ export class QdrantManager {
144
184
  }
145
185
  }
146
186
 
187
+ // Check if collection uses named vectors (hybrid mode)
188
+ const collectionInfo = await this.getCollectionInfo(collectionName);
189
+
147
190
  const results = await this.client.search(collectionName, {
148
- vector,
191
+ vector: collectionInfo.hybridEnabled ? { name: "dense", vector } : vector,
149
192
  limit,
150
193
  filter: qdrantFilter,
151
194
  });
@@ -180,16 +223,113 @@ export class QdrantManager {
180
223
  }
181
224
  }
182
225
 
183
- async deletePoints(
184
- collectionName: string,
185
- ids: (string | number)[]
186
- ): Promise<void> {
226
+ async deletePoints(collectionName: string, ids: (string | number)[]): Promise<void> {
187
227
  // Normalize IDs to ensure string IDs are in UUID format
188
- const normalizedIds = ids.map(id => this.normalizeId(id));
228
+ const normalizedIds = ids.map((id) => this.normalizeId(id));
189
229
 
190
230
  await this.client.delete(collectionName, {
191
231
  wait: true,
192
232
  points: normalizedIds,
193
233
  });
194
234
  }
235
+
236
+ /**
237
+ * Performs hybrid search combining semantic vector search with sparse vector (keyword) search
238
+ * using Reciprocal Rank Fusion (RRF) to combine results
239
+ */
240
+ async hybridSearch(
241
+ collectionName: string,
242
+ denseVector: number[],
243
+ sparseVector: SparseVector,
244
+ limit: number = 5,
245
+ filter?: Record<string, any>,
246
+ _semanticWeight: number = 0.7
247
+ ): Promise<SearchResult[]> {
248
+ // Convert simple key-value filter to Qdrant filter format
249
+ let qdrantFilter;
250
+ if (filter && Object.keys(filter).length > 0) {
251
+ if (filter.must || filter.should || filter.must_not) {
252
+ qdrantFilter = filter;
253
+ } else {
254
+ qdrantFilter = {
255
+ must: Object.entries(filter).map(([key, value]) => ({
256
+ key,
257
+ match: { value },
258
+ })),
259
+ };
260
+ }
261
+ }
262
+
263
+ // Calculate prefetch limits based on weights
264
+ // We fetch more results than needed to ensure good fusion results
265
+ const prefetchLimit = Math.max(20, limit * 4);
266
+
267
+ try {
268
+ const results = await this.client.query(collectionName, {
269
+ prefetch: [
270
+ {
271
+ query: denseVector,
272
+ using: "dense",
273
+ limit: prefetchLimit,
274
+ filter: qdrantFilter,
275
+ },
276
+ {
277
+ query: sparseVector,
278
+ using: "text",
279
+ limit: prefetchLimit,
280
+ filter: qdrantFilter,
281
+ },
282
+ ],
283
+ query: {
284
+ fusion: "rrf",
285
+ },
286
+ limit: limit,
287
+ with_payload: true,
288
+ });
289
+
290
+ return results.points.map((result: any) => ({
291
+ id: result.id,
292
+ score: result.score,
293
+ payload: result.payload || undefined,
294
+ }));
295
+ } catch (error: any) {
296
+ const errorMessage = error?.data?.status?.error || error?.message || String(error);
297
+ throw new Error(`Hybrid search failed on collection "${collectionName}": ${errorMessage}`);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Adds points with both dense and sparse vectors for hybrid search
303
+ */
304
+ async addPointsWithSparse(
305
+ collectionName: string,
306
+ points: Array<{
307
+ id: string | number;
308
+ vector: number[];
309
+ sparseVector: SparseVector;
310
+ payload?: Record<string, any>;
311
+ }>
312
+ ): Promise<void> {
313
+ try {
314
+ // Normalize all IDs to ensure string IDs are in UUID format
315
+ const normalizedPoints = points.map((point) => ({
316
+ id: this.normalizeId(point.id),
317
+ vector: {
318
+ dense: point.vector,
319
+ text: point.sparseVector,
320
+ },
321
+ payload: point.payload,
322
+ }));
323
+
324
+ await this.client.upsert(collectionName, {
325
+ wait: true,
326
+ points: normalizedPoints,
327
+ });
328
+ } catch (error: any) {
329
+ const errorMessage = error?.data?.status?.error || error?.message || String(error);
330
+ throw new Error(
331
+ `Failed to add points with sparse vectors to collection "${collectionName}": ${errorMessage}`
332
+ );
333
+ }
334
+ }
195
335
  }
@@ -0,0 +1,202 @@
1
+ import type { Server as HttpServer } from "node:http";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ describe("Transport Configuration", () => {
5
+ let originalEnv: NodeJS.ProcessEnv;
6
+
7
+ beforeEach(() => {
8
+ originalEnv = { ...process.env };
9
+ });
10
+
11
+ afterEach(() => {
12
+ process.env = originalEnv;
13
+ });
14
+
15
+ describe("HTTP Port Validation", () => {
16
+ it("should accept valid port numbers", () => {
17
+ const validPorts = ["1", "80", "443", "3000", "8080", "65535"];
18
+
19
+ validPorts.forEach((port) => {
20
+ const parsed = parseInt(port, 10);
21
+ expect(parsed).toBeGreaterThanOrEqual(1);
22
+ expect(parsed).toBeLessThanOrEqual(65535);
23
+ expect(Number.isNaN(parsed)).toBe(false);
24
+ });
25
+ });
26
+
27
+ it("should reject invalid port numbers", () => {
28
+ const invalidCases = [
29
+ { port: "0", reason: "port 0 is reserved" },
30
+ { port: "-1", reason: "negative ports are invalid" },
31
+ { port: "65536", reason: "exceeds maximum port" },
32
+ { port: "99999", reason: "exceeds maximum port" },
33
+ { port: "abc", reason: "non-numeric input" },
34
+ { port: "", reason: "empty string" },
35
+ ];
36
+
37
+ invalidCases.forEach(({ port, reason }) => {
38
+ const parsed = parseInt(port, 10);
39
+ const isValid = !Number.isNaN(parsed) && parsed >= 1 && parsed <= 65535;
40
+ expect(isValid, `Failed for ${port}: ${reason}`).toBe(false);
41
+ });
42
+ });
43
+
44
+ it("should use default port 3000 when HTTP_PORT is not set", () => {
45
+ const port = parseInt(process.env.HTTP_PORT || "3000", 10);
46
+ expect(port).toBe(3000);
47
+ });
48
+
49
+ it("should parse HTTP_PORT from environment", () => {
50
+ process.env.HTTP_PORT = "8080";
51
+ const port = parseInt(process.env.HTTP_PORT || "3000", 10);
52
+ expect(port).toBe(8080);
53
+ });
54
+ });
55
+
56
+ describe("Transport Mode Validation", () => {
57
+ it("should accept valid transport modes", () => {
58
+ const validModes = ["stdio", "http", "STDIO", "HTTP"];
59
+
60
+ validModes.forEach((mode) => {
61
+ const normalized = mode.toLowerCase();
62
+ expect(["stdio", "http"]).toContain(normalized);
63
+ });
64
+ });
65
+
66
+ it("should reject invalid transport modes", () => {
67
+ const invalidModes = ["tcp", "websocket", "grpc", ""];
68
+
69
+ invalidModes.forEach((mode) => {
70
+ const normalized = mode.toLowerCase();
71
+ expect(["stdio", "http"]).not.toContain(normalized);
72
+ });
73
+ });
74
+
75
+ it("should default to stdio when TRANSPORT_MODE is not set", () => {
76
+ const mode = (process.env.TRANSPORT_MODE || "stdio").toLowerCase();
77
+ expect(mode).toBe("stdio");
78
+ });
79
+ });
80
+
81
+ describe("Request Size Limits", () => {
82
+ it("should define request size limit for HTTP transport", () => {
83
+ const limit = "10mb";
84
+ expect(limit).toMatch(/^\d+mb$/);
85
+ });
86
+
87
+ it("should parse size limit correctly", () => {
88
+ const limit = "10mb";
89
+ const sizeInBytes = parseInt(limit, 10) * 1024 * 1024;
90
+ expect(sizeInBytes).toBe(10485760);
91
+ });
92
+ });
93
+ });
94
+
95
+ describe("HTTP Server Configuration", () => {
96
+ describe("Graceful Shutdown", () => {
97
+ it("should handle shutdown signals", async () => {
98
+ const mockServer = {
99
+ close: vi.fn((callback) => {
100
+ if (callback) callback();
101
+ }),
102
+ } as unknown as HttpServer;
103
+
104
+ const shutdown = () => {
105
+ mockServer.close(() => {
106
+ // Shutdown callback
107
+ });
108
+ };
109
+
110
+ shutdown();
111
+ expect(mockServer.close).toHaveBeenCalled();
112
+ });
113
+
114
+ it("should support timeout for forced shutdown", () => {
115
+ vi.useFakeTimers();
116
+
117
+ let forcedShutdown = false;
118
+ const timeout = setTimeout(() => {
119
+ forcedShutdown = true;
120
+ }, 10000);
121
+
122
+ vi.advanceTimersByTime(9999);
123
+ expect(forcedShutdown).toBe(false);
124
+
125
+ vi.advanceTimersByTime(1);
126
+ expect(forcedShutdown).toBe(true);
127
+
128
+ clearTimeout(timeout);
129
+ vi.useRealTimers();
130
+ });
131
+ });
132
+
133
+ describe("Error Handling", () => {
134
+ it("should return JSON-RPC 2.0 error format", () => {
135
+ const error = {
136
+ jsonrpc: "2.0",
137
+ error: {
138
+ code: -32603,
139
+ message: "Internal server error",
140
+ },
141
+ id: null,
142
+ };
143
+
144
+ expect(error.jsonrpc).toBe("2.0");
145
+ expect(error.error.code).toBe(-32603);
146
+ expect(error.error.message).toBeTruthy();
147
+ expect(error.id).toBeNull();
148
+ });
149
+
150
+ it("should use standard JSON-RPC error codes", () => {
151
+ const errorCodes = {
152
+ parseError: -32700,
153
+ invalidRequest: -32600,
154
+ methodNotFound: -32601,
155
+ invalidParams: -32602,
156
+ internalError: -32603,
157
+ };
158
+
159
+ expect(errorCodes.parseError).toBe(-32700);
160
+ expect(errorCodes.invalidRequest).toBe(-32600);
161
+ expect(errorCodes.methodNotFound).toBe(-32601);
162
+ expect(errorCodes.invalidParams).toBe(-32602);
163
+ expect(errorCodes.internalError).toBe(-32603);
164
+ });
165
+ });
166
+ });
167
+
168
+ describe("Transport Lifecycle", () => {
169
+ it("should close transport on response close", () => {
170
+ const mockTransport = {
171
+ close: vi.fn(),
172
+ };
173
+
174
+ const mockResponse = {
175
+ on: vi.fn((event, callback) => {
176
+ if (event === "close") {
177
+ callback();
178
+ }
179
+ }),
180
+ };
181
+
182
+ mockResponse.on("close", () => {
183
+ mockTransport.close();
184
+ });
185
+
186
+ expect(mockTransport.close).toHaveBeenCalled();
187
+ });
188
+
189
+ it("should handle transport connection", async () => {
190
+ const mockServer = {
191
+ connect: vi.fn().mockResolvedValue(undefined),
192
+ };
193
+
194
+ const mockTransport = {
195
+ handleRequest: vi.fn().mockResolvedValue(undefined),
196
+ close: vi.fn(),
197
+ };
198
+
199
+ await mockServer.connect(mockTransport);
200
+ expect(mockServer.connect).toHaveBeenCalledWith(mockTransport);
201
+ });
202
+ });
package/vitest.config.ts CHANGED
@@ -14,10 +14,10 @@ export default defineConfig({
14
14
  "**/*.test.ts",
15
15
  "**/*.spec.ts",
16
16
  "vitest.config.ts",
17
- "src/index.ts", // MCP server SDK integration - tested via integration
18
- "scripts/**", // Exclude utility scripts from coverage
17
+ "commitlint.config.js",
18
+ "src/index.ts",
19
+ "scripts/**",
19
20
  ],
20
- // Set thresholds for core business logic modules
21
21
  thresholds: {
22
22
  "src/qdrant/client.ts": {
23
23
  lines: 90,