UNPKG

@benborla29/mcp-server-mysql

Version:

MCP server for interacting with MySQL databases based on Node

606 lines (605 loc) 24.3 kB
#!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import * as mysql2 from "mysql2/promise"; import * as dotenv from "dotenv"; import SqlParser from 'node-sql-parser'; import { log } from './utils/index.js'; dotenv.config(); log('info', 'Starting MCP server...'); if (process.env.NODE_ENV === 'test' && !process.env.MYSQL_DB) { process.env.MYSQL_DB = 'mcp_test_db'; } const ALLOW_INSERT_OPERATION = process.env.ALLOW_INSERT_OPERATION === 'true'; const ALLOW_UPDATE_OPERATION = process.env.ALLOW_UPDATE_OPERATION === 'true'; const ALLOW_DELETE_OPERATION = process.env.ALLOW_DELETE_OPERATION === 'true'; const ALLOW_DDL_OPERATION = process.env.ALLOW_DDL_OPERATION === 'true'; const SCHEMA_INSERT_PERMISSIONS = parseSchemaPermissions(process.env.SCHEMA_INSERT_PERMISSIONS); const SCHEMA_UPDATE_PERMISSIONS = parseSchemaPermissions(process.env.SCHEMA_UPDATE_PERMISSIONS); const SCHEMA_DELETE_PERMISSIONS = parseSchemaPermissions(process.env.SCHEMA_DELETE_PERMISSIONS); const SCHEMA_DDL_PERMISSIONS = parseSchemaPermissions(process.env.SCHEMA_DDL_PERMISSIONS); const isMultiDbMode = !process.env.MYSQL_DB || process.env.MYSQL_DB.trim() === ''; if (isMultiDbMode && process.env.MULTI_DB_WRITE_MODE !== 'true') { log('error', 'Multi-DB mode detected - enabling read-only mode for safety'); } const isTestEnvironment = process.env.NODE_ENV === 'test' || process.env.VITEST; function safeExit(code) { if (!isTestEnvironment) { process.exit(code); } else { log('error', `[Test mode] Would have called process.exit(${code})`); } } function parseSchemaPermissions(permissionsString) { const permissions = {}; if (!permissionsString) { return permissions; } const permissionPairs = permissionsString.split(','); for (const pair of permissionPairs) { const [schema, value] = pair.split(':'); if (schema && value) { permissions[schema.trim()] = value.trim() === 'true'; } } return permissions; } function isInsertAllowedForSchema(schema) { if (!schema) { return ALLOW_INSERT_OPERATION; } return schema in SCHEMA_INSERT_PERMISSIONS ? SCHEMA_INSERT_PERMISSIONS[schema] : ALLOW_INSERT_OPERATION; } function isUpdateAllowedForSchema(schema) { if (!schema) { return ALLOW_UPDATE_OPERATION; } return schema in SCHEMA_UPDATE_PERMISSIONS ? SCHEMA_UPDATE_PERMISSIONS[schema] : ALLOW_UPDATE_OPERATION; } function isDeleteAllowedForSchema(schema) { if (!schema) { return ALLOW_DELETE_OPERATION; } return schema in SCHEMA_DELETE_PERMISSIONS ? SCHEMA_DELETE_PERMISSIONS[schema] : ALLOW_DELETE_OPERATION; } function isDDLAllowedForSchema(schema) { if (!schema) { return ALLOW_DDL_OPERATION; } return schema in SCHEMA_DDL_PERMISSIONS ? SCHEMA_DDL_PERMISSIONS[schema] : ALLOW_DDL_OPERATION; } function extractSchemaFromQuery(sql) { const defaultSchema = process.env.MYSQL_DB || null; if (defaultSchema && !isMultiDbMode) { return defaultSchema; } const useMatch = sql.match(/USE\s+`?([a-zA-Z0-9_]+)`?/i); if (useMatch && useMatch[1]) { return useMatch[1]; } const dbTableMatch = sql.match(/`?([a-zA-Z0-9_]+)`?\.`?[a-zA-Z0-9_]+`?/i); if (dbTableMatch && dbTableMatch[1]) { return dbTableMatch[1]; } return defaultSchema; } let toolDescription = 'Run SQL queries against MySQL database'; if (isMultiDbMode) { toolDescription += ' (Multi-DB mode enabled)'; } if (ALLOW_INSERT_OPERATION || ALLOW_UPDATE_OPERATION || ALLOW_DELETE_OPERATION || ALLOW_DDL_OPERATION) { toolDescription += ' with support for:'; if (ALLOW_INSERT_OPERATION) { toolDescription += ' INSERT,'; } if (ALLOW_UPDATE_OPERATION) { toolDescription += ' UPDATE,'; } if (ALLOW_DELETE_OPERATION) { toolDescription += ' DELETE,'; } if (ALLOW_DDL_OPERATION) { toolDescription += ' DDL,'; } toolDescription = toolDescription.replace(/,$/, '') + ' and READ operations'; if (Object.keys(SCHEMA_INSERT_PERMISSIONS).length > 0 || Object.keys(SCHEMA_UPDATE_PERMISSIONS).length > 0 || Object.keys(SCHEMA_DELETE_PERMISSIONS).length > 0 || Object.keys(SCHEMA_DDL_PERMISSIONS).length > 0) { toolDescription += ' (Schema-specific permissions enabled)'; } } else { toolDescription += ' (READ-ONLY)'; } const config = { server: { name: '@benborla29/mcp-server-mysql', version: '0.1.18', connectionTypes: ['stdio'], }, mysql: { host: process.env.MYSQL_HOST || '127.0.0.1', port: Number(process.env.MYSQL_PORT || '3306'), user: process.env.MYSQL_USER || 'root', password: process.env.MYSQL_PASS || 'root', database: process.env.MYSQL_DB || undefined, connectionLimit: 10, authPlugins: { mysql_clear_password: () => () => Buffer.from(process.env.MYSQL_PASS || 'root'), }, ...(process.env.MYSQL_SSL === 'true' ? { ssl: { rejectUnauthorized: process.env.MYSQL_SSL_REJECT_UNAUTHORIZED === 'true', }, } : {}), }, paths: { schema: 'schema', }, }; log('info', 'MySQL Configuration:', JSON.stringify({ host: config.mysql.host, port: config.mysql.port, user: config.mysql.user, password: config.mysql.password ? '******' : 'not set', database: config.mysql.database || 'MULTI_DB_MODE', ssl: process.env.MYSQL_SSL === 'true' ? 'enabled' : 'disabled', multiDbMode: isMultiDbMode ? 'enabled' : 'disabled', }, null, 2)); let poolPromise; const getPool = () => { if (!poolPromise) { poolPromise = new Promise((resolve, reject) => { try { const pool = mysql2.createPool(config.mysql); log('info', 'MySQL pool created successfully'); resolve(pool); } catch (error) { log('error', 'Error creating MySQL pool:', error); reject(error); } }); } return poolPromise; }; let serverInstance = null; const getServer = () => { if (!serverInstance) { serverInstance = new Promise((resolve) => { const server = new Server(config.server, { capabilities: { resources: {}, tools: { mysql_query: { description: toolDescription, inputSchema: { type: 'object', properties: { sql: { type: 'string', description: 'The SQL query to execute', }, }, required: ['sql'], }, }, }, }, }); server.setRequestHandler(ListResourcesRequestSchema, async () => { try { log('error', 'Handling ListResourcesRequest'); if (isMultiDbMode) { const databases = (await executeQuery('SHOW DATABASES')); let allResources = []; for (const db of databases) { if (['information_schema', 'mysql', 'performance_schema', 'sys'].includes(db.Database)) { continue; } const tables = (await executeQuery(`SELECT table_name FROM information_schema.tables WHERE table_schema = '${db.Database}'`)); allResources.push(...tables.map((row) => ({ uri: new URL(`${db.Database}/${row.table_name}/${config.paths.schema}`, `${config.mysql.host}:${config.mysql.port}`).href, mimeType: 'application/json', name: `"${db.Database}.${row.table_name}" database schema`, }))); } return { resources: allResources, }; } else { const results = (await executeQuery('SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()')); return { resources: results.map((row) => ({ uri: new URL(`${row.table_name}/${config.paths.schema}`, `${config.mysql.host}:${config.mysql.port}`).href, mimeType: 'application/json', name: `"${row.table_name}" database schema`, })), }; } } catch (error) { log('error', 'Error in ListResourcesRequest handler:', error); throw error; } }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { log('error', 'Handling ReadResourceRequest'); const resourceUrl = new URL(request.params.uri); const pathComponents = resourceUrl.pathname.split('/'); const schema = pathComponents.pop(); const tableName = pathComponents.pop(); let dbName = null; if (isMultiDbMode && pathComponents.length > 0) { dbName = pathComponents.pop() || null; } if (schema !== config.paths.schema) { throw new Error('Invalid resource URI'); } let columnsQuery = 'SELECT column_name, data_type FROM information_schema.columns WHERE table_name = ?'; let queryParams = [tableName]; if (dbName) { columnsQuery += ' AND table_schema = ?'; queryParams.push(dbName); } const results = (await executeQuery(columnsQuery, queryParams)); return { contents: [ { uri: request.params.uri, mimeType: 'application/json', text: JSON.stringify(results, null, 2), }, ], }; } catch (error) { log('error', 'Error in ReadResourceRequest handler:', error); throw error; } }); server.setRequestHandler(ListToolsRequestSchema, async () => { log('error', 'Handling ListToolsRequest'); const toolsResponse = { tools: [ { name: 'mysql_query', description: toolDescription, inputSchema: { type: 'object', properties: { sql: { type: 'string', description: 'The SQL query to execute', }, }, required: ['sql'], }, }, ], }; log('error', 'ListToolsRequest response:', JSON.stringify(toolsResponse, null, 2)); return toolsResponse; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { log('error', 'Handling CallToolRequest:', request.params.name); if (request.params.name !== 'mysql_query') { throw new Error(`Unknown tool: ${request.params.name}`); } const sql = request.params.arguments?.sql; return executeReadOnlyQuery(sql); } catch (error) { log('error', 'Error in CallToolRequest handler:', error); throw error; } }); resolve(server); }); } return serverInstance; }; const { Parser } = SqlParser; const parser = new Parser(); async function getQueryTypes(query) { try { log('error', "Parsing SQL query: ", query); const astOrArray = parser.astify(query, { database: 'mysql' }); const statements = Array.isArray(astOrArray) ? astOrArray : [astOrArray]; log('error', "Parsed SQL AST: ", statements.map(stmt => stmt.type?.toLowerCase() ?? 'unknown')); return statements.map(stmt => stmt.type?.toLowerCase() ?? 'unknown'); } catch (err) { log('error', "sqlParser error, query: ", query); log('error', 'Error parsing SQL query:', err); throw new Error(`Parsing failed: ${err.message}`); } } async function executeQuery(sql, params = []) { let connection; try { const pool = await getPool(); connection = await pool.getConnection(); const result = await connection.query(sql.toLocaleLowerCase(), params); return (Array.isArray(result) ? result[0] : result); } catch (error) { log('error', 'Error executing query:', error); throw error; } finally { if (connection) { connection.release(); log('error', 'Connection released'); } } } async function executeReadOnlyQuery(sql) { let connection; try { const queryTypes = await getQueryTypes(sql); const schema = extractSchemaFromQuery(sql); const isUpdateOperation = queryTypes.some(type => ['update'].includes(type)); const isInsertOperation = queryTypes.some(type => ['insert'].includes(type)); const isDeleteOperation = queryTypes.some(type => ['delete'].includes(type)); const isDDLOperation = queryTypes.some(type => ['create', 'alter', 'drop', 'truncate'].includes(type)); if (isInsertOperation && !isInsertAllowedForSchema(schema)) { log('error', `INSERT operations are not allowed for schema '${schema || 'default'}'. Configure SCHEMA_INSERT_PERMISSIONS.`); return { content: [ { type: 'text', text: `Error: INSERT operations are not allowed for schema '${schema || 'default'}'. Ask the administrator to update SCHEMA_INSERT_PERMISSIONS.`, }, ], isError: true, }; } if (isUpdateOperation && !isUpdateAllowedForSchema(schema)) { log('error', `UPDATE operations are not allowed for schema '${schema || 'default'}'. Configure SCHEMA_UPDATE_PERMISSIONS.`); return { content: [ { type: 'text', text: `Error: UPDATE operations are not allowed for schema '${schema || 'default'}'. Ask the administrator to update SCHEMA_UPDATE_PERMISSIONS.`, }, ], isError: true, }; } if (isDeleteOperation && !isDeleteAllowedForSchema(schema)) { log('error', `DELETE operations are not allowed for schema '${schema || 'default'}'. Configure SCHEMA_DELETE_PERMISSIONS.`); return { content: [ { type: 'text', text: `Error: DELETE operations are not allowed for schema '${schema || 'default'}'. Ask the administrator to update SCHEMA_DELETE_PERMISSIONS.`, }, ], isError: true, }; } if (isDDLOperation && !isDDLAllowedForSchema(schema)) { log('error', `DDL operations are not allowed for schema '${schema || 'default'}'. Configure SCHEMA_DDL_PERMISSIONS.`); return { content: [ { type: 'text', text: `Error: DDL operations are not allowed for schema '${schema || 'default'}'. Ask the administrator to update SCHEMA_DDL_PERMISSIONS.`, }, ], isError: true, }; } if ((isInsertOperation && isInsertAllowedForSchema(schema)) || (isUpdateOperation && isUpdateAllowedForSchema(schema)) || (isDeleteOperation && isDeleteAllowedForSchema(schema)) || (isDDLOperation && isDDLAllowedForSchema(schema))) { return executeWriteQuery(sql); } const pool = await getPool(); connection = await pool.getConnection(); log('error', 'Read-only connection acquired'); await connection.query('SET SESSION TRANSACTION READ ONLY'); await connection.beginTransaction(); try { const result = await connection.query(sql.toLocaleLowerCase()); const rows = Array.isArray(result) ? result[0] : result; await connection.rollback(); await connection.query('SET SESSION TRANSACTION READ WRITE'); return { content: [ { type: 'text', text: JSON.stringify(rows, null, 2), }, ], isError: false, }; } catch (error) { log('error', 'Error executing read-only query:', error); await connection.rollback(); throw error; } } catch (error) { log('error', 'Error in read-only query transaction:', error); try { if (connection) { await connection.rollback(); await connection.query('SET SESSION TRANSACTION READ WRITE'); } } catch (cleanupError) { log('error', 'Error during cleanup:', cleanupError); } throw error; } finally { if (connection) { connection.release(); log('error', 'Read-only connection released'); } } } async function executeWriteQuery(sql) { let connection; try { const pool = await getPool(); connection = await pool.getConnection(); log('error', 'Write connection acquired'); const schema = extractSchemaFromQuery(sql); await connection.beginTransaction(); try { const result = await connection.query(sql.toLocaleLowerCase()); const response = Array.isArray(result) ? result[0] : result; await connection.commit(); let responseText; const queryTypes = await getQueryTypes(sql); const isUpdateOperation = queryTypes.some(type => ['update'].includes(type)); const isInsertOperation = queryTypes.some(type => ['insert'].includes(type)); const isDeleteOperation = queryTypes.some(type => ['delete'].includes(type)); const isDDLOperation = queryTypes.some(type => ['create', 'alter', 'drop', 'truncate'].includes(type)); if (isInsertOperation) { const resultHeader = response; responseText = `Insert successful on schema '${schema || 'default'}'. Affected rows: ${resultHeader.affectedRows}, Last insert ID: ${resultHeader.insertId}`; } else if (isUpdateOperation) { const resultHeader = response; responseText = `Update successful on schema '${schema || 'default'}'. Affected rows: ${resultHeader.affectedRows}, Changed rows: ${resultHeader.changedRows || 0}`; } else if (isDeleteOperation) { const resultHeader = response; responseText = `Delete successful on schema '${schema || 'default'}'. Affected rows: ${resultHeader.affectedRows}`; } else if (isDDLOperation) { responseText = `DDL operation successful on schema '${schema || 'default'}'.`; } else { responseText = JSON.stringify(response, null, 2); } return { content: [ { type: 'text', text: responseText, }, ], isError: false, }; } catch (error) { log('error', 'Error executing write query:', error); await connection.rollback(); return { content: [ { type: 'text', text: `Error executing write operation: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } } catch (error) { log('error', 'Error in write operation transaction:', error); return { content: [ { type: 'text', text: `Database connection error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } finally { if (connection) { connection.release(); log('error', 'Write connection released'); } } } export { executeQuery, executeReadOnlyQuery, executeWriteQuery, getServer }; async function runServer() { try { log('error', 'Attempting to test database connection...'); const pool = await getPool(); const connection = await pool.getConnection(); log('error', 'Database connection test successful'); connection.release(); const server = await getServer(); const transport = new StdioServerTransport(); log('error', 'Connecting server to transport...'); await server.connect(transport); log('error', 'Server connected to transport successfully'); } catch (error) { log('error', 'Fatal error during server startup:', error); safeExit(1); } } const shutdown = async (signal) => { log('error', `Received ${signal}. Shutting down...`); try { if (poolPromise) { const pool = await poolPromise; await pool.end(); log('error', 'MySQL pool closed successfully'); } } catch (err) { log('error', 'Error closing pool:', err); throw err; } }; process.on('SIGINT', async () => { try { await shutdown('SIGINT'); process.exit(0); } catch (err) { log('error', 'Error during SIGINT shutdown:', err); safeExit(1); } }); process.on('SIGTERM', async () => { try { await shutdown('SIGTERM'); process.exit(0); } catch (err) { log('error', 'Error during SIGTERM shutdown:', err); safeExit(1); } }); process.on('uncaughtException', (error) => { log('error', 'Uncaught exception:', error); safeExit(1); }); process.on('unhandledRejection', (reason, promise) => { log('error', 'Unhandled rejection at:', promise, 'reason:', reason); safeExit(1); }); runServer().catch((error) => { log('error', 'Server error:', error); safeExit(1); });