@benborla29/mcp-server-mysql
Version:
MCP server for interacting with MySQL databases based on Node
606 lines (605 loc) • 24.3 kB
JavaScript
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);
});