@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 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
- # Redis Configuration (for key-value operations)
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
- Build and run the MCP server in a Docker container (defaults to HTTP transport):
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:
@@ -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
- password: config.falkorDB.password,
45
- username: config.falkorDB.username,
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.1.0",
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.38.0",
75
- "@typescript-eslint/parser": "^8.38.0",
76
- "eslint": "^9.31.0",
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
- });