@berthojoris/mcp-mysql-server 1.0.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.
@@ -0,0 +1,52 @@
1
+ import { FeatureConfig } from '../config/featureConfig.js';
2
+ export declare class SecurityLayer {
3
+ private ajv;
4
+ private readonly dangerousKeywords;
5
+ private readonly allowedOperations;
6
+ private readonly ddlOperations;
7
+ private featureConfig;
8
+ constructor(featureConfig?: FeatureConfig);
9
+ /**
10
+ * Validate input against a JSON schema
11
+ */
12
+ validateInput(schema: object, data: any): {
13
+ valid: boolean;
14
+ errors?: any;
15
+ };
16
+ /**
17
+ * Validate and sanitize table/column names to prevent SQL injection
18
+ */
19
+ validateIdentifier(identifier: string): {
20
+ valid: boolean;
21
+ error?: string;
22
+ };
23
+ /**
24
+ * Validate SQL query for security issues
25
+ */
26
+ validateQuery(query: string): {
27
+ valid: boolean;
28
+ error?: string;
29
+ queryType?: string;
30
+ };
31
+ /**
32
+ * Validate parameter values to prevent injection
33
+ */
34
+ validateParameters(params: any[]): {
35
+ valid: boolean;
36
+ error?: string;
37
+ sanitizedParams?: any[];
38
+ };
39
+ /**
40
+ * Check if a query is a read-only SELECT query
41
+ */
42
+ isReadOnlyQuery(query: string): boolean;
43
+ /**
44
+ * Check if a query contains dangerous operations
45
+ */
46
+ hasDangerousOperations(query: string): boolean;
47
+ /**
48
+ * Escape identifier for safe use in SQL queries
49
+ */
50
+ escapeIdentifier(identifier: string): string;
51
+ }
52
+ export default SecurityLayer;
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SecurityLayer = void 0;
7
+ const ajv_1 = __importDefault(require("ajv"));
8
+ const featureConfig_js_1 = require("../config/featureConfig.js");
9
+ class SecurityLayer {
10
+ constructor(featureConfig) {
11
+ this.ajv = new ajv_1.default();
12
+ this.featureConfig = featureConfig || new featureConfig_js_1.FeatureConfig();
13
+ // Define dangerous SQL keywords that should always be blocked (security threats)
14
+ this.dangerousKeywords = [
15
+ 'GRANT', 'REVOKE', 'LOAD_FILE', 'INTO OUTFILE', 'INTO DUMPFILE',
16
+ 'LOAD DATA', 'INFORMATION_SCHEMA', 'MYSQL', 'PERFORMANCE_SCHEMA',
17
+ 'SYS', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN', 'PROCEDURE',
18
+ 'FUNCTION', 'TRIGGER', 'EVENT', 'VIEW', 'INDEX', 'DATABASE',
19
+ 'SCHEMA', 'USER', 'PASSWORD', 'SLEEP', 'BENCHMARK'
20
+ ];
21
+ // Define basic allowed SQL operations
22
+ this.allowedOperations = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
23
+ // Define DDL operations that require special permission
24
+ this.ddlOperations = ['CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME'];
25
+ }
26
+ /**
27
+ * Validate input against a JSON schema
28
+ */
29
+ validateInput(schema, data) {
30
+ const validate = this.ajv.compile(schema);
31
+ const valid = validate(data);
32
+ if (!valid) {
33
+ return {
34
+ valid: false,
35
+ errors: validate.errors
36
+ };
37
+ }
38
+ return { valid: true };
39
+ }
40
+ /**
41
+ * Validate and sanitize table/column names to prevent SQL injection
42
+ */
43
+ validateIdentifier(identifier) {
44
+ if (!identifier || typeof identifier !== 'string') {
45
+ return { valid: false, error: 'Identifier must be a non-empty string' };
46
+ }
47
+ // Check length
48
+ if (identifier.length > 64) {
49
+ return { valid: false, error: 'Identifier too long (max 64 characters)' };
50
+ }
51
+ // MySQL identifier rules: alphanumeric, underscore, dollar sign
52
+ // Must start with letter, underscore, or dollar sign
53
+ const identifierRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
54
+ if (!identifierRegex.test(identifier)) {
55
+ return { valid: false, error: 'Invalid identifier format' };
56
+ }
57
+ // Check against MySQL reserved words (basic list)
58
+ const reservedWords = [
59
+ 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'WHERE', 'JOIN',
60
+ 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'ON', 'AS', 'AND', 'OR', 'NOT',
61
+ 'NULL', 'TRUE', 'FALSE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT',
62
+ 'OFFSET', 'DISTINCT', 'ALL', 'EXISTS', 'IN', 'BETWEEN', 'LIKE',
63
+ 'REGEXP', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'IF', 'IFNULL'
64
+ ];
65
+ if (reservedWords.includes(identifier.toUpperCase())) {
66
+ return { valid: false, error: 'Identifier cannot be a reserved word' };
67
+ }
68
+ return { valid: true };
69
+ }
70
+ /**
71
+ * Validate SQL query for security issues
72
+ */
73
+ validateQuery(query) {
74
+ if (!query || typeof query !== 'string') {
75
+ return { valid: false, error: 'Query must be a non-empty string' };
76
+ }
77
+ const trimmedQuery = query.trim().toUpperCase();
78
+ // Check for empty query
79
+ if (trimmedQuery.length === 0) {
80
+ return { valid: false, error: 'Query cannot be empty' };
81
+ }
82
+ // Check for multiple statements (basic check)
83
+ if (query.includes(';') && !query.trim().endsWith(';')) {
84
+ return { valid: false, error: 'Multiple statements not allowed' };
85
+ }
86
+ // Remove trailing semicolon for analysis
87
+ const cleanQuery = trimmedQuery.replace(/;$/, '');
88
+ // Determine query type - check basic operations first
89
+ let queryType = '';
90
+ for (const operation of this.allowedOperations) {
91
+ if (cleanQuery.startsWith(operation)) {
92
+ queryType = operation;
93
+ break;
94
+ }
95
+ }
96
+ // If not a basic operation, check if it's a DDL operation
97
+ if (!queryType) {
98
+ for (const ddlOp of this.ddlOperations) {
99
+ if (cleanQuery.startsWith(ddlOp)) {
100
+ // Check if DDL permission is enabled
101
+ if (this.featureConfig.isCategoryEnabled(featureConfig_js_1.ToolCategory.DDL)) {
102
+ queryType = ddlOp;
103
+ break;
104
+ }
105
+ else {
106
+ return {
107
+ valid: false,
108
+ error: `DDL operation '${ddlOp}' requires 'ddl' permission. Add 'ddl' to your permissions configuration.`
109
+ };
110
+ }
111
+ }
112
+ }
113
+ }
114
+ if (!queryType) {
115
+ return { valid: false, error: 'Query type not allowed' };
116
+ }
117
+ // Check for dangerous keywords (always blocked regardless of permissions)
118
+ for (const keyword of this.dangerousKeywords) {
119
+ if (cleanQuery.includes(keyword)) {
120
+ return { valid: false, error: `Dangerous keyword detected: ${keyword}` };
121
+ }
122
+ }
123
+ // Additional checks for specific query types
124
+ if (queryType === 'SELECT') {
125
+ // Check for UNION attacks
126
+ if (cleanQuery.includes('UNION')) {
127
+ return { valid: false, error: 'UNION operations not allowed' };
128
+ }
129
+ // Check for subqueries in FROM clause (basic check)
130
+ if (cleanQuery.includes('FROM (')) {
131
+ return { valid: false, error: 'Subqueries in FROM clause not allowed' };
132
+ }
133
+ }
134
+ // Check for comment-based injection attempts
135
+ if (cleanQuery.includes('/*') || cleanQuery.includes('--') || cleanQuery.includes('#')) {
136
+ return { valid: false, error: 'Comments not allowed in queries' };
137
+ }
138
+ return { valid: true, queryType };
139
+ }
140
+ /**
141
+ * Validate parameter values to prevent injection
142
+ */
143
+ validateParameters(params) {
144
+ if (!params) {
145
+ return { valid: true, sanitizedParams: [] };
146
+ }
147
+ if (!Array.isArray(params)) {
148
+ return { valid: false, error: 'Parameters must be an array' };
149
+ }
150
+ const sanitizedParams = [];
151
+ for (let i = 0; i < params.length; i++) {
152
+ const param = params[i];
153
+ // Check for null/undefined
154
+ if (param === null || param === undefined) {
155
+ sanitizedParams.push(null);
156
+ continue;
157
+ }
158
+ // Validate based on type
159
+ if (typeof param === 'string') {
160
+ // Check string length
161
+ if (param.length > 65535) {
162
+ return { valid: false, error: `Parameter ${i} too long (max 65535 characters)` };
163
+ }
164
+ // Don't modify strings - let MySQL handle escaping through prepared statements
165
+ sanitizedParams.push(param);
166
+ }
167
+ else if (typeof param === 'number') {
168
+ // Validate number
169
+ if (!Number.isFinite(param)) {
170
+ return { valid: false, error: `Parameter ${i} must be a finite number` };
171
+ }
172
+ sanitizedParams.push(param);
173
+ }
174
+ else if (typeof param === 'boolean') {
175
+ sanitizedParams.push(param);
176
+ }
177
+ else if (param instanceof Date) {
178
+ sanitizedParams.push(param);
179
+ }
180
+ else {
181
+ return { valid: false, error: `Parameter ${i} has unsupported type: ${typeof param}` };
182
+ }
183
+ }
184
+ return { valid: true, sanitizedParams };
185
+ }
186
+ /**
187
+ * Check if a query is a read-only SELECT query
188
+ */
189
+ isReadOnlyQuery(query) {
190
+ const validation = this.validateQuery(query);
191
+ return validation.valid && validation.queryType === 'SELECT';
192
+ }
193
+ /**
194
+ * Check if a query contains dangerous operations
195
+ */
196
+ hasDangerousOperations(query) {
197
+ const validation = this.validateQuery(query);
198
+ return !validation.valid;
199
+ }
200
+ /**
201
+ * Escape identifier for safe use in SQL queries
202
+ */
203
+ escapeIdentifier(identifier) {
204
+ const validation = this.validateIdentifier(identifier);
205
+ if (!validation.valid) {
206
+ throw new Error(`Invalid identifier: ${validation.error}`);
207
+ }
208
+ // Use backticks to escape MySQL identifiers
209
+ return `\`${identifier}\``;
210
+ }
211
+ }
212
+ exports.SecurityLayer = SecurityLayer;
213
+ exports.default = SecurityLayer;
@@ -0,0 +1,2 @@
1
+ declare const app: import("express-serve-static-core").Express;
2
+ export default app;
package/dist/server.js ADDED
@@ -0,0 +1,283 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const express_1 = __importDefault(require("express"));
7
+ const cors_1 = __importDefault(require("cors"));
8
+ const helmet_1 = __importDefault(require("helmet"));
9
+ const morgan_1 = __importDefault(require("morgan"));
10
+ const express_rate_limit_1 = __importDefault(require("express-rate-limit"));
11
+ const index_1 = require("./index");
12
+ const winston_1 = require("winston");
13
+ // Initialize the MCP instance
14
+ const mcp = new index_1.MySQLMCP();
15
+ // Create Winston logger
16
+ const logger = (0, winston_1.createLogger)({
17
+ level: 'info',
18
+ format: winston_1.format.combine(winston_1.format.timestamp(), winston_1.format.json()),
19
+ transports: [
20
+ new winston_1.transports.Console(),
21
+ new winston_1.transports.File({ filename: 'logs/error.log', level: 'error' }),
22
+ new winston_1.transports.File({ filename: 'logs/combined.log' })
23
+ ]
24
+ });
25
+ // Initialize Express app
26
+ const app = (0, express_1.default)();
27
+ const PORT = process.env.PORT || 3000;
28
+ // Middleware
29
+ app.use((0, helmet_1.default)()); // Security headers
30
+ app.use((0, cors_1.default)()); // Enable CORS
31
+ app.use(express_1.default.json()); // Parse JSON bodies
32
+ app.use((0, morgan_1.default)('combined')); // HTTP request logging
33
+ // Rate limiting
34
+ const apiLimiter = (0, express_rate_limit_1.default)({
35
+ windowMs: 15 * 60 * 1000, // 15 minutes
36
+ max: 100, // Limit each IP to 100 requests per windowMs
37
+ standardHeaders: true,
38
+ legacyHeaders: false,
39
+ });
40
+ app.use(apiLimiter);
41
+ // No authentication middleware needed for MCP server
42
+ // Error handling middleware
43
+ const errorHandler = (err, req, res, next) => {
44
+ logger.error(`${err.name}: ${err.message}`, {
45
+ path: req.path,
46
+ method: req.method,
47
+ body: req.body,
48
+ stack: err.stack
49
+ });
50
+ res.status(500).json({
51
+ error: {
52
+ code: 'SERVER_ERROR',
53
+ message: 'An unexpected error occurred',
54
+ details: process.env.NODE_ENV === 'production' ? 'See server logs for details' : err.message
55
+ }
56
+ });
57
+ };
58
+ // Health check endpoint (no auth required)
59
+ app.get('/health', (req, res) => {
60
+ res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
61
+ });
62
+ // Feature configuration status endpoint
63
+ app.get('/features', (req, res) => {
64
+ try {
65
+ const featureStatus = mcp.getFeatureStatus();
66
+ res.status(200).json(featureStatus);
67
+ }
68
+ catch (error) {
69
+ logger.error('Error getting feature status', { error });
70
+ res.status(500).json({
71
+ status: 'error',
72
+ error: 'Failed to retrieve feature configuration status'
73
+ });
74
+ }
75
+ });
76
+ // API routes - no authentication required for MCP server
77
+ const apiRouter = express_1.default.Router();
78
+ app.use('/api', apiRouter);
79
+ // Database Tools Routes
80
+ apiRouter.get('/databases', async (req, res, next) => {
81
+ try {
82
+ const result = await mcp.listDatabases();
83
+ res.json(result);
84
+ }
85
+ catch (error) {
86
+ next(error);
87
+ }
88
+ });
89
+ apiRouter.get('/tables', async (req, res, next) => {
90
+ try {
91
+ const result = await mcp.listTables({ database: undefined });
92
+ res.json(result);
93
+ }
94
+ catch (error) {
95
+ next(error);
96
+ }
97
+ });
98
+ apiRouter.get('/tables/:tableName/schema', async (req, res, next) => {
99
+ try {
100
+ const { tableName } = req.params;
101
+ const result = await mcp.readTableSchema({ table_name: tableName });
102
+ res.json(result);
103
+ }
104
+ catch (error) {
105
+ next(error);
106
+ }
107
+ });
108
+ // CRUD Operations Routes
109
+ apiRouter.post('/tables/:tableName/records', async (req, res, next) => {
110
+ try {
111
+ const { tableName } = req.params;
112
+ const { data } = req.body;
113
+ if (!data) {
114
+ return res.status(400).json({
115
+ error: {
116
+ code: 'INVALID_INPUT',
117
+ message: 'Missing data field in request body',
118
+ details: 'The request body must contain a data object with the record fields'
119
+ }
120
+ });
121
+ }
122
+ const result = await mcp.createRecord({
123
+ table_name: tableName,
124
+ data
125
+ });
126
+ res.status(201).json(result);
127
+ }
128
+ catch (error) {
129
+ next(error);
130
+ }
131
+ });
132
+ apiRouter.get('/tables/:tableName/records', async (req, res, next) => {
133
+ try {
134
+ const { tableName } = req.params;
135
+ const { filters, limit, offset, sort_by, sort_direction } = req.query;
136
+ const result = await mcp.readRecords({
137
+ table_name: tableName,
138
+ filters: filters ? JSON.parse(filters) : undefined,
139
+ pagination: {
140
+ page: offset ? Math.floor(parseInt(offset) / (limit ? parseInt(limit) : 10)) + 1 : 1,
141
+ limit: limit ? parseInt(limit) : 10
142
+ },
143
+ sorting: sort_by ? {
144
+ field: sort_by,
145
+ direction: sort_direction?.toLowerCase() === 'desc' ? 'desc' : 'asc'
146
+ } : undefined
147
+ });
148
+ res.json(result);
149
+ }
150
+ catch (error) {
151
+ next(error);
152
+ }
153
+ });
154
+ apiRouter.put('/tables/:tableName/records/:id', async (req, res, next) => {
155
+ try {
156
+ const { tableName, id } = req.params;
157
+ const { data, id_field } = req.body;
158
+ if (!data) {
159
+ return res.status(400).json({
160
+ error: {
161
+ code: 'INVALID_INPUT',
162
+ message: 'Missing data field in request body',
163
+ details: 'The request body must contain a data object with the fields to update'
164
+ }
165
+ });
166
+ }
167
+ const result = await mcp.updateRecord({
168
+ table_name: tableName,
169
+ data,
170
+ conditions: [{ field: id_field || 'id', operator: '=', value: id }]
171
+ });
172
+ res.json(result);
173
+ }
174
+ catch (error) {
175
+ next(error);
176
+ }
177
+ });
178
+ apiRouter.delete('/tables/:tableName/records/:id', async (req, res, next) => {
179
+ try {
180
+ const { tableName, id } = req.params;
181
+ const { id_field } = req.query;
182
+ const result = await mcp.deleteRecord({
183
+ table_name: tableName,
184
+ conditions: [{ field: id_field || 'id', operator: '=', value: id }]
185
+ });
186
+ res.json(result);
187
+ }
188
+ catch (error) {
189
+ next(error);
190
+ }
191
+ });
192
+ // Query Tools Routes
193
+ apiRouter.post('/query', async (req, res, next) => {
194
+ try {
195
+ const { query, params } = req.body;
196
+ if (!query) {
197
+ return res.status(400).json({
198
+ error: {
199
+ code: 'INVALID_INPUT',
200
+ message: 'Missing query field in request body',
201
+ details: 'The request body must contain a query string'
202
+ }
203
+ });
204
+ }
205
+ const result = await mcp.runQuery({
206
+ query,
207
+ params: params || []
208
+ });
209
+ res.json(result);
210
+ }
211
+ catch (error) {
212
+ next(error);
213
+ }
214
+ });
215
+ apiRouter.post('/execute', async (req, res, next) => {
216
+ try {
217
+ const { query, params } = req.body;
218
+ if (!query) {
219
+ return res.status(400).json({
220
+ error: {
221
+ code: 'INVALID_INPUT',
222
+ message: 'Missing query field in request body',
223
+ details: 'The request body must contain a query string'
224
+ }
225
+ });
226
+ }
227
+ const result = await mcp.executeSql({
228
+ query,
229
+ params: params || []
230
+ });
231
+ res.json(result);
232
+ }
233
+ catch (error) {
234
+ next(error);
235
+ }
236
+ });
237
+ // Utility Tools Routes
238
+ apiRouter.get('/connection', async (req, res, next) => {
239
+ try {
240
+ const result = await mcp.describeConnection();
241
+ res.json(result);
242
+ }
243
+ catch (error) {
244
+ next(error);
245
+ }
246
+ });
247
+ apiRouter.get('/connection/test', async (req, res, next) => {
248
+ try {
249
+ const result = await mcp.testConnection();
250
+ res.json(result);
251
+ }
252
+ catch (error) {
253
+ next(error);
254
+ }
255
+ });
256
+ apiRouter.get('/tables/:tableName/relationships', async (req, res, next) => {
257
+ try {
258
+ const { tableName } = req.params;
259
+ const result = await mcp.getTableRelationships({ table_name: tableName });
260
+ res.json(result);
261
+ }
262
+ catch (error) {
263
+ next(error);
264
+ }
265
+ });
266
+ // Apply error handler
267
+ app.use(errorHandler);
268
+ // Start the server
269
+ const server = app.listen(PORT, () => {
270
+ logger.info(`MCP MySQL Server running on port ${PORT}`);
271
+ console.log(`MCP MySQL Server running on port ${PORT}`);
272
+ });
273
+ // Graceful shutdown
274
+ process.on('SIGTERM', () => {
275
+ logger.info('SIGTERM signal received: closing HTTP server');
276
+ server.close(async () => {
277
+ logger.info('HTTP server closed');
278
+ await mcp.close();
279
+ logger.info('Database connections closed');
280
+ process.exit(0);
281
+ });
282
+ });
283
+ exports.default = app;
@@ -0,0 +1,59 @@
1
+ import SecurityLayer from '../security/securityLayer';
2
+ import { FilterCondition, Pagination, Sorting } from '../validation/schemas';
3
+ export declare class CrudTools {
4
+ private db;
5
+ private security;
6
+ constructor(security: SecurityLayer);
7
+ /**
8
+ * Create a new record in the specified table
9
+ */
10
+ createRecord(params: {
11
+ table_name: string;
12
+ data: Record<string, any>;
13
+ }): Promise<{
14
+ status: string;
15
+ data?: any;
16
+ error?: string;
17
+ }>;
18
+ /**
19
+ * Read records from the specified table with optional filters, pagination, and sorting
20
+ */
21
+ readRecords(params: {
22
+ table_name: string;
23
+ filters?: FilterCondition[];
24
+ pagination?: Pagination;
25
+ sorting?: Sorting;
26
+ }): Promise<{
27
+ status: string;
28
+ data?: any[];
29
+ total?: number;
30
+ error?: string;
31
+ }>;
32
+ /**
33
+ * Update records in the specified table based on conditions
34
+ */
35
+ updateRecord(params: {
36
+ table_name: string;
37
+ data: Record<string, any>;
38
+ conditions: FilterCondition[];
39
+ }): Promise<{
40
+ status: string;
41
+ data?: {
42
+ affectedRows: number;
43
+ };
44
+ error?: string;
45
+ }>;
46
+ /**
47
+ * Delete records from the specified table based on conditions
48
+ */
49
+ deleteRecord(params: {
50
+ table_name: string;
51
+ conditions: FilterCondition[];
52
+ }): Promise<{
53
+ status: string;
54
+ data?: {
55
+ affectedRows: number;
56
+ };
57
+ error?: string;
58
+ }>;
59
+ }