@falkordb/mcpserver 1.0.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/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/dist/config/index.js +27 -0
- package/dist/config/index.test.js +23 -0
- package/dist/errors/AppError.js +27 -0
- package/dist/errors/ErrorHandler.js +46 -0
- package/dist/errors/ErrorHandler.test.js +146 -0
- package/dist/index.js +234 -0
- package/dist/mcp/prompts.js +229 -0
- package/dist/mcp/resources.js +26 -0
- package/dist/mcp/tools.js +258 -0
- package/dist/models/mcp-client-config.js +34 -0
- package/dist/models/mcp-client-config.test.js +173 -0
- package/dist/models/mcp.types.js +4 -0
- package/dist/services/falkordb.service.js +175 -0
- package/dist/services/falkordb.service.test.js +489 -0
- package/dist/services/logger.service.js +151 -0
- package/dist/services/logger.service.test.js +115 -0
- package/dist/services/redis.service.js +179 -0
- package/dist/services/redis.service.test.js +399 -0
- package/dist/utils/connection-parser.js +71 -0
- package/dist/utils/connection-parser.test.js +232 -0
- package/package.json +99 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { falkorDBService } from './falkordb.service';
|
|
2
|
+
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
3
|
+
// Mock the logger service
|
|
4
|
+
jest.mock('./logger.service.js', () => ({
|
|
5
|
+
logger: {
|
|
6
|
+
info: jest.fn().mockResolvedValue(undefined),
|
|
7
|
+
warn: jest.fn().mockResolvedValue(undefined),
|
|
8
|
+
error: jest.fn().mockResolvedValue(undefined),
|
|
9
|
+
debug: jest.fn().mockResolvedValue(undefined),
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
// Mock the config
|
|
13
|
+
jest.mock('../config/index.js', () => ({
|
|
14
|
+
config: {
|
|
15
|
+
falkorDB: {
|
|
16
|
+
host: 'localhost',
|
|
17
|
+
port: 6379,
|
|
18
|
+
username: 'testuser',
|
|
19
|
+
password: 'testpass',
|
|
20
|
+
defaultReadOnly: false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}));
|
|
24
|
+
// Mock the FalkorDB library
|
|
25
|
+
jest.mock('falkordb', () => {
|
|
26
|
+
const mockSelectGraph = jest.fn();
|
|
27
|
+
const mockQuery = jest.fn();
|
|
28
|
+
const mockRoQuery = jest.fn();
|
|
29
|
+
const mockList = jest.fn();
|
|
30
|
+
const mockClose = jest.fn();
|
|
31
|
+
const mockPing = jest.fn();
|
|
32
|
+
const mockDelete = jest.fn();
|
|
33
|
+
return {
|
|
34
|
+
FalkorDB: {
|
|
35
|
+
connect: jest.fn().mockResolvedValue({
|
|
36
|
+
connection: Promise.resolve({
|
|
37
|
+
ping: mockPing
|
|
38
|
+
}),
|
|
39
|
+
selectGraph: mockSelectGraph.mockReturnValue({
|
|
40
|
+
query: mockQuery,
|
|
41
|
+
roQuery: mockRoQuery,
|
|
42
|
+
delete: mockDelete
|
|
43
|
+
}),
|
|
44
|
+
list: mockList,
|
|
45
|
+
close: mockClose
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
mockSelectGraph,
|
|
49
|
+
mockQuery,
|
|
50
|
+
mockRoQuery,
|
|
51
|
+
mockList,
|
|
52
|
+
mockClose,
|
|
53
|
+
mockPing,
|
|
54
|
+
mockDelete
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
describe('FalkorDB Service', () => {
|
|
58
|
+
let mockFalkorDB;
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
// Access the mocks
|
|
61
|
+
mockFalkorDB = await import('falkordb');
|
|
62
|
+
});
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
jest.clearAllMocks();
|
|
65
|
+
// Reset service state
|
|
66
|
+
falkorDBService.client = null;
|
|
67
|
+
falkorDBService.retryCount = 0;
|
|
68
|
+
falkorDBService.initializingPromise = null;
|
|
69
|
+
});
|
|
70
|
+
describe('initialize', () => {
|
|
71
|
+
it('should successfully initialize and connect to FalkorDB', async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
mockFalkorDB.FalkorDB.connect.mockResolvedValue({
|
|
74
|
+
connection: Promise.resolve({
|
|
75
|
+
ping: mockFalkorDB.mockPing.mockResolvedValue('PONG')
|
|
76
|
+
}),
|
|
77
|
+
selectGraph: mockFalkorDB.mockSelectGraph,
|
|
78
|
+
list: mockFalkorDB.mockList,
|
|
79
|
+
close: mockFalkorDB.mockClose
|
|
80
|
+
});
|
|
81
|
+
// Act
|
|
82
|
+
await falkorDBService.initialize();
|
|
83
|
+
// Assert
|
|
84
|
+
expect(mockFalkorDB.FalkorDB.connect).toHaveBeenCalledWith({
|
|
85
|
+
socket: {
|
|
86
|
+
host: 'localhost',
|
|
87
|
+
port: 6379,
|
|
88
|
+
},
|
|
89
|
+
password: 'testpass',
|
|
90
|
+
username: 'testuser',
|
|
91
|
+
});
|
|
92
|
+
expect(mockFalkorDB.mockPing).toHaveBeenCalled();
|
|
93
|
+
expect(falkorDBService.client).not.toBeNull();
|
|
94
|
+
expect(falkorDBService.retryCount).toBe(0);
|
|
95
|
+
expect(falkorDBService.initializingPromise).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
it('should await ongoing initialization if already initializing', async () => {
|
|
98
|
+
// Arrange
|
|
99
|
+
mockFalkorDB.FalkorDB.connect.mockResolvedValue({
|
|
100
|
+
connection: Promise.resolve({
|
|
101
|
+
ping: mockFalkorDB.mockPing.mockResolvedValue('PONG')
|
|
102
|
+
}),
|
|
103
|
+
selectGraph: mockFalkorDB.mockSelectGraph,
|
|
104
|
+
list: mockFalkorDB.mockList,
|
|
105
|
+
close: mockFalkorDB.mockClose
|
|
106
|
+
});
|
|
107
|
+
// Act - start two initializations concurrently
|
|
108
|
+
const init1 = falkorDBService.initialize();
|
|
109
|
+
const init2 = falkorDBService.initialize();
|
|
110
|
+
await Promise.all([init1, init2]);
|
|
111
|
+
// Assert - connect should only be called once
|
|
112
|
+
expect(mockFalkorDB.FalkorDB.connect).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(falkorDBService.client).not.toBeNull();
|
|
114
|
+
});
|
|
115
|
+
it('should retry connection on failure and eventually succeed', async () => {
|
|
116
|
+
// Arrange
|
|
117
|
+
const connectError = new Error('Connection failed');
|
|
118
|
+
mockFalkorDB.FalkorDB.connect
|
|
119
|
+
.mockRejectedValueOnce(connectError)
|
|
120
|
+
.mockRejectedValueOnce(connectError)
|
|
121
|
+
.mockResolvedValueOnce({
|
|
122
|
+
connection: Promise.resolve({
|
|
123
|
+
ping: mockFalkorDB.mockPing.mockResolvedValue('PONG')
|
|
124
|
+
}),
|
|
125
|
+
selectGraph: mockFalkorDB.mockSelectGraph,
|
|
126
|
+
list: mockFalkorDB.mockList,
|
|
127
|
+
close: mockFalkorDB.mockClose
|
|
128
|
+
});
|
|
129
|
+
// Mock setTimeout to avoid actual delays
|
|
130
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
131
|
+
setImmediate(callback);
|
|
132
|
+
return {};
|
|
133
|
+
});
|
|
134
|
+
// Act
|
|
135
|
+
await falkorDBService.initialize();
|
|
136
|
+
// Assert
|
|
137
|
+
expect(mockFalkorDB.FalkorDB.connect).toHaveBeenCalledTimes(3);
|
|
138
|
+
expect(falkorDBService.client).not.toBeNull();
|
|
139
|
+
expect(falkorDBService.retryCount).toBe(0);
|
|
140
|
+
// Cleanup
|
|
141
|
+
setTimeoutSpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
it('should throw AppError after max retries exceeded', async () => {
|
|
144
|
+
// Arrange
|
|
145
|
+
const connectError = new Error('Connection failed');
|
|
146
|
+
mockFalkorDB.FalkorDB.connect.mockRejectedValue(connectError);
|
|
147
|
+
// Mock setTimeout to avoid actual delays
|
|
148
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
149
|
+
setImmediate(callback);
|
|
150
|
+
return {};
|
|
151
|
+
});
|
|
152
|
+
// Act & Assert
|
|
153
|
+
try {
|
|
154
|
+
await falkorDBService.initialize();
|
|
155
|
+
fail('Expected initialize to throw AppError');
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
expect(error).toBeInstanceOf(AppError);
|
|
159
|
+
expect(error.name).toBe(CommonErrors.CONNECTION_FAILED);
|
|
160
|
+
}
|
|
161
|
+
expect(mockFalkorDB.FalkorDB.connect).toHaveBeenCalledTimes(6); // 1 initial + 5 retries
|
|
162
|
+
// Cleanup
|
|
163
|
+
setTimeoutSpy.mockRestore();
|
|
164
|
+
});
|
|
165
|
+
it('should handle ping failure during connection test', async () => {
|
|
166
|
+
// Arrange
|
|
167
|
+
const pingError = new Error('Ping failed');
|
|
168
|
+
mockFalkorDB.FalkorDB.connect.mockResolvedValue({
|
|
169
|
+
connection: Promise.resolve({
|
|
170
|
+
ping: mockFalkorDB.mockPing.mockRejectedValue(pingError)
|
|
171
|
+
}),
|
|
172
|
+
selectGraph: mockFalkorDB.mockSelectGraph,
|
|
173
|
+
list: mockFalkorDB.mockList,
|
|
174
|
+
close: mockFalkorDB.mockClose
|
|
175
|
+
});
|
|
176
|
+
// Mock setTimeout to avoid actual delays
|
|
177
|
+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
178
|
+
setImmediate(callback);
|
|
179
|
+
return {};
|
|
180
|
+
});
|
|
181
|
+
// Act & Assert
|
|
182
|
+
await expect(falkorDBService.initialize()).rejects.toThrow(AppError);
|
|
183
|
+
// Cleanup
|
|
184
|
+
setTimeoutSpy.mockRestore();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('executeQuery', () => {
|
|
188
|
+
it('should execute a query on the specified graph', async () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
const graphName = 'testGraph';
|
|
191
|
+
const query = 'MATCH (n) RETURN n';
|
|
192
|
+
const params = { param1: 'value1' };
|
|
193
|
+
const expectedResult = { records: [{ id: 1 }] };
|
|
194
|
+
mockFalkorDB.mockQuery.mockResolvedValue(expectedResult);
|
|
195
|
+
// Force client to be available
|
|
196
|
+
falkorDBService.client = {
|
|
197
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
198
|
+
};
|
|
199
|
+
// Act
|
|
200
|
+
const result = await falkorDBService.executeQuery(graphName, query, params);
|
|
201
|
+
// Assert
|
|
202
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
203
|
+
expect(mockFalkorDB.mockQuery).toHaveBeenCalledWith(query, params);
|
|
204
|
+
expect(mockFalkorDB.mockRoQuery).not.toHaveBeenCalled();
|
|
205
|
+
expect(result).toEqual(expectedResult);
|
|
206
|
+
});
|
|
207
|
+
it('should execute a query without params', async () => {
|
|
208
|
+
// Arrange
|
|
209
|
+
const graphName = 'testGraph';
|
|
210
|
+
const query = 'MATCH (n) RETURN n';
|
|
211
|
+
const expectedResult = { records: [{ id: 1 }] };
|
|
212
|
+
mockFalkorDB.mockQuery.mockResolvedValue(expectedResult);
|
|
213
|
+
// Force client to be available
|
|
214
|
+
falkorDBService.client = {
|
|
215
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
216
|
+
};
|
|
217
|
+
// Act
|
|
218
|
+
const result = await falkorDBService.executeQuery(graphName, query);
|
|
219
|
+
// Assert
|
|
220
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
221
|
+
expect(mockFalkorDB.mockQuery).toHaveBeenCalledWith(query, undefined);
|
|
222
|
+
expect(result).toEqual(expectedResult);
|
|
223
|
+
});
|
|
224
|
+
it('should execute a read-only query when readOnly flag is true', async () => {
|
|
225
|
+
// Arrange
|
|
226
|
+
const graphName = 'testGraph';
|
|
227
|
+
const query = 'MATCH (n) RETURN n';
|
|
228
|
+
const params = { param1: 'value1' };
|
|
229
|
+
const expectedResult = { records: [{ id: 1 }] };
|
|
230
|
+
mockFalkorDB.mockRoQuery.mockResolvedValue(expectedResult);
|
|
231
|
+
// Force client to be available
|
|
232
|
+
falkorDBService.client = {
|
|
233
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
234
|
+
};
|
|
235
|
+
// Act
|
|
236
|
+
const result = await falkorDBService.executeQuery(graphName, query, params, true);
|
|
237
|
+
// Assert
|
|
238
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
239
|
+
expect(mockFalkorDB.mockRoQuery).toHaveBeenCalledWith(query, params);
|
|
240
|
+
expect(mockFalkorDB.mockQuery).not.toHaveBeenCalled();
|
|
241
|
+
expect(result).toEqual(expectedResult);
|
|
242
|
+
});
|
|
243
|
+
it('should execute a read-only query without params', async () => {
|
|
244
|
+
// Arrange
|
|
245
|
+
const graphName = 'testGraph';
|
|
246
|
+
const query = 'MATCH (n) RETURN n';
|
|
247
|
+
const expectedResult = { records: [{ id: 1 }] };
|
|
248
|
+
mockFalkorDB.mockRoQuery.mockResolvedValue(expectedResult);
|
|
249
|
+
// Force client to be available
|
|
250
|
+
falkorDBService.client = {
|
|
251
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
252
|
+
};
|
|
253
|
+
// Act
|
|
254
|
+
const result = await falkorDBService.executeQuery(graphName, query, undefined, true);
|
|
255
|
+
// Assert
|
|
256
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
257
|
+
expect(mockFalkorDB.mockRoQuery).toHaveBeenCalledWith(query, undefined);
|
|
258
|
+
expect(mockFalkorDB.mockQuery).not.toHaveBeenCalled();
|
|
259
|
+
expect(result).toEqual(expectedResult);
|
|
260
|
+
});
|
|
261
|
+
it('should throw AppError if client is not initialized', async () => {
|
|
262
|
+
// Arrange
|
|
263
|
+
falkorDBService.client = null;
|
|
264
|
+
// Act & Assert
|
|
265
|
+
await expect(falkorDBService.executeQuery('graph', 'query'))
|
|
266
|
+
.rejects
|
|
267
|
+
.toThrow(AppError);
|
|
268
|
+
await expect(falkorDBService.executeQuery('graph', 'query'))
|
|
269
|
+
.rejects
|
|
270
|
+
.toThrow('FalkorDB client not initialized');
|
|
271
|
+
});
|
|
272
|
+
it('should throw AppError when query execution fails', async () => {
|
|
273
|
+
// Arrange
|
|
274
|
+
const graphName = 'testGraph';
|
|
275
|
+
const query = 'INVALID QUERY';
|
|
276
|
+
const queryError = new Error('Query syntax error');
|
|
277
|
+
mockFalkorDB.mockQuery.mockRejectedValue(queryError);
|
|
278
|
+
// Force client to be available
|
|
279
|
+
falkorDBService.client = {
|
|
280
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
281
|
+
};
|
|
282
|
+
// Act & Assert
|
|
283
|
+
try {
|
|
284
|
+
await falkorDBService.executeQuery(graphName, query);
|
|
285
|
+
fail('Expected executeQuery to throw AppError');
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
expect(error).toBeInstanceOf(AppError);
|
|
289
|
+
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
it('should throw AppError when read-only query execution fails', async () => {
|
|
293
|
+
// Arrange
|
|
294
|
+
const graphName = 'testGraph';
|
|
295
|
+
const query = 'CREATE (n) RETURN n';
|
|
296
|
+
const queryError = new Error('Write operations not allowed in read-only mode');
|
|
297
|
+
mockFalkorDB.mockRoQuery.mockRejectedValue(queryError);
|
|
298
|
+
// Force client to be available
|
|
299
|
+
falkorDBService.client = {
|
|
300
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
301
|
+
};
|
|
302
|
+
// Act & Assert
|
|
303
|
+
try {
|
|
304
|
+
await falkorDBService.executeQuery(graphName, query, undefined, true);
|
|
305
|
+
fail('Expected executeQuery to throw AppError');
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
expect(error).toBeInstanceOf(AppError);
|
|
309
|
+
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
310
|
+
expect(error.message).toContain('read-only');
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
describe('executeReadOnlyQuery', () => {
|
|
315
|
+
it('should execute a read-only query using ro_query', async () => {
|
|
316
|
+
// Arrange
|
|
317
|
+
const graphName = 'testGraph';
|
|
318
|
+
const query = 'MATCH (n) RETURN n';
|
|
319
|
+
const params = { param1: 'value1' };
|
|
320
|
+
const expectedResult = { records: [{ id: 1 }] };
|
|
321
|
+
mockFalkorDB.mockRoQuery.mockResolvedValue(expectedResult);
|
|
322
|
+
// Force client to be available
|
|
323
|
+
falkorDBService.client = {
|
|
324
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
325
|
+
};
|
|
326
|
+
// Act
|
|
327
|
+
const result = await falkorDBService.executeReadOnlyQuery(graphName, query, params);
|
|
328
|
+
// Assert
|
|
329
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
330
|
+
expect(mockFalkorDB.mockRoQuery).toHaveBeenCalledWith(query, params);
|
|
331
|
+
expect(mockFalkorDB.mockQuery).not.toHaveBeenCalled();
|
|
332
|
+
expect(result).toEqual(expectedResult);
|
|
333
|
+
});
|
|
334
|
+
it('should throw AppError if client is not initialized', async () => {
|
|
335
|
+
// Arrange
|
|
336
|
+
falkorDBService.client = null;
|
|
337
|
+
// Act & Assert
|
|
338
|
+
await expect(falkorDBService.executeReadOnlyQuery('graph', 'query'))
|
|
339
|
+
.rejects
|
|
340
|
+
.toThrow(AppError);
|
|
341
|
+
await expect(falkorDBService.executeReadOnlyQuery('graph', 'query'))
|
|
342
|
+
.rejects
|
|
343
|
+
.toThrow('FalkorDB client not initialized');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
describe('listGraphs', () => {
|
|
347
|
+
it('should return a list of graphs', async () => {
|
|
348
|
+
// Arrange
|
|
349
|
+
const expectedGraphs = ['graph1', 'graph2'];
|
|
350
|
+
mockFalkorDB.mockList.mockResolvedValue(expectedGraphs);
|
|
351
|
+
// Force client to be available
|
|
352
|
+
falkorDBService.client = {
|
|
353
|
+
list: mockFalkorDB.mockList
|
|
354
|
+
};
|
|
355
|
+
// Act
|
|
356
|
+
const result = await falkorDBService.listGraphs();
|
|
357
|
+
// Assert
|
|
358
|
+
expect(mockFalkorDB.mockList).toHaveBeenCalled();
|
|
359
|
+
expect(result).toEqual(expectedGraphs);
|
|
360
|
+
});
|
|
361
|
+
it('should return empty array when no graphs exist', async () => {
|
|
362
|
+
// Arrange
|
|
363
|
+
const expectedGraphs = [];
|
|
364
|
+
mockFalkorDB.mockList.mockResolvedValue(expectedGraphs);
|
|
365
|
+
// Force client to be available
|
|
366
|
+
falkorDBService.client = {
|
|
367
|
+
list: mockFalkorDB.mockList
|
|
368
|
+
};
|
|
369
|
+
// Act
|
|
370
|
+
const result = await falkorDBService.listGraphs();
|
|
371
|
+
// Assert
|
|
372
|
+
expect(mockFalkorDB.mockList).toHaveBeenCalled();
|
|
373
|
+
expect(result).toEqual(expectedGraphs);
|
|
374
|
+
});
|
|
375
|
+
it('should throw AppError if client is not initialized', async () => {
|
|
376
|
+
// Arrange
|
|
377
|
+
falkorDBService.client = null;
|
|
378
|
+
// Act & Assert
|
|
379
|
+
await expect(falkorDBService.listGraphs())
|
|
380
|
+
.rejects
|
|
381
|
+
.toThrow(AppError);
|
|
382
|
+
await expect(falkorDBService.listGraphs())
|
|
383
|
+
.rejects
|
|
384
|
+
.toThrow('FalkorDB client not initialized');
|
|
385
|
+
});
|
|
386
|
+
it('should throw AppError when listing graphs fails', async () => {
|
|
387
|
+
// Arrange
|
|
388
|
+
const listError = new Error('Database connection lost');
|
|
389
|
+
mockFalkorDB.mockList.mockRejectedValue(listError);
|
|
390
|
+
// Force client to be available
|
|
391
|
+
falkorDBService.client = {
|
|
392
|
+
list: mockFalkorDB.mockList
|
|
393
|
+
};
|
|
394
|
+
// Act & Assert
|
|
395
|
+
try {
|
|
396
|
+
await falkorDBService.listGraphs();
|
|
397
|
+
fail('Expected listGraphs to throw AppError');
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
expect(error).toBeInstanceOf(AppError);
|
|
401
|
+
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
describe('deleteGraph', () => {
|
|
406
|
+
it('should delete a graph successfully', async () => {
|
|
407
|
+
// Arrange
|
|
408
|
+
const graphName = 'testGraph';
|
|
409
|
+
mockFalkorDB.mockDelete.mockResolvedValue(undefined);
|
|
410
|
+
// Force client to be available
|
|
411
|
+
falkorDBService.client = {
|
|
412
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
413
|
+
};
|
|
414
|
+
// Act
|
|
415
|
+
await falkorDBService.deleteGraph(graphName);
|
|
416
|
+
// Assert
|
|
417
|
+
expect(mockFalkorDB.mockSelectGraph).toHaveBeenCalledWith(graphName);
|
|
418
|
+
expect(mockFalkorDB.mockDelete).toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
it('should throw AppError if client is not initialized', async () => {
|
|
421
|
+
// Arrange
|
|
422
|
+
falkorDBService.client = null;
|
|
423
|
+
// Act & Assert
|
|
424
|
+
await expect(falkorDBService.deleteGraph('testGraph'))
|
|
425
|
+
.rejects
|
|
426
|
+
.toThrow(AppError);
|
|
427
|
+
await expect(falkorDBService.deleteGraph('testGraph'))
|
|
428
|
+
.rejects
|
|
429
|
+
.toThrow('FalkorDB client not initialized');
|
|
430
|
+
});
|
|
431
|
+
it('should throw AppError when delete operation fails', async () => {
|
|
432
|
+
// Arrange
|
|
433
|
+
const graphName = 'testGraph';
|
|
434
|
+
const deleteError = new Error('Graph not found');
|
|
435
|
+
mockFalkorDB.mockDelete.mockRejectedValue(deleteError);
|
|
436
|
+
// Force client to be available
|
|
437
|
+
falkorDBService.client = {
|
|
438
|
+
selectGraph: mockFalkorDB.mockSelectGraph
|
|
439
|
+
};
|
|
440
|
+
// Act & Assert
|
|
441
|
+
try {
|
|
442
|
+
await falkorDBService.deleteGraph(graphName);
|
|
443
|
+
fail('Expected deleteGraph to throw AppError');
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
expect(error).toBeInstanceOf(AppError);
|
|
447
|
+
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
describe('close', () => {
|
|
452
|
+
it('should close the client connection successfully', async () => {
|
|
453
|
+
// Arrange
|
|
454
|
+
mockFalkorDB.mockClose.mockResolvedValue(undefined);
|
|
455
|
+
falkorDBService.client = {
|
|
456
|
+
close: mockFalkorDB.mockClose
|
|
457
|
+
};
|
|
458
|
+
falkorDBService.retryCount = 3;
|
|
459
|
+
// Act
|
|
460
|
+
await falkorDBService.close();
|
|
461
|
+
// Assert
|
|
462
|
+
expect(mockFalkorDB.mockClose).toHaveBeenCalled();
|
|
463
|
+
expect(falkorDBService.client).toBeNull();
|
|
464
|
+
expect(falkorDBService.retryCount).toBe(0);
|
|
465
|
+
});
|
|
466
|
+
it('should handle close error gracefully', async () => {
|
|
467
|
+
// Arrange
|
|
468
|
+
const closeError = new Error('Close failed');
|
|
469
|
+
mockFalkorDB.mockClose.mockRejectedValue(closeError);
|
|
470
|
+
falkorDBService.client = {
|
|
471
|
+
close: mockFalkorDB.mockClose
|
|
472
|
+
};
|
|
473
|
+
falkorDBService.retryCount = 2;
|
|
474
|
+
// Act
|
|
475
|
+
await falkorDBService.close();
|
|
476
|
+
// Assert
|
|
477
|
+
expect(mockFalkorDB.mockClose).toHaveBeenCalled();
|
|
478
|
+
expect(falkorDBService.client).toBeNull();
|
|
479
|
+
expect(falkorDBService.retryCount).toBe(0);
|
|
480
|
+
});
|
|
481
|
+
it('should not throw if client is already null', async () => {
|
|
482
|
+
// Arrange
|
|
483
|
+
falkorDBService.client = null;
|
|
484
|
+
// Act & Assert
|
|
485
|
+
await expect(falkorDBService.close()).resolves.not.toThrow();
|
|
486
|
+
expect(mockFalkorDB.mockClose).not.toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { userLogDir } from 'platformdirs';
|
|
4
|
+
/**
|
|
5
|
+
* Enhanced logging service for MCP servers with both MCP client notifications and file fallback
|
|
6
|
+
* Logs are sent to MCP clients when connected, and always written to files for persistence
|
|
7
|
+
*/
|
|
8
|
+
export class Logger {
|
|
9
|
+
logDir;
|
|
10
|
+
logFile;
|
|
11
|
+
mcpServer;
|
|
12
|
+
constructor() {
|
|
13
|
+
// For MCP servers, we primarily use MCP notifications for logging
|
|
14
|
+
// File logging is optional and only enabled if environment allows it
|
|
15
|
+
this.logDir = userLogDir('falkordb-mcp', 'mulliken-llc', '0.1.0', false, true);
|
|
16
|
+
this.logFile = join(this.logDir, 'falkordb-mcp.log');
|
|
17
|
+
// Only create log directory if we're in development or explicitly enabled
|
|
18
|
+
if (process.env.ENABLE_FILE_LOGGING === 'true' || process.env.NODE_ENV === 'development') {
|
|
19
|
+
try {
|
|
20
|
+
if (!existsSync(this.logDir)) {
|
|
21
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
// If we can't create logs directory, disable file logging
|
|
26
|
+
console.warn('Failed to create log directory:', error instanceof Error ? error.message : String(error));
|
|
27
|
+
this.logFile = '';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Disable file logging by default for MCP servers
|
|
32
|
+
this.logFile = '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Set the MCP server instance to enable client notifications
|
|
37
|
+
* This should be called after the server is created but before starting
|
|
38
|
+
*/
|
|
39
|
+
setMcpServer(server) {
|
|
40
|
+
this.mcpServer = server;
|
|
41
|
+
}
|
|
42
|
+
formatLog(level, message, context) {
|
|
43
|
+
const logEntry = {
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
level,
|
|
46
|
+
message,
|
|
47
|
+
...(context && { context }),
|
|
48
|
+
pid: process.pid
|
|
49
|
+
};
|
|
50
|
+
return JSON.stringify(logEntry) + '\n';
|
|
51
|
+
}
|
|
52
|
+
writeLog(level, message, context) {
|
|
53
|
+
// Guard against file logging when disabled
|
|
54
|
+
if (!this.logFile) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
// Always write to file for persistence
|
|
59
|
+
const logLine = this.formatLog(level, message, context);
|
|
60
|
+
appendFileSync(this.logFile, logLine);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// If we can't log to file, we can't do much about it without breaking MCP
|
|
64
|
+
// In production, consider using a more robust logging solution
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async sendMcpLog(level, message, context) {
|
|
68
|
+
if (!this.mcpServer) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// Format log data for MCP client
|
|
73
|
+
const logData = context ? `${message} | ${JSON.stringify(context)}` : message;
|
|
74
|
+
// Map WARN to warning for MCP spec compliance
|
|
75
|
+
const mcpLevel = level === 'WARN' ? 'warning' : level.toLowerCase();
|
|
76
|
+
// Send notification to MCP client
|
|
77
|
+
await this.mcpServer.server.notification({
|
|
78
|
+
method: 'notifications/message',
|
|
79
|
+
params: {
|
|
80
|
+
level: mcpLevel,
|
|
81
|
+
data: logData,
|
|
82
|
+
logger: 'falkordb-mcp'
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// If MCP notification fails, just continue - file logging is our fallback
|
|
88
|
+
// Don't log this error to avoid infinite loops
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async log(level, message, context) {
|
|
92
|
+
// Always write to file
|
|
93
|
+
this.writeLog(level, message, context);
|
|
94
|
+
// Try to send to MCP client if server is available
|
|
95
|
+
await this.sendMcpLog(level, message, context);
|
|
96
|
+
}
|
|
97
|
+
async info(message, context) {
|
|
98
|
+
await this.log('INFO', message, context);
|
|
99
|
+
}
|
|
100
|
+
async warn(message, context) {
|
|
101
|
+
await this.log('WARN', message, context);
|
|
102
|
+
}
|
|
103
|
+
async error(message, error, context) {
|
|
104
|
+
const errorContext = error ? {
|
|
105
|
+
name: error.name,
|
|
106
|
+
message: error.message,
|
|
107
|
+
stack: error.stack,
|
|
108
|
+
...error.isOperational !== undefined && {
|
|
109
|
+
isOperational: error.isOperational
|
|
110
|
+
},
|
|
111
|
+
...context
|
|
112
|
+
} : context;
|
|
113
|
+
await this.log('ERROR', message, errorContext);
|
|
114
|
+
}
|
|
115
|
+
async debug(message, context) {
|
|
116
|
+
if (process.env.NODE_ENV === 'development') {
|
|
117
|
+
await this.log('DEBUG', message, context);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Synchronous versions for backward compatibility in cases where async isn't possible
|
|
121
|
+
infoSync(message, context) {
|
|
122
|
+
this.writeLog('INFO', message, context);
|
|
123
|
+
// Fire and forget for MCP notification
|
|
124
|
+
this.sendMcpLog('INFO', message, context).catch(() => { });
|
|
125
|
+
}
|
|
126
|
+
warnSync(message, context) {
|
|
127
|
+
this.writeLog('WARN', message, context);
|
|
128
|
+
this.sendMcpLog('WARN', message, context).catch(() => { });
|
|
129
|
+
}
|
|
130
|
+
errorSync(message, error, context) {
|
|
131
|
+
const errorContext = error ? {
|
|
132
|
+
name: error.name,
|
|
133
|
+
message: error.message,
|
|
134
|
+
stack: error.stack,
|
|
135
|
+
...error.isOperational !== undefined && {
|
|
136
|
+
isOperational: error.isOperational
|
|
137
|
+
},
|
|
138
|
+
...context
|
|
139
|
+
} : context;
|
|
140
|
+
this.writeLog('ERROR', message, errorContext);
|
|
141
|
+
this.sendMcpLog('ERROR', message, errorContext).catch(() => { });
|
|
142
|
+
}
|
|
143
|
+
debugSync(message, context) {
|
|
144
|
+
if (process.env.NODE_ENV === 'development') {
|
|
145
|
+
this.writeLog('DEBUG', message, context);
|
|
146
|
+
this.sendMcpLog('DEBUG', message, context).catch(() => { });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Export singleton instance
|
|
151
|
+
export const logger = new Logger();
|