@falkordb/mcpserver 1.1.0 → 1.2.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/.env.example +3 -5
- package/README.md +69 -12
- package/dist/config/index.js +1 -5
- package/dist/config/index.test.js +2 -0
- package/dist/index.js +0 -3
- package/dist/mcp/tools.js +8 -121
- package/dist/mcp/tools.test.js +172 -0
- package/dist/services/falkordb.service.js +2 -2
- package/package.json +8 -7
- package/dist/services/redis.service.js +0 -179
- package/dist/services/redis.service.test.js +0 -399
package/.env.example
CHANGED
|
@@ -10,17 +10,15 @@ MCP_TRANSPORT=stdio
|
|
|
10
10
|
# MCP_API_KEY=
|
|
11
11
|
|
|
12
12
|
# FalkorDB Configuration
|
|
13
|
+
# When using Docker Compose, set FALKORDB_HOST=falkordb (the service name)
|
|
13
14
|
FALKORDB_HOST=localhost
|
|
14
15
|
FALKORDB_PORT=6379
|
|
15
16
|
FALKORDB_USERNAME=
|
|
16
17
|
FALKORDB_PASSWORD=
|
|
17
18
|
# Set to 'true' to use read-only queries by default (useful for replica instances)
|
|
18
19
|
FALKORDB_DEFAULT_READONLY=false
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
REDIS_URL=redis://localhost:6379
|
|
22
|
-
REDIS_USERNAME=
|
|
23
|
-
REDIS_PASSWORD=
|
|
20
|
+
# Set to 'true' to enforce strict read-only mode - blocks all write operations (queries, graph deletions, key modifications)
|
|
21
|
+
FALKORDB_STRICT_READONLY=false
|
|
24
22
|
|
|
25
23
|
# Logging Configuration (optional)
|
|
26
24
|
ENABLE_FILE_LOGGING=false
|
package/README.md
CHANGED
|
@@ -17,7 +17,6 @@ FalkorDB MCP Server enables AI assistants like Claude to interact with FalkorDB
|
|
|
17
17
|
This server implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), allowing AI models to:
|
|
18
18
|
- **Query graph databases** using OpenCypher (with read-only mode support)
|
|
19
19
|
- **Create and manage** nodes and relationships
|
|
20
|
-
- **Store and retrieve** key-value data
|
|
21
20
|
- **List and explore** multiple graphs
|
|
22
21
|
- **Delete graphs** when needed
|
|
23
22
|
- **Read-only queries** for replica instances or to prevent accidental writes
|
|
@@ -54,6 +53,53 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
|
|
|
54
53
|
}
|
|
55
54
|
```
|
|
56
55
|
|
|
56
|
+
### Running with npx
|
|
57
|
+
|
|
58
|
+
You can run the server directly from the command line using npx:
|
|
59
|
+
|
|
60
|
+
**Using inline environment variables:**
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Run with stdio transport (default)
|
|
64
|
+
FALKORDB_HOST=localhost FALKORDB_PORT=6379 npx -y @falkordb/mcpserver
|
|
65
|
+
|
|
66
|
+
# Run with HTTP transport
|
|
67
|
+
MCP_TRANSPORT=http MCP_PORT=3005 FALKORDB_HOST=localhost FALKORDB_PORT=6379 npx -y @falkordb/mcpserver
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Using a .env file:**
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Using dotenv-cli to load environment variables from .env
|
|
74
|
+
npx dotenv-cli -e .env -- npx @falkordb/mcpserver
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This is useful for:
|
|
78
|
+
- Quick testing and development
|
|
79
|
+
- Running the server standalone without Claude Desktop
|
|
80
|
+
- Custom integrations and scripting
|
|
81
|
+
|
|
82
|
+
### Docker Compose
|
|
83
|
+
|
|
84
|
+
Run FalkorDB and the MCP server together:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
cp .env.example .env # create env file; edit to set MCP_API_KEY, FALKORDB_PASSWORD, etc.
|
|
88
|
+
docker compose up -d
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
> **Note:** Skipping the `.env` file leaves variables like `MCP_API_KEY` and `FALKORDB_PASSWORD` empty, which disables API key authentication and uses no database password.
|
|
92
|
+
|
|
93
|
+
This starts FalkorDB with health checks and persistent volumes, plus the MCP server pre-configured to connect to it.
|
|
94
|
+
|
|
95
|
+
The MCP server runs in **HTTP transport** mode and is exposed on `localhost:3000` by default. To connect a client, configure it to use:
|
|
96
|
+
|
|
97
|
+
- **Transport:** `http`
|
|
98
|
+
- **URL:** `http://localhost:3000`
|
|
99
|
+
- **API Key:** Set via the `MCP_API_KEY` environment variable (optional)
|
|
100
|
+
|
|
101
|
+
See `docker-compose.yml` for the exact port and configuration values.
|
|
102
|
+
|
|
57
103
|
### Installation
|
|
58
104
|
|
|
59
105
|
1. **Clone and install:**
|
|
@@ -72,19 +118,14 @@ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_
|
|
|
72
118
|
```env
|
|
73
119
|
# Environment Configuration
|
|
74
120
|
NODE_ENV=development
|
|
75
|
-
|
|
121
|
+
|
|
76
122
|
# FalkorDB Configuration
|
|
77
123
|
FALKORDB_HOST=localhost
|
|
78
124
|
FALKORDB_PORT=6379
|
|
79
125
|
FALKORDB_USERNAME= # Optional
|
|
80
126
|
FALKORDB_PASSWORD= # Optional
|
|
81
127
|
FALKORDB_DEFAULT_READONLY=false # Set to 'true' for read-only mode (useful for replicas)
|
|
82
|
-
|
|
83
|
-
# Redis Configuration (for key-value operations)
|
|
84
|
-
REDIS_URL=redis://localhost:6379
|
|
85
|
-
REDIS_USERNAME= # Optional
|
|
86
|
-
REDIS_PASSWORD= # Optional
|
|
87
|
-
|
|
128
|
+
|
|
88
129
|
# Logging Configuration (optional)
|
|
89
130
|
ENABLE_FILE_LOGGING=false
|
|
90
131
|
```
|
|
@@ -136,7 +177,6 @@ There's also a dedicated `query_graph_readonly` tool that always executes querie
|
|
|
136
177
|
```text
|
|
137
178
|
"Create a new person named Alice who knows Bob"
|
|
138
179
|
"Add a 'WORKS_AT' relationship between Alice and TechCorp"
|
|
139
|
-
"Store my API key in the database"
|
|
140
180
|
```
|
|
141
181
|
|
|
142
182
|
### 📊 Explore Structure
|
|
@@ -195,7 +235,6 @@ src/
|
|
|
195
235
|
├── index.ts # MCP server entry point
|
|
196
236
|
├── services/ # Core business logic
|
|
197
237
|
│ ├── falkordb.service.ts # FalkorDB operations
|
|
198
|
-
│ ├── redis.service.ts # Key-value operations
|
|
199
238
|
│ └── logger.service.ts # Logging and MCP notifications
|
|
200
239
|
├── mcp/ # MCP protocol implementations
|
|
201
240
|
│ ├── tools.ts # MCP tool definitions
|
|
@@ -268,7 +307,25 @@ Requests without a valid key receive a `401 Unauthorized` response. Auth is only
|
|
|
268
307
|
|
|
269
308
|
### Using with Docker
|
|
270
309
|
|
|
271
|
-
|
|
310
|
+
**Using pre-built images from Docker Hub:**
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
# Use the latest stable release
|
|
314
|
+
docker pull falkordb/mcpserver:latest
|
|
315
|
+
docker run -p 3000:3000 \
|
|
316
|
+
-e FALKORDB_HOST=host.docker.internal \
|
|
317
|
+
-e FALKORDB_PORT=6379 \
|
|
318
|
+
-e MCP_API_KEY=your-secret-key \
|
|
319
|
+
falkordb/mcpserver:latest
|
|
320
|
+
|
|
321
|
+
# Or use the edge version (latest main branch)
|
|
322
|
+
docker pull falkordb/mcpserver:edge
|
|
323
|
+
|
|
324
|
+
# Or pin to a specific version
|
|
325
|
+
docker pull falkordb/mcpserver:1.0.0
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Building locally:**
|
|
272
329
|
|
|
273
330
|
```bash
|
|
274
331
|
docker build -t falkordb-mcpserver .
|
|
@@ -289,7 +346,7 @@ services:
|
|
|
289
346
|
- "6379:6379"
|
|
290
347
|
|
|
291
348
|
mcp-server:
|
|
292
|
-
build: .
|
|
349
|
+
image: falkordb/mcpserver:latest # or use 'build: .' to build locally
|
|
293
350
|
ports:
|
|
294
351
|
- "3000:3000"
|
|
295
352
|
environment:
|
package/dist/config/index.js
CHANGED
|
@@ -14,11 +14,7 @@ export const config = {
|
|
|
14
14
|
username: process.env.FALKORDB_USERNAME || '',
|
|
15
15
|
password: process.env.FALKORDB_PASSWORD || '',
|
|
16
16
|
defaultReadOnly: process.env.FALKORDB_DEFAULT_READONLY === 'true',
|
|
17
|
-
|
|
18
|
-
redis: {
|
|
19
|
-
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
|
20
|
-
username: process.env.REDIS_USERNAME || '',
|
|
21
|
-
password: process.env.REDIS_PASSWORD || '',
|
|
17
|
+
strictReadOnly: process.env.FALKORDB_STRICT_READONLY === 'true',
|
|
22
18
|
},
|
|
23
19
|
mcp: {
|
|
24
20
|
transport: (process.env.MCP_TRANSPORT || 'stdio'),
|
|
@@ -13,6 +13,8 @@ describe('Config', () => {
|
|
|
13
13
|
expect(config.falkorDB).toHaveProperty('password');
|
|
14
14
|
expect(config.falkorDB).toHaveProperty('defaultReadOnly');
|
|
15
15
|
expect(typeof config.falkorDB.defaultReadOnly).toBe('boolean');
|
|
16
|
+
expect(config.falkorDB).toHaveProperty('strictReadOnly');
|
|
17
|
+
expect(typeof config.falkorDB.strictReadOnly).toBe('boolean');
|
|
16
18
|
});
|
|
17
19
|
test('should have MCP configuration', () => {
|
|
18
20
|
expect(config).toHaveProperty('mcp');
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,6 @@ import { falkorDBService } from './services/falkordb.service.js';
|
|
|
6
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
7
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
8
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
-
import { redisService } from './services/redis.service.js';
|
|
10
9
|
import { errorHandler } from './errors/ErrorHandler.js';
|
|
11
10
|
import { logger } from './services/logger.service.js';
|
|
12
11
|
import { config } from './config/index.js';
|
|
@@ -39,7 +38,6 @@ const gracefulShutdown = async (signal) => {
|
|
|
39
38
|
});
|
|
40
39
|
}
|
|
41
40
|
await falkorDBService.close();
|
|
42
|
-
await redisService.close();
|
|
43
41
|
await logger.info('All services closed successfully');
|
|
44
42
|
process.exit(0);
|
|
45
43
|
}
|
|
@@ -81,7 +79,6 @@ async function initializeServices() {
|
|
|
81
79
|
await logger.info('Initializing FalkorDB MCP server...');
|
|
82
80
|
try {
|
|
83
81
|
await falkorDBService.initialize();
|
|
84
|
-
await redisService.initialize();
|
|
85
82
|
await logger.info('All services initialized successfully');
|
|
86
83
|
}
|
|
87
84
|
catch (error) {
|
package/dist/mcp/tools.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { falkorDBService } from '../services/falkordb.service.js';
|
|
3
|
-
import { redisService } from '../services/redis.service.js';
|
|
4
3
|
import { logger } from '../services/logger.service.js';
|
|
5
4
|
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
6
5
|
import { config } from '../config/index.js';
|
|
@@ -18,17 +17,6 @@ const deleteGraphSchema = {
|
|
|
18
17
|
graphName: z.string().describe("The name of the graph to delete"),
|
|
19
18
|
confirmDelete: z.literal(true).describe("Must be set to true to confirm deletion. This is a safety measure to prevent accidental data loss."),
|
|
20
19
|
};
|
|
21
|
-
const setKeySchema = {
|
|
22
|
-
key: z.string().describe("The key to set"),
|
|
23
|
-
value: z.string().describe("The value to set"),
|
|
24
|
-
};
|
|
25
|
-
const getKeySchema = {
|
|
26
|
-
key: z.string().describe("The key to get."),
|
|
27
|
-
};
|
|
28
|
-
const deleteKeySchema = {
|
|
29
|
-
key: z.string().describe("The key to delete"),
|
|
30
|
-
confirmDelete: z.literal(true).describe("Must be set to true to confirm deletion. This is a safety measure to prevent accidental data loss."),
|
|
31
|
-
};
|
|
32
20
|
function registerQueryGraphTool(server) {
|
|
33
21
|
server.registerTool("query_graph", {
|
|
34
22
|
title: "Query Graph",
|
|
@@ -46,6 +34,10 @@ function registerQueryGraphTool(server) {
|
|
|
46
34
|
}
|
|
47
35
|
// Use the provided readOnly flag, or fall back to the default from config
|
|
48
36
|
const isReadOnly = readOnly !== undefined ? readOnly : config.falkorDB.defaultReadOnly;
|
|
37
|
+
// Enforce strict read-only mode if enabled
|
|
38
|
+
if (config.falkorDB.strictReadOnly && !isReadOnly) {
|
|
39
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Cannot execute write queries: server is in strict read-only mode (FALKORDB_STRICT_READONLY=true)', true);
|
|
40
|
+
}
|
|
49
41
|
const result = await falkorDBService.executeQuery(graphName, query, undefined, isReadOnly);
|
|
50
42
|
await logger.debug('Query tool executed successfully', { graphName, readOnly: isReadOnly });
|
|
51
43
|
return {
|
|
@@ -125,6 +117,10 @@ function registerDeleteGraphTool(server) {
|
|
|
125
117
|
if (!graphName?.trim()) {
|
|
126
118
|
throw new AppError(CommonErrors.INVALID_INPUT, 'Graph name is required and cannot be empty', true);
|
|
127
119
|
}
|
|
120
|
+
// Enforce strict read-only mode if enabled
|
|
121
|
+
if (config.falkorDB.strictReadOnly) {
|
|
122
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Cannot delete graphs: server is in strict read-only mode (FALKORDB_STRICT_READONLY=true)', true);
|
|
123
|
+
}
|
|
128
124
|
await falkorDBService.deleteGraph(graphName);
|
|
129
125
|
await logger.info('Delete graph tool executed successfully', { graphName });
|
|
130
126
|
return {
|
|
@@ -140,119 +136,10 @@ function registerDeleteGraphTool(server) {
|
|
|
140
136
|
}
|
|
141
137
|
});
|
|
142
138
|
}
|
|
143
|
-
function registerListKeysTool(server) {
|
|
144
|
-
server.registerTool("list_keys", {
|
|
145
|
-
title: "List Keys",
|
|
146
|
-
description: "List all keys in Redis",
|
|
147
|
-
inputSchema: {},
|
|
148
|
-
}, async () => {
|
|
149
|
-
try {
|
|
150
|
-
const keys = await redisService.listKeys();
|
|
151
|
-
await logger.debug('List keys tool executed', { count: keys.length });
|
|
152
|
-
return {
|
|
153
|
-
content: [{
|
|
154
|
-
type: "text",
|
|
155
|
-
text: keys.join("\n"),
|
|
156
|
-
}]
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
await logger.error('List keys tool execution failed', error instanceof Error ? error : new Error(String(error)));
|
|
161
|
-
throw error;
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
function registerSetKeyTool(server) {
|
|
166
|
-
// Register set_key tool
|
|
167
|
-
server.registerTool("set_key", {
|
|
168
|
-
title: "Set Key",
|
|
169
|
-
description: "Set a key in Redis",
|
|
170
|
-
inputSchema: setKeySchema,
|
|
171
|
-
}, async (args) => {
|
|
172
|
-
const { key, value } = z.object(setKeySchema).parse(args);
|
|
173
|
-
try {
|
|
174
|
-
if (!key?.trim()) {
|
|
175
|
-
throw new AppError(CommonErrors.INVALID_INPUT, 'Key is required and cannot be empty', true);
|
|
176
|
-
}
|
|
177
|
-
if (value === undefined || value === null) {
|
|
178
|
-
throw new AppError(CommonErrors.INVALID_INPUT, 'Value is required', true);
|
|
179
|
-
}
|
|
180
|
-
await redisService.set(key, value);
|
|
181
|
-
await logger.debug('Set key tool executed successfully', { key });
|
|
182
|
-
return {
|
|
183
|
-
content: [{
|
|
184
|
-
type: "text",
|
|
185
|
-
text: `Key ${key} set successfully`
|
|
186
|
-
}]
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
catch (error) {
|
|
190
|
-
await logger.error('Set key tool execution failed', error instanceof Error ? error : new Error(String(error)), { key });
|
|
191
|
-
throw error;
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
function registerGetKeyTool(server) {
|
|
196
|
-
// Register get_key tool
|
|
197
|
-
server.registerTool("get_key", {
|
|
198
|
-
title: "Get Key",
|
|
199
|
-
description: "Get a key from Redis",
|
|
200
|
-
inputSchema: getKeySchema,
|
|
201
|
-
}, async (args) => {
|
|
202
|
-
const { key } = z.object(getKeySchema).parse(args);
|
|
203
|
-
try {
|
|
204
|
-
if (!key?.trim()) {
|
|
205
|
-
throw new AppError(CommonErrors.INVALID_INPUT, 'Key is required and cannot be empty', true);
|
|
206
|
-
}
|
|
207
|
-
const value = await redisService.get(key);
|
|
208
|
-
await logger.debug('Get key tool executed successfully', { key, hasValue: value !== null });
|
|
209
|
-
return {
|
|
210
|
-
content: [{
|
|
211
|
-
type: "text",
|
|
212
|
-
text: `Key ${key} is ${value ?? 'null (not found)'}`
|
|
213
|
-
}]
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
catch (error) {
|
|
217
|
-
await logger.error('Get key tool execution failed', error instanceof Error ? error : new Error(String(error)), { key });
|
|
218
|
-
throw error;
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
}
|
|
222
|
-
function registerDeleteKeyTool(server) {
|
|
223
|
-
server.registerTool("delete_key", {
|
|
224
|
-
title: "Delete Key",
|
|
225
|
-
description: "Permanently delete a key from Redis. WARNING: This action is irreversible. You must set confirmDelete to true to proceed.",
|
|
226
|
-
inputSchema: deleteKeySchema,
|
|
227
|
-
}, async (args) => {
|
|
228
|
-
const { key } = z.object(deleteKeySchema).parse(args);
|
|
229
|
-
try {
|
|
230
|
-
if (!key?.trim()) {
|
|
231
|
-
throw new AppError(CommonErrors.INVALID_INPUT, 'Key is required and cannot be empty', true);
|
|
232
|
-
}
|
|
233
|
-
await redisService.delete(key);
|
|
234
|
-
await logger.debug('Delete key tool executed successfully', { key });
|
|
235
|
-
return {
|
|
236
|
-
content: [{
|
|
237
|
-
type: "text",
|
|
238
|
-
text: `Key ${key} deleted`
|
|
239
|
-
}]
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
await logger.error('Delete key tool execution failed', error instanceof Error ? error : new Error(String(error)), { key });
|
|
244
|
-
throw error;
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
139
|
export default function registerAllTools(server) {
|
|
249
140
|
// Register query_graph tools
|
|
250
141
|
registerQueryGraphTool(server);
|
|
251
142
|
registerQueryGraphReadOnlyTool(server);
|
|
252
143
|
registerListGraphsTool(server);
|
|
253
144
|
registerDeleteGraphTool(server);
|
|
254
|
-
registerSetKeyTool(server);
|
|
255
|
-
registerGetKeyTool(server);
|
|
256
|
-
registerDeleteKeyTool(server);
|
|
257
|
-
registerListKeysTool(server);
|
|
258
145
|
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
2
|
+
// Mock the logger service
|
|
3
|
+
jest.mock('../services/logger.service.js', () => ({
|
|
4
|
+
logger: {
|
|
5
|
+
info: jest.fn().mockResolvedValue(undefined),
|
|
6
|
+
warn: jest.fn().mockResolvedValue(undefined),
|
|
7
|
+
error: jest.fn().mockResolvedValue(undefined),
|
|
8
|
+
debug: jest.fn().mockResolvedValue(undefined),
|
|
9
|
+
}
|
|
10
|
+
}));
|
|
11
|
+
// Mock the FalkorDB service
|
|
12
|
+
jest.mock('../services/falkordb.service.js', () => ({
|
|
13
|
+
falkorDBService: {
|
|
14
|
+
executeQuery: jest.fn(),
|
|
15
|
+
executeReadOnlyQuery: jest.fn(),
|
|
16
|
+
listGraphs: jest.fn(),
|
|
17
|
+
deleteGraph: jest.fn(),
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
// Mock config with different scenarios
|
|
21
|
+
let mockConfig = {
|
|
22
|
+
falkorDB: {
|
|
23
|
+
defaultReadOnly: false,
|
|
24
|
+
strictReadOnly: false,
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
jest.mock('../config/index.js', () => ({
|
|
28
|
+
get config() {
|
|
29
|
+
return mockConfig;
|
|
30
|
+
}
|
|
31
|
+
}));
|
|
32
|
+
// Import after mocks are set up
|
|
33
|
+
import registerAllTools from './tools.js';
|
|
34
|
+
import { falkorDBService } from '../services/falkordb.service.js';
|
|
35
|
+
describe('MCP Tools - Strict Read-Only Mode', () => {
|
|
36
|
+
let server;
|
|
37
|
+
let queryGraphHandler;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks();
|
|
40
|
+
// Reset mock config
|
|
41
|
+
mockConfig = {
|
|
42
|
+
falkorDB: {
|
|
43
|
+
defaultReadOnly: false,
|
|
44
|
+
strictReadOnly: false,
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
// Create a minimal mock server that captures tool handlers
|
|
48
|
+
server = {
|
|
49
|
+
registerTool: jest.fn((name, schema, handler) => {
|
|
50
|
+
if (name === 'query_graph') {
|
|
51
|
+
queryGraphHandler = handler;
|
|
52
|
+
}
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
registerAllTools(server);
|
|
56
|
+
});
|
|
57
|
+
describe('query_graph tool with strictReadOnly=false', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
mockConfig.falkorDB.strictReadOnly = false;
|
|
60
|
+
mockConfig.falkorDB.defaultReadOnly = false;
|
|
61
|
+
});
|
|
62
|
+
it('should allow readOnly=false when strictReadOnly is disabled', async () => {
|
|
63
|
+
const mockResult = { records: [] };
|
|
64
|
+
falkorDBService.executeQuery.mockResolvedValue(mockResult);
|
|
65
|
+
await queryGraphHandler({
|
|
66
|
+
graphName: 'test',
|
|
67
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
68
|
+
readOnly: false,
|
|
69
|
+
});
|
|
70
|
+
expect(falkorDBService.executeQuery).toHaveBeenCalledWith('test', 'CREATE (n:Test) RETURN n', undefined, false);
|
|
71
|
+
});
|
|
72
|
+
it('should allow readOnly=true when strictReadOnly is disabled', async () => {
|
|
73
|
+
const mockResult = { records: [] };
|
|
74
|
+
falkorDBService.executeQuery.mockResolvedValue(mockResult);
|
|
75
|
+
await queryGraphHandler({
|
|
76
|
+
graphName: 'test',
|
|
77
|
+
query: 'MATCH (n) RETURN n',
|
|
78
|
+
readOnly: true,
|
|
79
|
+
});
|
|
80
|
+
expect(falkorDBService.executeQuery).toHaveBeenCalledWith('test', 'MATCH (n) RETURN n', undefined, true);
|
|
81
|
+
});
|
|
82
|
+
it('should use default when readOnly is not specified', async () => {
|
|
83
|
+
const mockResult = { records: [] };
|
|
84
|
+
falkorDBService.executeQuery.mockResolvedValue(mockResult);
|
|
85
|
+
await queryGraphHandler({
|
|
86
|
+
graphName: 'test',
|
|
87
|
+
query: 'MATCH (n) RETURN n',
|
|
88
|
+
});
|
|
89
|
+
expect(falkorDBService.executeQuery).toHaveBeenCalledWith('test', 'MATCH (n) RETURN n', undefined, false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('query_graph tool with strictReadOnly=true', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
mockConfig.falkorDB.strictReadOnly = true;
|
|
95
|
+
mockConfig.falkorDB.defaultReadOnly = true;
|
|
96
|
+
});
|
|
97
|
+
it('should reject readOnly=false when strictReadOnly is enabled', async () => {
|
|
98
|
+
await expect(queryGraphHandler({
|
|
99
|
+
graphName: 'test',
|
|
100
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
101
|
+
readOnly: false,
|
|
102
|
+
})).rejects.toThrow(AppError);
|
|
103
|
+
await expect(queryGraphHandler({
|
|
104
|
+
graphName: 'test',
|
|
105
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
106
|
+
readOnly: false,
|
|
107
|
+
})).rejects.toThrow('strict read-only mode');
|
|
108
|
+
expect(falkorDBService.executeQuery).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
it('should allow readOnly=true when strictReadOnly is enabled', async () => {
|
|
111
|
+
const mockResult = { records: [] };
|
|
112
|
+
falkorDBService.executeQuery.mockResolvedValue(mockResult);
|
|
113
|
+
await queryGraphHandler({
|
|
114
|
+
graphName: 'test',
|
|
115
|
+
query: 'MATCH (n) RETURN n',
|
|
116
|
+
readOnly: true,
|
|
117
|
+
});
|
|
118
|
+
expect(falkorDBService.executeQuery).toHaveBeenCalledWith('test', 'MATCH (n) RETURN n', undefined, true);
|
|
119
|
+
});
|
|
120
|
+
it('should use defaultReadOnly when readOnly is not specified in strict mode', async () => {
|
|
121
|
+
const mockResult = { records: [] };
|
|
122
|
+
falkorDBService.executeQuery.mockResolvedValue(mockResult);
|
|
123
|
+
await queryGraphHandler({
|
|
124
|
+
graphName: 'test',
|
|
125
|
+
query: 'MATCH (n) RETURN n',
|
|
126
|
+
});
|
|
127
|
+
expect(falkorDBService.executeQuery).toHaveBeenCalledWith('test', 'MATCH (n) RETURN n', undefined, true);
|
|
128
|
+
});
|
|
129
|
+
it('should include proper error information when rejecting write queries', async () => {
|
|
130
|
+
try {
|
|
131
|
+
await queryGraphHandler({
|
|
132
|
+
graphName: 'test',
|
|
133
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
134
|
+
readOnly: false,
|
|
135
|
+
});
|
|
136
|
+
fail('Expected error to be thrown');
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
expect(error).toBeInstanceOf(AppError);
|
|
140
|
+
expect(error.name).toBe(CommonErrors.INVALID_INPUT);
|
|
141
|
+
expect(error.message).toContain('FALKORDB_STRICT_READONLY=true');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it('should reject write queries when strictReadOnly is enabled and defaultReadOnly=false and readOnly is not specified', async () => {
|
|
145
|
+
// Override defaultReadOnly for this specific test case
|
|
146
|
+
mockConfig.falkorDB.defaultReadOnly = false;
|
|
147
|
+
await expect(queryGraphHandler({
|
|
148
|
+
graphName: 'test',
|
|
149
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
150
|
+
})).rejects.toThrow(AppError);
|
|
151
|
+
await expect(queryGraphHandler({
|
|
152
|
+
graphName: 'test',
|
|
153
|
+
query: 'CREATE (n:Test) RETURN n',
|
|
154
|
+
})).rejects.toThrow('strict read-only mode');
|
|
155
|
+
expect(falkorDBService.executeQuery).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('query_graph tool input validation', () => {
|
|
159
|
+
it('should reject empty graph name', async () => {
|
|
160
|
+
await expect(queryGraphHandler({
|
|
161
|
+
graphName: '',
|
|
162
|
+
query: 'MATCH (n) RETURN n',
|
|
163
|
+
})).rejects.toThrow('Graph name is required and cannot be empty');
|
|
164
|
+
});
|
|
165
|
+
it('should reject empty query', async () => {
|
|
166
|
+
await expect(queryGraphHandler({
|
|
167
|
+
graphName: 'test',
|
|
168
|
+
query: '',
|
|
169
|
+
})).rejects.toThrow('Query is required and cannot be empty');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -41,8 +41,8 @@ class FalkorDBService {
|
|
|
41
41
|
host: config.falkorDB.host,
|
|
42
42
|
port: config.falkorDB.port,
|
|
43
43
|
},
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
...(config.falkorDB.username && { username: config.falkorDB.username }),
|
|
45
|
+
...(config.falkorDB.password && { password: config.falkorDB.password }),
|
|
46
46
|
});
|
|
47
47
|
// Test connection
|
|
48
48
|
const connection = await this.client.connection;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falkordb/mcpserver",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Model Context Protocol server for FalkorDB graph databases - enables AI assistants to query and manage graph data using natural language",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
"claude",
|
|
33
33
|
"opencypher",
|
|
34
34
|
"cypher",
|
|
35
|
-
"redis",
|
|
36
35
|
"graph",
|
|
37
36
|
"database",
|
|
38
37
|
"typescript",
|
|
@@ -65,15 +64,14 @@
|
|
|
65
64
|
"test:ci": "jest --coverage --ci --watchAll=false",
|
|
66
65
|
"inspect": "npm run build && npx -y @modelcontextprotocol/inspector node $PWD/dist/index.js",
|
|
67
66
|
"clean": "rm -rf dist coverage",
|
|
68
|
-
"prepublish": "npm run test:ci && npm run lint && npm run build",
|
|
69
67
|
"prepublishOnly": "npm run clean && npm run test:ci && npm run lint && npm run build"
|
|
70
68
|
},
|
|
71
69
|
"devDependencies": {
|
|
72
70
|
"@types/jest": "^30.0.0",
|
|
73
71
|
"@types/node": "^20.14.0",
|
|
74
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
75
|
-
"@typescript-eslint/parser": "^8.
|
|
76
|
-
"eslint": "^
|
|
72
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
73
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
74
|
+
"eslint": "^10.0.1",
|
|
77
75
|
"jest": "^30.0.5",
|
|
78
76
|
"nodemon": "^3.1.10",
|
|
79
77
|
"ts-jest": "^29.4.0",
|
|
@@ -85,13 +83,16 @@
|
|
|
85
83
|
"dotenv": "^17.2.1",
|
|
86
84
|
"falkordb": "^6.3.0",
|
|
87
85
|
"platformdirs": "^4.3.8-rc3",
|
|
88
|
-
"redis": "^4.0.0",
|
|
89
86
|
"zod": "^3.23.0"
|
|
90
87
|
},
|
|
91
88
|
"publishConfig": {
|
|
92
89
|
"access": "public",
|
|
93
90
|
"registry": "https://registry.npmjs.org/"
|
|
94
91
|
},
|
|
92
|
+
"overrides": {
|
|
93
|
+
"minimatch": "^10.2.1",
|
|
94
|
+
"test-exclude": "^7.0.2"
|
|
95
|
+
},
|
|
95
96
|
"funding": {
|
|
96
97
|
"type": "github",
|
|
97
98
|
"url": "https://github.com/sponsors/SecKatie"
|
|
@@ -1,179 +0,0 @@
|
|
|
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();
|
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
import { redisService } from './redis.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
|
-
redis: {
|
|
16
|
-
url: 'redis://localhost:6379',
|
|
17
|
-
username: 'testuser',
|
|
18
|
-
password: 'testpass'
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}));
|
|
22
|
-
// Mock the Redis library
|
|
23
|
-
jest.mock('redis', () => {
|
|
24
|
-
const mockConnect = jest.fn();
|
|
25
|
-
const mockPing = jest.fn();
|
|
26
|
-
const mockGet = jest.fn();
|
|
27
|
-
const mockSet = jest.fn();
|
|
28
|
-
const mockDel = jest.fn();
|
|
29
|
-
const mockScan = jest.fn();
|
|
30
|
-
const mockQuit = jest.fn();
|
|
31
|
-
const mockClient = {
|
|
32
|
-
connect: mockConnect,
|
|
33
|
-
ping: mockPing,
|
|
34
|
-
get: mockGet,
|
|
35
|
-
set: mockSet,
|
|
36
|
-
del: mockDel,
|
|
37
|
-
scan: mockScan,
|
|
38
|
-
quit: mockQuit
|
|
39
|
-
};
|
|
40
|
-
return {
|
|
41
|
-
createClient: jest.fn().mockReturnValue(mockClient),
|
|
42
|
-
mockConnect,
|
|
43
|
-
mockPing,
|
|
44
|
-
mockGet,
|
|
45
|
-
mockSet,
|
|
46
|
-
mockDel,
|
|
47
|
-
mockScan,
|
|
48
|
-
mockQuit,
|
|
49
|
-
mockClient
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
describe('Redis Service', () => {
|
|
53
|
-
let mockRedis;
|
|
54
|
-
beforeAll(async () => {
|
|
55
|
-
// Access the mocks
|
|
56
|
-
mockRedis = await import('redis');
|
|
57
|
-
});
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
jest.clearAllMocks();
|
|
60
|
-
// Reset service state
|
|
61
|
-
redisService.client = null;
|
|
62
|
-
redisService.retryCount = 0;
|
|
63
|
-
redisService.initializingPromise = null;
|
|
64
|
-
});
|
|
65
|
-
describe('initialize', () => {
|
|
66
|
-
it('should successfully initialize and connect to Redis', async () => {
|
|
67
|
-
// Arrange
|
|
68
|
-
mockRedis.mockConnect.mockResolvedValue(undefined);
|
|
69
|
-
mockRedis.mockPing.mockResolvedValue('PONG');
|
|
70
|
-
// Act
|
|
71
|
-
await redisService.initialize();
|
|
72
|
-
// Assert
|
|
73
|
-
expect(mockRedis.createClient).toHaveBeenCalledWith({
|
|
74
|
-
url: 'redis://localhost:6379',
|
|
75
|
-
username: 'testuser',
|
|
76
|
-
password: 'testpass',
|
|
77
|
-
});
|
|
78
|
-
expect(mockRedis.mockConnect).toHaveBeenCalled();
|
|
79
|
-
expect(mockRedis.mockPing).toHaveBeenCalled();
|
|
80
|
-
expect(redisService.client).not.toBeNull();
|
|
81
|
-
expect(redisService.retryCount).toBe(0);
|
|
82
|
-
expect(redisService.initializingPromise).toBeNull();
|
|
83
|
-
});
|
|
84
|
-
it('should await ongoing initialization if already initializing', async () => {
|
|
85
|
-
// Arrange
|
|
86
|
-
mockRedis.mockConnect.mockResolvedValue(undefined);
|
|
87
|
-
mockRedis.mockPing.mockResolvedValue('PONG');
|
|
88
|
-
// Act - start two initializations concurrently
|
|
89
|
-
const init1 = redisService.initialize();
|
|
90
|
-
const init2 = redisService.initialize();
|
|
91
|
-
await Promise.all([init1, init2]);
|
|
92
|
-
// Assert - createClient should only be called once
|
|
93
|
-
expect(mockRedis.createClient).toHaveBeenCalledTimes(1);
|
|
94
|
-
expect(redisService.client).not.toBeNull();
|
|
95
|
-
});
|
|
96
|
-
it('should retry connection on failure and eventually succeed', async () => {
|
|
97
|
-
// Arrange
|
|
98
|
-
const connectError = new Error('Connection failed');
|
|
99
|
-
mockRedis.mockConnect
|
|
100
|
-
.mockRejectedValueOnce(connectError)
|
|
101
|
-
.mockRejectedValueOnce(connectError)
|
|
102
|
-
.mockResolvedValueOnce(undefined);
|
|
103
|
-
mockRedis.mockPing.mockResolvedValue('PONG');
|
|
104
|
-
// Mock setTimeout to avoid actual delays
|
|
105
|
-
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
106
|
-
setImmediate(callback);
|
|
107
|
-
return {};
|
|
108
|
-
});
|
|
109
|
-
// Act
|
|
110
|
-
await redisService.initialize();
|
|
111
|
-
// Assert
|
|
112
|
-
expect(mockRedis.createClient).toHaveBeenCalledTimes(3);
|
|
113
|
-
expect(redisService.client).not.toBeNull();
|
|
114
|
-
expect(redisService.retryCount).toBe(0);
|
|
115
|
-
// Cleanup
|
|
116
|
-
setTimeoutSpy.mockRestore();
|
|
117
|
-
});
|
|
118
|
-
it('should throw AppError after max retries exceeded', async () => {
|
|
119
|
-
// Arrange
|
|
120
|
-
const connectError = new Error('Connection failed');
|
|
121
|
-
mockRedis.mockConnect.mockRejectedValue(connectError);
|
|
122
|
-
// Mock setTimeout to avoid actual delays
|
|
123
|
-
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
124
|
-
setImmediate(callback);
|
|
125
|
-
return {};
|
|
126
|
-
});
|
|
127
|
-
// Act & Assert
|
|
128
|
-
try {
|
|
129
|
-
await redisService.initialize();
|
|
130
|
-
fail('Expected initialize to throw AppError');
|
|
131
|
-
}
|
|
132
|
-
catch (error) {
|
|
133
|
-
expect(error).toBeInstanceOf(AppError);
|
|
134
|
-
expect(error.name).toBe(CommonErrors.CONNECTION_FAILED);
|
|
135
|
-
}
|
|
136
|
-
expect(mockRedis.createClient).toHaveBeenCalledTimes(6); // 1 initial + 5 retries
|
|
137
|
-
// Cleanup
|
|
138
|
-
setTimeoutSpy.mockRestore();
|
|
139
|
-
});
|
|
140
|
-
it('should handle ping failure during connection test', async () => {
|
|
141
|
-
// Arrange
|
|
142
|
-
const pingError = new Error('Ping failed');
|
|
143
|
-
mockRedis.mockConnect.mockResolvedValue(undefined);
|
|
144
|
-
mockRedis.mockPing.mockRejectedValue(pingError);
|
|
145
|
-
// Mock setTimeout to avoid actual delays
|
|
146
|
-
const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => {
|
|
147
|
-
setImmediate(callback);
|
|
148
|
-
return {};
|
|
149
|
-
});
|
|
150
|
-
// Act & Assert
|
|
151
|
-
await expect(redisService.initialize()).rejects.toThrow(AppError);
|
|
152
|
-
// Cleanup
|
|
153
|
-
setTimeoutSpy.mockRestore();
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
describe('get', () => {
|
|
157
|
-
it('should get a value from Redis', async () => {
|
|
158
|
-
// Arrange
|
|
159
|
-
const key = 'testKey';
|
|
160
|
-
const expectedValue = 'testValue';
|
|
161
|
-
mockRedis.mockGet.mockResolvedValue(expectedValue);
|
|
162
|
-
// Force client to be available
|
|
163
|
-
redisService.client = mockRedis.mockClient;
|
|
164
|
-
// Act
|
|
165
|
-
const result = await redisService.get(key);
|
|
166
|
-
// Assert
|
|
167
|
-
expect(mockRedis.mockGet).toHaveBeenCalledWith(key);
|
|
168
|
-
expect(result).toBe(expectedValue);
|
|
169
|
-
});
|
|
170
|
-
it('should return null when key does not exist', async () => {
|
|
171
|
-
// Arrange
|
|
172
|
-
const key = 'nonExistentKey';
|
|
173
|
-
mockRedis.mockGet.mockResolvedValue(null);
|
|
174
|
-
// Force client to be available
|
|
175
|
-
redisService.client = mockRedis.mockClient;
|
|
176
|
-
// Act
|
|
177
|
-
const result = await redisService.get(key);
|
|
178
|
-
// Assert
|
|
179
|
-
expect(mockRedis.mockGet).toHaveBeenCalledWith(key);
|
|
180
|
-
expect(result).toBeNull();
|
|
181
|
-
});
|
|
182
|
-
it('should throw AppError if client is not initialized', async () => {
|
|
183
|
-
// Arrange
|
|
184
|
-
redisService.client = null;
|
|
185
|
-
// Act & Assert
|
|
186
|
-
await expect(redisService.get('testKey'))
|
|
187
|
-
.rejects
|
|
188
|
-
.toThrow(AppError);
|
|
189
|
-
await expect(redisService.get('testKey'))
|
|
190
|
-
.rejects
|
|
191
|
-
.toThrow('Redis client not initialized');
|
|
192
|
-
});
|
|
193
|
-
it('should throw AppError when get operation fails', async () => {
|
|
194
|
-
// Arrange
|
|
195
|
-
const key = 'testKey';
|
|
196
|
-
const getError = new Error('Redis GET failed');
|
|
197
|
-
mockRedis.mockGet.mockRejectedValue(getError);
|
|
198
|
-
// Force client to be available
|
|
199
|
-
redisService.client = mockRedis.mockClient;
|
|
200
|
-
// Act & Assert
|
|
201
|
-
try {
|
|
202
|
-
await redisService.get(key);
|
|
203
|
-
fail('Expected get to throw AppError');
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
expect(error).toBeInstanceOf(AppError);
|
|
207
|
-
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
});
|
|
211
|
-
describe('set', () => {
|
|
212
|
-
it('should set a value in Redis', async () => {
|
|
213
|
-
// Arrange
|
|
214
|
-
const key = 'testKey';
|
|
215
|
-
const value = 'testValue';
|
|
216
|
-
mockRedis.mockSet.mockResolvedValue('OK');
|
|
217
|
-
// Force client to be available
|
|
218
|
-
redisService.client = mockRedis.mockClient;
|
|
219
|
-
// Act
|
|
220
|
-
await redisService.set(key, value);
|
|
221
|
-
// Assert
|
|
222
|
-
expect(mockRedis.mockSet).toHaveBeenCalledWith(key, value);
|
|
223
|
-
});
|
|
224
|
-
it('should throw AppError if client is not initialized', async () => {
|
|
225
|
-
// Arrange
|
|
226
|
-
redisService.client = null;
|
|
227
|
-
// Act & Assert
|
|
228
|
-
await expect(redisService.set('testKey', 'testValue'))
|
|
229
|
-
.rejects
|
|
230
|
-
.toThrow(AppError);
|
|
231
|
-
await expect(redisService.set('testKey', 'testValue'))
|
|
232
|
-
.rejects
|
|
233
|
-
.toThrow('Redis client not initialized');
|
|
234
|
-
});
|
|
235
|
-
it('should throw AppError when set operation fails', async () => {
|
|
236
|
-
// Arrange
|
|
237
|
-
const key = 'testKey';
|
|
238
|
-
const value = 'testValue';
|
|
239
|
-
const setError = new Error('Redis SET failed');
|
|
240
|
-
mockRedis.mockSet.mockRejectedValue(setError);
|
|
241
|
-
// Force client to be available
|
|
242
|
-
redisService.client = mockRedis.mockClient;
|
|
243
|
-
// Act & Assert
|
|
244
|
-
try {
|
|
245
|
-
await redisService.set(key, value);
|
|
246
|
-
fail('Expected set to throw AppError');
|
|
247
|
-
}
|
|
248
|
-
catch (error) {
|
|
249
|
-
expect(error).toBeInstanceOf(AppError);
|
|
250
|
-
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
describe('close', () => {
|
|
255
|
-
it('should close the client connection successfully', async () => {
|
|
256
|
-
// Arrange
|
|
257
|
-
mockRedis.mockQuit.mockResolvedValue('OK');
|
|
258
|
-
redisService.client = mockRedis.mockClient;
|
|
259
|
-
redisService.retryCount = 3;
|
|
260
|
-
// Act
|
|
261
|
-
await redisService.close();
|
|
262
|
-
// Assert
|
|
263
|
-
expect(mockRedis.mockQuit).toHaveBeenCalled();
|
|
264
|
-
expect(redisService.client).toBeNull();
|
|
265
|
-
expect(redisService.retryCount).toBe(0);
|
|
266
|
-
});
|
|
267
|
-
it('should handle close error gracefully', async () => {
|
|
268
|
-
// Arrange
|
|
269
|
-
const closeError = new Error('Close failed');
|
|
270
|
-
mockRedis.mockQuit.mockRejectedValue(closeError);
|
|
271
|
-
redisService.client = mockRedis.mockClient;
|
|
272
|
-
redisService.retryCount = 2;
|
|
273
|
-
// Act
|
|
274
|
-
await redisService.close();
|
|
275
|
-
// Assert
|
|
276
|
-
expect(mockRedis.mockQuit).toHaveBeenCalled();
|
|
277
|
-
expect(redisService.client).toBeNull();
|
|
278
|
-
expect(redisService.retryCount).toBe(0);
|
|
279
|
-
});
|
|
280
|
-
it('should not throw if client is already null', async () => {
|
|
281
|
-
// Arrange
|
|
282
|
-
redisService.client = null;
|
|
283
|
-
// Act & Assert
|
|
284
|
-
await expect(redisService.close()).resolves.not.toThrow();
|
|
285
|
-
expect(mockRedis.mockQuit).not.toHaveBeenCalled();
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
describe('delete', () => {
|
|
289
|
-
it('should delete a key from Redis', async () => {
|
|
290
|
-
// Arrange
|
|
291
|
-
const key = 'testKey';
|
|
292
|
-
mockRedis.mockDel.mockResolvedValue(1);
|
|
293
|
-
// Force client to be available
|
|
294
|
-
redisService.client = mockRedis.mockClient;
|
|
295
|
-
// Act
|
|
296
|
-
await redisService.delete(key);
|
|
297
|
-
// Assert
|
|
298
|
-
expect(mockRedis.mockDel).toHaveBeenCalledWith(key);
|
|
299
|
-
});
|
|
300
|
-
it('should throw AppError if client is not initialized', async () => {
|
|
301
|
-
// Arrange
|
|
302
|
-
redisService.client = null;
|
|
303
|
-
// Act & Assert
|
|
304
|
-
await expect(redisService.delete('testKey'))
|
|
305
|
-
.rejects
|
|
306
|
-
.toThrow(AppError);
|
|
307
|
-
await expect(redisService.delete('testKey'))
|
|
308
|
-
.rejects
|
|
309
|
-
.toThrow('Redis client not initialized');
|
|
310
|
-
});
|
|
311
|
-
it('should throw AppError when delete operation fails', async () => {
|
|
312
|
-
// Arrange
|
|
313
|
-
const key = 'testKey';
|
|
314
|
-
const delError = new Error('Redis DEL failed');
|
|
315
|
-
mockRedis.mockDel.mockRejectedValue(delError);
|
|
316
|
-
// Force client to be available
|
|
317
|
-
redisService.client = mockRedis.mockClient;
|
|
318
|
-
// Act & Assert
|
|
319
|
-
try {
|
|
320
|
-
await redisService.delete(key);
|
|
321
|
-
fail('Expected delete to throw AppError');
|
|
322
|
-
}
|
|
323
|
-
catch (error) {
|
|
324
|
-
expect(error).toBeInstanceOf(AppError);
|
|
325
|
-
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
});
|
|
329
|
-
describe('listKeys', () => {
|
|
330
|
-
it('should list all keys from Redis using scan iteration', async () => {
|
|
331
|
-
// Arrange
|
|
332
|
-
mockRedis.mockScan
|
|
333
|
-
.mockResolvedValueOnce({ cursor: 5, keys: ['key1', 'key2'] })
|
|
334
|
-
.mockResolvedValueOnce({ cursor: 10, keys: ['key3', 'key4'] })
|
|
335
|
-
.mockResolvedValueOnce({ cursor: 0, keys: ['key5'] });
|
|
336
|
-
// Force client to be available
|
|
337
|
-
redisService.client = mockRedis.mockClient;
|
|
338
|
-
// Act
|
|
339
|
-
const result = await redisService.listKeys();
|
|
340
|
-
// Assert
|
|
341
|
-
expect(mockRedis.mockScan).toHaveBeenCalledTimes(3);
|
|
342
|
-
expect(mockRedis.mockScan).toHaveBeenCalledWith(0, { MATCH: '*', COUNT: 1000 });
|
|
343
|
-
expect(mockRedis.mockScan).toHaveBeenCalledWith(5, { MATCH: '*', COUNT: 1000 });
|
|
344
|
-
expect(mockRedis.mockScan).toHaveBeenCalledWith(10, { MATCH: '*', COUNT: 1000 });
|
|
345
|
-
expect(result).toEqual(['key1', 'key2', 'key3', 'key4', 'key5']);
|
|
346
|
-
});
|
|
347
|
-
it('should handle cursor as string and convert to number', async () => {
|
|
348
|
-
// Arrange
|
|
349
|
-
mockRedis.mockScan
|
|
350
|
-
.mockResolvedValueOnce({ cursor: '5', keys: ['key1'] })
|
|
351
|
-
.mockResolvedValueOnce({ cursor: '0', keys: ['key2'] });
|
|
352
|
-
// Force client to be available
|
|
353
|
-
redisService.client = mockRedis.mockClient;
|
|
354
|
-
// Act
|
|
355
|
-
const result = await redisService.listKeys();
|
|
356
|
-
// Assert
|
|
357
|
-
expect(mockRedis.mockScan).toHaveBeenCalledTimes(2);
|
|
358
|
-
expect(result).toEqual(['key1', 'key2']);
|
|
359
|
-
});
|
|
360
|
-
it('should return empty array when no keys exist', async () => {
|
|
361
|
-
// Arrange
|
|
362
|
-
mockRedis.mockScan.mockResolvedValueOnce({ cursor: 0, keys: [] });
|
|
363
|
-
// Force client to be available
|
|
364
|
-
redisService.client = mockRedis.mockClient;
|
|
365
|
-
// Act
|
|
366
|
-
const result = await redisService.listKeys();
|
|
367
|
-
// Assert
|
|
368
|
-
expect(mockRedis.mockScan).toHaveBeenCalledTimes(1);
|
|
369
|
-
expect(result).toEqual([]);
|
|
370
|
-
});
|
|
371
|
-
it('should throw AppError if client is not initialized', async () => {
|
|
372
|
-
// Arrange
|
|
373
|
-
redisService.client = null;
|
|
374
|
-
// Act & Assert
|
|
375
|
-
await expect(redisService.listKeys())
|
|
376
|
-
.rejects
|
|
377
|
-
.toThrow(AppError);
|
|
378
|
-
await expect(redisService.listKeys())
|
|
379
|
-
.rejects
|
|
380
|
-
.toThrow('Redis client not initialized');
|
|
381
|
-
});
|
|
382
|
-
it('should throw AppError when listKeys operation fails', async () => {
|
|
383
|
-
// Arrange
|
|
384
|
-
const scanError = new Error('Redis SCAN failed');
|
|
385
|
-
mockRedis.mockScan.mockRejectedValue(scanError);
|
|
386
|
-
// Force client to be available
|
|
387
|
-
redisService.client = mockRedis.mockClient;
|
|
388
|
-
// Act & Assert
|
|
389
|
-
try {
|
|
390
|
-
await redisService.listKeys();
|
|
391
|
-
fail('Expected listKeys to throw AppError');
|
|
392
|
-
}
|
|
393
|
-
catch (error) {
|
|
394
|
-
expect(error).toBeInstanceOf(AppError);
|
|
395
|
-
expect(error.name).toBe(CommonErrors.OPERATION_FAILED);
|
|
396
|
-
}
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
});
|