@falkordb/mcpserver 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/dist/config/index.js +27 -0
- package/dist/config/index.test.js +23 -0
- package/dist/errors/AppError.js +27 -0
- package/dist/errors/ErrorHandler.js +46 -0
- package/dist/errors/ErrorHandler.test.js +146 -0
- package/dist/index.js +234 -0
- package/dist/mcp/prompts.js +229 -0
- package/dist/mcp/resources.js +26 -0
- package/dist/mcp/tools.js +258 -0
- package/dist/models/mcp-client-config.js +34 -0
- package/dist/models/mcp-client-config.test.js +173 -0
- package/dist/models/mcp.types.js +4 -0
- package/dist/services/falkordb.service.js +175 -0
- package/dist/services/falkordb.service.test.js +489 -0
- package/dist/services/logger.service.js +151 -0
- package/dist/services/logger.service.test.js +115 -0
- package/dist/services/redis.service.js +179 -0
- package/dist/services/redis.service.test.js +399 -0
- package/dist/utils/connection-parser.js +71 -0
- package/dist/utils/connection-parser.test.js +232 -0
- package/package.json +99 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { falkorDBService } from '../services/falkordb.service.js';
|
|
3
|
+
import { redisService } from '../services/redis.service.js';
|
|
4
|
+
import { logger } from '../services/logger.service.js';
|
|
5
|
+
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
6
|
+
import { config } from '../config/index.js';
|
|
7
|
+
// Define schemas as simple objects first to avoid TS2589 deep recursion
|
|
8
|
+
const queryGraphSchema = {
|
|
9
|
+
graphName: z.string().describe("The name of the graph to query"),
|
|
10
|
+
query: z.string().describe("The OpenCypher query to run"),
|
|
11
|
+
readOnly: z.boolean().optional().describe("If true, executes as a read-only query (GRAPH.RO_QUERY). Useful for replica instances or to prevent accidental writes. Defaults to FALKORDB_DEFAULT_READONLY environment variable."),
|
|
12
|
+
};
|
|
13
|
+
const queryGraphReadOnlySchema = {
|
|
14
|
+
graphName: z.string().describe("The name of the graph to query"),
|
|
15
|
+
query: z.string().describe("The read-only OpenCypher query to run (write operations will fail)"),
|
|
16
|
+
};
|
|
17
|
+
const deleteGraphSchema = {
|
|
18
|
+
graphName: z.string().describe("The name of the graph to delete"),
|
|
19
|
+
confirmDelete: z.literal(true).describe("Must be set to true to confirm deletion. This is a safety measure to prevent accidental data loss."),
|
|
20
|
+
};
|
|
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
|
+
function registerQueryGraphTool(server) {
|
|
33
|
+
server.registerTool("query_graph", {
|
|
34
|
+
title: "Query Graph",
|
|
35
|
+
description: "Run an OpenCypher query on a graph. Supports both read-write and read-only queries.",
|
|
36
|
+
inputSchema: queryGraphSchema, // Cast to any to prevent TS2589 (deep recursion) during type inference
|
|
37
|
+
}, async (args) => {
|
|
38
|
+
// Manual validation since we're using raw shape for registration
|
|
39
|
+
const { graphName, query, readOnly } = z.object(queryGraphSchema).parse(args);
|
|
40
|
+
try {
|
|
41
|
+
if (!graphName?.trim()) {
|
|
42
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Graph name is required and cannot be empty', true);
|
|
43
|
+
}
|
|
44
|
+
if (!query?.trim()) {
|
|
45
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Query is required and cannot be empty', true);
|
|
46
|
+
}
|
|
47
|
+
// Use the provided readOnly flag, or fall back to the default from config
|
|
48
|
+
const isReadOnly = readOnly !== undefined ? readOnly : config.falkorDB.defaultReadOnly;
|
|
49
|
+
const result = await falkorDBService.executeQuery(graphName, query, undefined, isReadOnly);
|
|
50
|
+
await logger.debug('Query tool executed successfully', { graphName, readOnly: isReadOnly });
|
|
51
|
+
return {
|
|
52
|
+
content: [{
|
|
53
|
+
type: "text",
|
|
54
|
+
text: JSON.stringify(result, null, 2)
|
|
55
|
+
}]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
await logger.error('Query tool execution failed', error instanceof Error ? error : new Error(String(error)), { graphName, query: query.substring(0, 100) + (query.length > 100 ? '...' : '') });
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function registerQueryGraphReadOnlyTool(server) {
|
|
65
|
+
server.registerTool("query_graph_readonly", {
|
|
66
|
+
title: "Query Graph (Read-Only)",
|
|
67
|
+
description: "Run a read-only OpenCypher query on a graph using GRAPH.RO_QUERY. This ensures no write operations are performed and is ideal for replica instances.",
|
|
68
|
+
inputSchema: queryGraphReadOnlySchema,
|
|
69
|
+
}, async (args) => {
|
|
70
|
+
const { graphName, query } = z.object(queryGraphReadOnlySchema).parse(args);
|
|
71
|
+
try {
|
|
72
|
+
if (!graphName?.trim()) {
|
|
73
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Graph name is required and cannot be empty', true);
|
|
74
|
+
}
|
|
75
|
+
if (!query?.trim()) {
|
|
76
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Query is required and cannot be empty', true);
|
|
77
|
+
}
|
|
78
|
+
const result = await falkorDBService.executeReadOnlyQuery(graphName, query);
|
|
79
|
+
await logger.debug('Read-only query tool executed successfully', { graphName });
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: "text",
|
|
83
|
+
text: JSON.stringify(result, null, 2)
|
|
84
|
+
}]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
await logger.error('Read-only query tool execution failed', error instanceof Error ? error : new Error(String(error)), { graphName, query: query.substring(0, 100) + (query.length > 100 ? '...' : '') });
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function registerListGraphsTool(server) {
|
|
94
|
+
// Register list_graphs tool
|
|
95
|
+
server.registerTool("list_graphs", {
|
|
96
|
+
title: "List Graphs",
|
|
97
|
+
description: "List all graphs available to query",
|
|
98
|
+
inputSchema: {},
|
|
99
|
+
}, async () => {
|
|
100
|
+
try {
|
|
101
|
+
const result = await falkorDBService.listGraphs();
|
|
102
|
+
await logger.debug('List graphs tool executed', { count: result.length });
|
|
103
|
+
return {
|
|
104
|
+
content: [{
|
|
105
|
+
type: "text",
|
|
106
|
+
text: result.join("\n"),
|
|
107
|
+
}]
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
await logger.error('List graphs tool execution failed', error instanceof Error ? error : new Error(String(error)));
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function registerDeleteGraphTool(server) {
|
|
117
|
+
// Register delete_graph tool
|
|
118
|
+
server.registerTool("delete_graph", {
|
|
119
|
+
title: "Delete Graph",
|
|
120
|
+
description: "Permanently delete a graph from the database. WARNING: This action is irreversible. You must set confirmDelete to true to proceed.",
|
|
121
|
+
inputSchema: deleteGraphSchema,
|
|
122
|
+
}, async (args) => {
|
|
123
|
+
const { graphName } = z.object(deleteGraphSchema).parse(args);
|
|
124
|
+
try {
|
|
125
|
+
if (!graphName?.trim()) {
|
|
126
|
+
throw new AppError(CommonErrors.INVALID_INPUT, 'Graph name is required and cannot be empty', true);
|
|
127
|
+
}
|
|
128
|
+
await falkorDBService.deleteGraph(graphName);
|
|
129
|
+
await logger.info('Delete graph tool executed successfully', { graphName });
|
|
130
|
+
return {
|
|
131
|
+
content: [{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: `Graph ${graphName} deleted`
|
|
134
|
+
}]
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
await logger.error('Delete graph tool execution failed', error instanceof Error ? error : new Error(String(error)), { graphName });
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
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
|
+
export default function registerAllTools(server) {
|
|
249
|
+
// Register query_graph tools
|
|
250
|
+
registerQueryGraphTool(server);
|
|
251
|
+
registerQueryGraphReadOnlyTool(server);
|
|
252
|
+
registerListGraphsTool(server);
|
|
253
|
+
registerDeleteGraphTool(server);
|
|
254
|
+
registerSetKeyTool(server);
|
|
255
|
+
registerGetKeyTool(server);
|
|
256
|
+
registerDeleteKeyTool(server);
|
|
257
|
+
registerListKeysTool(server);
|
|
258
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Client Configuration Types
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Sample MCP Client Configuration
|
|
6
|
+
*/
|
|
7
|
+
export const sampleMCPClientConfig = {
|
|
8
|
+
defaultServer: "falkordb",
|
|
9
|
+
servers: {
|
|
10
|
+
"falkordb": {
|
|
11
|
+
url: "http://localhost:3000/api/mcp",
|
|
12
|
+
apiKey: "your_api_key_here"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Sample MCP Server Configuration
|
|
18
|
+
*/
|
|
19
|
+
export const sampleMCPServerConfig = {
|
|
20
|
+
mcpServers: {
|
|
21
|
+
"falkordb": {
|
|
22
|
+
command: "docker",
|
|
23
|
+
args: [
|
|
24
|
+
"run",
|
|
25
|
+
"-i",
|
|
26
|
+
"--rm",
|
|
27
|
+
"-p", "3000:3000",
|
|
28
|
+
"--env-file", ".env",
|
|
29
|
+
"falkordb-mcpserver",
|
|
30
|
+
"falkordb://host.docker.internal:6379"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { sampleMCPClientConfig, sampleMCPServerConfig } from './mcp-client-config';
|
|
2
|
+
describe('MCP Client Configuration Models', () => {
|
|
3
|
+
describe('Type Definitions', () => {
|
|
4
|
+
it('should define MCPServerConfig interface correctly', () => {
|
|
5
|
+
// Arrange
|
|
6
|
+
const config = {
|
|
7
|
+
mcpServers: {
|
|
8
|
+
'test-server': {
|
|
9
|
+
command: 'node',
|
|
10
|
+
args: ['server.js']
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
// Assert - TypeScript compilation validates the interface
|
|
15
|
+
expect(config.mcpServers).toBeDefined();
|
|
16
|
+
expect(config.mcpServers['test-server'].command).toBe('node');
|
|
17
|
+
expect(config.mcpServers['test-server'].args).toEqual(['server.js']);
|
|
18
|
+
});
|
|
19
|
+
it('should define MCPClientConfig interface correctly', () => {
|
|
20
|
+
// Arrange
|
|
21
|
+
const config = {
|
|
22
|
+
defaultServer: 'test',
|
|
23
|
+
servers: {
|
|
24
|
+
'test': {
|
|
25
|
+
url: 'http://localhost:3000',
|
|
26
|
+
apiKey: 'secret'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
// Assert - TypeScript compilation validates the interface
|
|
31
|
+
expect(config.defaultServer).toBe('test');
|
|
32
|
+
expect(config.servers).toBeDefined();
|
|
33
|
+
expect(config.servers['test'].url).toBe('http://localhost:3000');
|
|
34
|
+
expect(config.servers['test'].apiKey).toBe('secret');
|
|
35
|
+
});
|
|
36
|
+
it('should allow optional properties in MCPClientConfig', () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const configWithoutDefaults = {
|
|
39
|
+
servers: {
|
|
40
|
+
'test': {
|
|
41
|
+
url: 'http://localhost:3000'
|
|
42
|
+
// apiKey is optional
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// defaultServer is optional
|
|
46
|
+
};
|
|
47
|
+
// Assert
|
|
48
|
+
expect(configWithoutDefaults.defaultServer).toBeUndefined();
|
|
49
|
+
expect(configWithoutDefaults.servers['test'].apiKey).toBeUndefined();
|
|
50
|
+
expect(configWithoutDefaults.servers['test'].url).toBe('http://localhost:3000');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('Sample Configurations', () => {
|
|
54
|
+
describe('sampleMCPClientConfig', () => {
|
|
55
|
+
it('should have correct structure and values', () => {
|
|
56
|
+
// Assert
|
|
57
|
+
expect(sampleMCPClientConfig).toBeDefined();
|
|
58
|
+
expect(sampleMCPClientConfig.defaultServer).toBe('falkordb');
|
|
59
|
+
expect(sampleMCPClientConfig.servers).toBeDefined();
|
|
60
|
+
expect(sampleMCPClientConfig.servers.falkordb).toBeDefined();
|
|
61
|
+
expect(sampleMCPClientConfig.servers.falkordb.url).toBe('http://localhost:3000/api/mcp');
|
|
62
|
+
expect(sampleMCPClientConfig.servers.falkordb.apiKey).toBe('your_api_key_here');
|
|
63
|
+
});
|
|
64
|
+
it('should be a valid MCPClientConfig', () => {
|
|
65
|
+
// Act - assign to typed variable to ensure type compliance
|
|
66
|
+
const config = sampleMCPClientConfig;
|
|
67
|
+
// Assert
|
|
68
|
+
expect(config).toBe(sampleMCPClientConfig);
|
|
69
|
+
});
|
|
70
|
+
it('should be immutable reference', () => {
|
|
71
|
+
// Act - get multiple references
|
|
72
|
+
const ref1 = sampleMCPClientConfig;
|
|
73
|
+
const ref2 = sampleMCPClientConfig;
|
|
74
|
+
// Assert
|
|
75
|
+
expect(ref1).toBe(ref2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('sampleMCPServerConfig', () => {
|
|
79
|
+
it('should have correct structure and values', () => {
|
|
80
|
+
// Assert
|
|
81
|
+
expect(sampleMCPServerConfig).toBeDefined();
|
|
82
|
+
expect(sampleMCPServerConfig.mcpServers).toBeDefined();
|
|
83
|
+
expect(sampleMCPServerConfig.mcpServers.falkordb).toBeDefined();
|
|
84
|
+
expect(sampleMCPServerConfig.mcpServers.falkordb.command).toBe('docker');
|
|
85
|
+
expect(sampleMCPServerConfig.mcpServers.falkordb.args).toBeInstanceOf(Array);
|
|
86
|
+
});
|
|
87
|
+
it('should have correct docker arguments', () => {
|
|
88
|
+
// Arrange
|
|
89
|
+
const expectedArgs = [
|
|
90
|
+
'run',
|
|
91
|
+
'-i',
|
|
92
|
+
'--rm',
|
|
93
|
+
'-p', '3000:3000',
|
|
94
|
+
'--env-file', '.env',
|
|
95
|
+
'falkordb-mcpserver',
|
|
96
|
+
'falkordb://host.docker.internal:6379'
|
|
97
|
+
];
|
|
98
|
+
// Assert
|
|
99
|
+
expect(sampleMCPServerConfig.mcpServers.falkordb.args).toEqual(expectedArgs);
|
|
100
|
+
});
|
|
101
|
+
it('should be a valid MCPServerConfig', () => {
|
|
102
|
+
// Act - assign to typed variable to ensure type compliance
|
|
103
|
+
const config = sampleMCPServerConfig;
|
|
104
|
+
// Assert
|
|
105
|
+
expect(config).toBe(sampleMCPServerConfig);
|
|
106
|
+
});
|
|
107
|
+
it('should include Docker container configuration', () => {
|
|
108
|
+
// Arrange
|
|
109
|
+
const falkordbConfig = sampleMCPServerConfig.mcpServers.falkordb;
|
|
110
|
+
// Assert
|
|
111
|
+
expect(falkordbConfig.command).toBe('docker');
|
|
112
|
+
expect(falkordbConfig.args).toContain('run');
|
|
113
|
+
expect(falkordbConfig.args).toContain('falkordb-mcpserver');
|
|
114
|
+
expect(falkordbConfig.args).toContain('falkordb://host.docker.internal:6379');
|
|
115
|
+
expect(falkordbConfig.args).toContain('-p');
|
|
116
|
+
expect(falkordbConfig.args).toContain('3000:3000');
|
|
117
|
+
expect(falkordbConfig.args).toContain('--env-file');
|
|
118
|
+
expect(falkordbConfig.args).toContain('.env');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('Configuration Usage Examples', () => {
|
|
123
|
+
it('should support multiple servers in client config', () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
const multiServerConfig = {
|
|
126
|
+
defaultServer: 'primary',
|
|
127
|
+
servers: {
|
|
128
|
+
'primary': {
|
|
129
|
+
url: 'http://localhost:3000/api/mcp',
|
|
130
|
+
apiKey: 'primary-key'
|
|
131
|
+
},
|
|
132
|
+
'backup': {
|
|
133
|
+
url: 'http://backup.example.com/api/mcp',
|
|
134
|
+
apiKey: 'backup-key'
|
|
135
|
+
},
|
|
136
|
+
'dev': {
|
|
137
|
+
url: 'http://dev.localhost:3001/api/mcp'
|
|
138
|
+
// No API key for dev
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
// Assert
|
|
143
|
+
expect(Object.keys(multiServerConfig.servers)).toHaveLength(3);
|
|
144
|
+
expect(multiServerConfig.servers.primary.apiKey).toBeDefined();
|
|
145
|
+
expect(multiServerConfig.servers.backup.apiKey).toBeDefined();
|
|
146
|
+
expect(multiServerConfig.servers.dev.apiKey).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
it('should support multiple server processes in server config', () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
const multiProcessConfig = {
|
|
151
|
+
mcpServers: {
|
|
152
|
+
'falkordb-main': {
|
|
153
|
+
command: 'node',
|
|
154
|
+
args: ['dist/index.js']
|
|
155
|
+
},
|
|
156
|
+
'falkordb-worker': {
|
|
157
|
+
command: 'node',
|
|
158
|
+
args: ['dist/worker.js', '--port', '3001']
|
|
159
|
+
},
|
|
160
|
+
'falkordb-docker': {
|
|
161
|
+
command: 'docker',
|
|
162
|
+
args: ['run', '-p', '3002:3000', 'falkordb-mcpserver']
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
// Assert
|
|
167
|
+
expect(Object.keys(multiProcessConfig.mcpServers)).toHaveLength(3);
|
|
168
|
+
expect(multiProcessConfig.mcpServers['falkordb-main'].command).toBe('node');
|
|
169
|
+
expect(multiProcessConfig.mcpServers['falkordb-worker'].args).toContain('--port');
|
|
170
|
+
expect(multiProcessConfig.mcpServers['falkordb-docker'].command).toBe('docker');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { FalkorDB } from 'falkordb';
|
|
2
|
+
import { config } from '../config/index.js';
|
|
3
|
+
import { AppError, CommonErrors } from '../errors/AppError.js';
|
|
4
|
+
import { logger } from './logger.service.js';
|
|
5
|
+
class FalkorDBService {
|
|
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
|
+
async initialize() {
|
|
14
|
+
// Idempotency guard: skip if already connected
|
|
15
|
+
if (this.client) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (this.initializingPromise) {
|
|
19
|
+
return this.initializingPromise;
|
|
20
|
+
}
|
|
21
|
+
this.retryCount = 0;
|
|
22
|
+
this.initializingPromise = this._initialize();
|
|
23
|
+
try {
|
|
24
|
+
await this.initializingPromise;
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
this.initializingPromise = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async _initialize() {
|
|
31
|
+
for (;; this.retryCount++) {
|
|
32
|
+
try {
|
|
33
|
+
// Fire-and-forget: informational log, not critical
|
|
34
|
+
logger.info('Attempting to connect to FalkorDB', {
|
|
35
|
+
host: config.falkorDB.host,
|
|
36
|
+
port: config.falkorDB.port,
|
|
37
|
+
attempt: this.retryCount + 1
|
|
38
|
+
});
|
|
39
|
+
this.client = await FalkorDB.connect({
|
|
40
|
+
socket: {
|
|
41
|
+
host: config.falkorDB.host,
|
|
42
|
+
port: config.falkorDB.port,
|
|
43
|
+
},
|
|
44
|
+
password: config.falkorDB.password,
|
|
45
|
+
username: config.falkorDB.username,
|
|
46
|
+
});
|
|
47
|
+
// Test connection
|
|
48
|
+
const connection = await this.client.connection;
|
|
49
|
+
await connection.ping();
|
|
50
|
+
// Fire-and-forget: informational log, not critical
|
|
51
|
+
logger.info('Successfully connected to FalkorDB');
|
|
52
|
+
this.retryCount = 0;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
// Clean up any partially connected client before retrying or throwing
|
|
57
|
+
if (this.client) {
|
|
58
|
+
try {
|
|
59
|
+
await this.client.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Ignore cleanup errors
|
|
63
|
+
}
|
|
64
|
+
this.client = null;
|
|
65
|
+
}
|
|
66
|
+
if (this.retryCount < this.maxRetries) {
|
|
67
|
+
// Fire-and-forget: informational log before retry delay
|
|
68
|
+
logger.warn('Failed to connect to FalkorDB, retrying...', {
|
|
69
|
+
attempt: this.retryCount + 1,
|
|
70
|
+
maxRetries: this.maxRetries,
|
|
71
|
+
error: error instanceof Error ? error.message : String(error)
|
|
72
|
+
});
|
|
73
|
+
const delay = Math.min(5000 * 2 ** this.retryCount, 30000) + Math.random() * 1000;
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const appError = new AppError(CommonErrors.CONNECTION_FAILED, `Failed to connect to FalkorDB after ${this.maxRetries} attempts: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
78
|
+
await logger.error('FalkorDB connection failed permanently', appError);
|
|
79
|
+
throw appError;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async executeQuery(graphName, query, params, readOnly = false) {
|
|
85
|
+
if (!this.client) {
|
|
86
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'FalkorDB client not initialized. Call initialize() first.', true);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const graph = this.client.selectGraph(graphName);
|
|
90
|
+
const result = readOnly
|
|
91
|
+
? await graph.roQuery(query, params)
|
|
92
|
+
: await graph.query(query, params);
|
|
93
|
+
// Fire-and-forget: informational log, not critical
|
|
94
|
+
logger.debug('Query executed successfully', {
|
|
95
|
+
graphName,
|
|
96
|
+
query: query.substring(0, 100) + (query.length > 100 ? '...' : ''),
|
|
97
|
+
hasParams: !!params,
|
|
98
|
+
readOnly
|
|
99
|
+
});
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to execute ${readOnly ? 'read-only ' : ''}query on graph '${graphName}': ${error instanceof Error ? error.message : String(error)}`, true);
|
|
104
|
+
// Sanitize query for error logging using same truncation as debug logs
|
|
105
|
+
const safeQuery = query.substring(0, 100) + (query.length > 100 ? '...' : '');
|
|
106
|
+
await logger.error('Query execution failed', appError, { graphName, query: safeQuery, readOnly });
|
|
107
|
+
throw appError;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Execute a read-only query on a specific graph
|
|
112
|
+
* This is useful for replica instances or when you want to ensure no writes occur
|
|
113
|
+
* @param graphName - The name of the graph to query
|
|
114
|
+
* @param query - The OpenCypher query to execute
|
|
115
|
+
* @param params - Optional query parameters
|
|
116
|
+
* @returns Query result
|
|
117
|
+
*/
|
|
118
|
+
async executeReadOnlyQuery(graphName, query, params) {
|
|
119
|
+
return this.executeQuery(graphName, query, params, true);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Lists all available graphs in FalkorDB
|
|
123
|
+
* @returns Array of graph names
|
|
124
|
+
*/
|
|
125
|
+
async listGraphs() {
|
|
126
|
+
if (!this.client) {
|
|
127
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'FalkorDB client not initialized. Call initialize() first.', true);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const graphs = await this.client.list();
|
|
131
|
+
// Fire-and-forget: informational log, not critical
|
|
132
|
+
logger.debug('Listed graphs successfully', { count: graphs.length });
|
|
133
|
+
return graphs;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to list graphs: ${error instanceof Error ? error.message : String(error)}`, true);
|
|
137
|
+
await logger.error('Failed to list graphs', appError);
|
|
138
|
+
throw appError;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async deleteGraph(graphName) {
|
|
142
|
+
if (!this.client) {
|
|
143
|
+
throw new AppError(CommonErrors.CONNECTION_FAILED, 'FalkorDB client not initialized. Call initialize() first.', true);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await this.client.selectGraph(graphName).delete();
|
|
147
|
+
// Fire-and-forget: informational log, not critical
|
|
148
|
+
logger.info('Graph deleted successfully', { graphName });
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const appError = new AppError(CommonErrors.OPERATION_FAILED, `Failed to delete graph '${graphName}': ${error instanceof Error ? error.message : String(error)}`, true);
|
|
152
|
+
await logger.error('Failed to delete graph', appError, { graphName });
|
|
153
|
+
throw appError;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async close() {
|
|
157
|
+
if (this.client) {
|
|
158
|
+
try {
|
|
159
|
+
await this.client.close();
|
|
160
|
+
// Fire-and-forget: informational log, not critical
|
|
161
|
+
logger.info('FalkorDB connection closed successfully');
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
// Fire-and-forget: best-effort log during shutdown
|
|
165
|
+
logger.error('Error closing FalkorDB connection', error instanceof Error ? error : new Error(String(error)));
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
this.client = null;
|
|
169
|
+
this.retryCount = 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Export a singleton instance
|
|
175
|
+
export const falkorDBService = new FalkorDBService();
|