@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,115 @@
|
|
|
1
|
+
// Simple test for logger service to avoid import.meta issues
|
|
2
|
+
// Focus on testing the public interface without requiring complex mocking
|
|
3
|
+
// Mock platformdirs before importing logger service
|
|
4
|
+
jest.mock('platformdirs', () => ({
|
|
5
|
+
userLogDir: jest.fn().mockReturnValue('/mock/user/logs'),
|
|
6
|
+
}));
|
|
7
|
+
import { logger } from './logger.service';
|
|
8
|
+
// Mock file system operations
|
|
9
|
+
jest.mock('fs', () => ({
|
|
10
|
+
appendFileSync: jest.fn(),
|
|
11
|
+
existsSync: jest.fn().mockReturnValue(true), // Directory exists
|
|
12
|
+
mkdirSync: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
// Mock path module
|
|
15
|
+
jest.mock('path', () => ({
|
|
16
|
+
join: jest.fn().mockReturnValue('/mock/logs/test.log'),
|
|
17
|
+
}));
|
|
18
|
+
describe('Logger Service', () => {
|
|
19
|
+
// Mock console to avoid actual logging during tests
|
|
20
|
+
let consoleErrorSpy;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.clearAllMocks();
|
|
23
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
consoleErrorSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
describe('Singleton Instance', () => {
|
|
29
|
+
it('should export a singleton logger instance', () => {
|
|
30
|
+
expect(logger).toBeDefined();
|
|
31
|
+
expect(typeof logger.info).toBe('function');
|
|
32
|
+
expect(typeof logger.warn).toBe('function');
|
|
33
|
+
expect(typeof logger.error).toBe('function');
|
|
34
|
+
expect(typeof logger.debug).toBe('function');
|
|
35
|
+
});
|
|
36
|
+
it('should have sync logging methods', () => {
|
|
37
|
+
expect(typeof logger.infoSync).toBe('function');
|
|
38
|
+
expect(typeof logger.warnSync).toBe('function');
|
|
39
|
+
expect(typeof logger.errorSync).toBe('function');
|
|
40
|
+
expect(typeof logger.debugSync).toBe('function');
|
|
41
|
+
});
|
|
42
|
+
it('should have setMcpServer method', () => {
|
|
43
|
+
expect(typeof logger.setMcpServer).toBe('function');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('Async Logging Methods', () => {
|
|
47
|
+
it('should handle info logging without throwing', async () => {
|
|
48
|
+
await expect(logger.info('Test info message')).resolves.not.toThrow();
|
|
49
|
+
await expect(logger.info('Test with context', { key: 'value' })).resolves.not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
it('should handle warn logging without throwing', async () => {
|
|
52
|
+
await expect(logger.warn('Test warning')).resolves.not.toThrow();
|
|
53
|
+
await expect(logger.warn('Test with context', { code: 123 })).resolves.not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
it('should handle error logging without throwing', async () => {
|
|
56
|
+
const error = new Error('Test error');
|
|
57
|
+
await expect(logger.error('Error occurred')).resolves.not.toThrow();
|
|
58
|
+
await expect(logger.error('Error with object', error)).resolves.not.toThrow();
|
|
59
|
+
await expect(logger.error('Error with context', error, { extra: 'data' })).resolves.not.toThrow();
|
|
60
|
+
});
|
|
61
|
+
it('should handle debug logging without throwing', async () => {
|
|
62
|
+
await expect(logger.debug('Debug message')).resolves.not.toThrow();
|
|
63
|
+
await expect(logger.debug('Debug with context', { debug: true })).resolves.not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('Sync Logging Methods', () => {
|
|
67
|
+
it('should handle sync info logging without throwing', () => {
|
|
68
|
+
expect(() => logger.infoSync('Sync info')).not.toThrow();
|
|
69
|
+
expect(() => logger.infoSync('Sync info with context', { sync: true })).not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
it('should handle sync warn logging without throwing', () => {
|
|
72
|
+
expect(() => logger.warnSync('Sync warning')).not.toThrow();
|
|
73
|
+
expect(() => logger.warnSync('Sync warning with context', { level: 'warn' })).not.toThrow();
|
|
74
|
+
});
|
|
75
|
+
it('should handle sync error logging without throwing', () => {
|
|
76
|
+
const error = new Error('Sync error');
|
|
77
|
+
expect(() => logger.errorSync('Sync error occurred')).not.toThrow();
|
|
78
|
+
expect(() => logger.errorSync('Sync error with object', error)).not.toThrow();
|
|
79
|
+
expect(() => logger.errorSync('Sync error with context', error, { extra: 'data' })).not.toThrow();
|
|
80
|
+
});
|
|
81
|
+
it('should handle sync debug logging without throwing', () => {
|
|
82
|
+
expect(() => logger.debugSync('Sync debug')).not.toThrow();
|
|
83
|
+
expect(() => logger.debugSync('Sync debug with context', { debug: true })).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('MCP Server Integration', () => {
|
|
87
|
+
it('should accept MCP server instance without throwing', () => {
|
|
88
|
+
const mockMcpServer = {
|
|
89
|
+
server: {
|
|
90
|
+
notification: jest.fn().mockResolvedValue(undefined),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
expect(() => logger.setMcpServer(mockMcpServer)).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe('Error Resilience', () => {
|
|
97
|
+
it('should handle file system errors gracefully', async () => {
|
|
98
|
+
// The logger should not throw even if file operations fail
|
|
99
|
+
// This tests the try-catch blocks in the logging methods
|
|
100
|
+
await expect(logger.info('Test message during potential file error')).resolves.not.toThrow();
|
|
101
|
+
await expect(logger.error('Test error during potential file error', new Error('Test'))).resolves.not.toThrow();
|
|
102
|
+
});
|
|
103
|
+
it('should handle MCP notification errors gracefully', async () => {
|
|
104
|
+
// Set up a mock server that throws errors
|
|
105
|
+
const failingMcpServer = {
|
|
106
|
+
server: {
|
|
107
|
+
notification: jest.fn().mockRejectedValue(new Error('MCP notification failed')),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
logger.setMcpServer(failingMcpServer);
|
|
111
|
+
// Logger should not throw even if MCP notifications fail
|
|
112
|
+
await expect(logger.info('Test with failing MCP')).resolves.not.toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createClient } from 'redis';
|
|
2
|
+
import { config } from '../config/index.js';
|
|
3
|
+
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
4
|
+
import { logger } from './logger.service.js';
|
|
5
|
+
class RedisService {
|
|
6
|
+
client = null;
|
|
7
|
+
maxRetries = 5;
|
|
8
|
+
retryCount = 0;
|
|
9
|
+
initializingPromise = null;
|
|
10
|
+
constructor() {
|
|
11
|
+
// Don't initialize in constructor - use explicit initialization
|
|
12
|
+
}
|
|
13
|
+
sanitizeUrl(url) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = new URL(url);
|
|
16
|
+
if (parsed.username || parsed.password) {
|
|
17
|
+
parsed.username = parsed.username ? '***' : '';
|
|
18
|
+
parsed.password = parsed.password ? '***' : '';
|
|
19
|
+
}
|
|
20
|
+
return parsed.toString();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return '<invalid-url>';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async initialize() {
|
|
27
|
+
// Idempotency guard: don't overwrite an already-connected client
|
|
28
|
+
if (this.client) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (this.initializingPromise) {
|
|
32
|
+
return this.initializingPromise;
|
|
33
|
+
}
|
|
34
|
+
this.retryCount = 0;
|
|
35
|
+
this.initializingPromise = this._initialize();
|
|
36
|
+
try {
|
|
37
|
+
await this.initializingPromise;
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
this.initializingPromise = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async _initialize() {
|
|
44
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
// Fire-and-forget: non-critical connection attempt log
|
|
47
|
+
logger.info('Attempting to connect to Redis', {
|
|
48
|
+
url: this.sanitizeUrl(config.redis.url),
|
|
49
|
+
attempt: attempt + 1
|
|
50
|
+
});
|
|
51
|
+
this.client = createClient({
|
|
52
|
+
url: config.redis.url,
|
|
53
|
+
username: config.redis.username,
|
|
54
|
+
password: config.redis.password,
|
|
55
|
+
});
|
|
56
|
+
await this.client.connect();
|
|
57
|
+
await this.client.ping();
|
|
58
|
+
// Fire-and-forget: non-critical success log
|
|
59
|
+
logger.info('Successfully connected to Redis');
|
|
60
|
+
this.retryCount = 0;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Clean up failed client before retrying or throwing
|
|
65
|
+
if (this.client) {
|
|
66
|
+
try {
|
|
67
|
+
await this.client.disconnect();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Ignore disconnect errors
|
|
71
|
+
}
|
|
72
|
+
this.client = null;
|
|
73
|
+
}
|
|
74
|
+
if (attempt < this.maxRetries) {
|
|
75
|
+
this.retryCount = attempt + 1;
|
|
76
|
+
const delay = Math.min(5000 * 2 ** attempt, 30000) + Math.random() * 1000;
|
|
77
|
+
// Fire-and-forget: non-critical retry log
|
|
78
|
+
logger.warn('Failed to connect to Redis, retrying...', {
|
|
79
|
+
attempt: this.retryCount,
|
|
80
|
+
maxRetries: this.maxRetries,
|
|
81
|
+
nextRetryMs: Math.round(delay),
|
|
82
|
+
error: error instanceof Error ? error.message : String(error)
|
|
83
|
+
});
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const appError = new AppError(CommonErrors.CONNECTION_FAILED, `Failed to connect to Redis after ${this.maxRetries} attempts: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
88
|
+
await logger.error('Redis connection failed permanently', appError);
|
|
89
|
+
throw appError;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async get(key) {
|
|
95
|
+
if (!this.client) {
|
|
96
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'Redis client not initialized. Call initialize() first.', true);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const value = await this.client.get(key);
|
|
100
|
+
logger.debug('Redis GET operation completed', { key, hasValue: value !== null });
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to get key '${key}' from Redis: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
105
|
+
await logger.error('Redis GET operation failed', appError, { key });
|
|
106
|
+
throw appError;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async set(key, value) {
|
|
110
|
+
if (!this.client) {
|
|
111
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'Redis client not initialized. Call initialize() first.', true);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
await this.client.set(key, value);
|
|
115
|
+
logger.debug('Redis SET operation completed', { key });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to set key '${key}' in Redis: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
119
|
+
await logger.error('Redis SET operation failed', appError, { key });
|
|
120
|
+
throw appError;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async delete(key) {
|
|
124
|
+
if (!this.client) {
|
|
125
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'Redis client not initialized. Call initialize() first.', true);
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await this.client.del(key);
|
|
129
|
+
logger.debug('Redis DEL operation completed', { key });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to delete key '${key}' from Redis: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
133
|
+
await logger.error('Redis DEL operation failed', appError, { key });
|
|
134
|
+
throw appError;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async listKeys() {
|
|
138
|
+
if (!this.client) {
|
|
139
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'Redis client not initialized. Call initialize() first.', true);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
let cursor = 0;
|
|
143
|
+
const allKeys = [];
|
|
144
|
+
do {
|
|
145
|
+
const result = await this.client.scan(cursor, {
|
|
146
|
+
MATCH: '*',
|
|
147
|
+
COUNT: 1000
|
|
148
|
+
});
|
|
149
|
+
allKeys.push(...result.keys);
|
|
150
|
+
// Depending on the redis client, cursor may be a string; normalize to number
|
|
151
|
+
cursor = typeof result.cursor === 'string' ? Number(result.cursor) : result.cursor;
|
|
152
|
+
} while (cursor !== 0);
|
|
153
|
+
logger.debug('Redis KEYS operation completed', { count: allKeys.length });
|
|
154
|
+
return allKeys;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to list keys in Redis: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
158
|
+
await logger.error('Redis KEYS operation failed', appError);
|
|
159
|
+
throw appError;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async close() {
|
|
163
|
+
if (this.client) {
|
|
164
|
+
try {
|
|
165
|
+
await this.client.quit();
|
|
166
|
+
logger.info('Redis connection closed successfully');
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// Fire-and-forget: best-effort log during cleanup
|
|
170
|
+
logger.error('Error closing Redis connection', error instanceof Error ? error : new Error(String(error)));
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
this.client = null;
|
|
174
|
+
this.retryCount = 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export const redisService = new RedisService();
|