UNPKG

@kevinwatt/mysql-mcp

Version:

MCP server implementation for MySQL database access

420 lines (419 loc) 13.6 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 mysql from "mysql"; var SQLOperationType; (function (SQLOperationType) { SQLOperationType["SELECT"] = "SELECT"; SQLOperationType["INSERT"] = "INSERT"; SQLOperationType["UPDATE"] = "UPDATE"; SQLOperationType["DELETE"] = "DELETE"; })(SQLOperationType || (SQLOperationType = {})); const serverConfig = { server: { name: "mysql-mcp", version: "0.1.3", }, 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 || "", database: process.env.MYSQL_DB || "", connectionLimit: 10, }, paths: { schema: "schema", }, limits: { queryTimeout: 30000, maxRows: 1000, maxQueryLength: 4096 } }; const COLUMN_METADATA_QUERY = "SELECT column_name, data_type FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name = ?"; const executeQueryWithConnection = (connection, sql, params = []) => { return new Promise((resolve, reject) => { connection.query(sql, params, (error, results) => { if (error) reject(error); else resolve(results); }); }); }; const getConnectionFromPool = (pool) => { return new Promise((resolve, reject) => { pool.getConnection((error, connection) => { if (error) reject(error); else resolve(connection); }); }); }; const startTransaction = (connection) => { return new Promise((resolve, reject) => { connection.beginTransaction((error) => { if (error) reject(error); else resolve(); }); }); }; const rollbackTransaction = (connection) => { return new Promise((resolve) => { connection.rollback(() => resolve()); }); }; const connectionPool = mysql.createPool(serverConfig.mysql); const mcpServer = new Server(serverConfig.server, { capabilities: { resources: {}, tools: {}, }, }); function checkQueryLimits(sql) { if (sql.length > serverConfig.limits.maxQueryLength) { return { safe: false, reason: `SQL query length exceeds limit (${serverConfig.limits.maxQueryLength} characters)` }; } return { safe: true }; } function checkSQLSecurity(sql) { const dangerousPatterns = [ /;\s*DROP\s+/i, /;\s*DELETE\s+FROM\s+/i, /;\s*UPDATE\s+/i, /;\s*INSERT\s+/i, /EXECUTE\s+/i, /EXEC\s+/i, /INTO\s+OUTFILE/i, /INTO\s+DUMPFILE/i ]; for (const pattern of dangerousPatterns) { if (pattern.test(sql)) { return { safe: false, reason: 'Potential SQL injection attack detected' }; } } return { safe: true }; } async function logQuery(log) { console.log(JSON.stringify({ type: 'query_log', ...log })); } async function withPerformanceMonitoring(operation, sql, params, action) { const startTime = process.hrtime(); try { const result = await action(); const [seconds, nanoseconds] = process.hrtime(startTime); const duration = seconds * 1000 + nanoseconds / 1000000; await logQuery({ timestamp: new Date(), operation, sql, params, duration, success: true, affectedRows: result?.affectedRows }); return result; } catch (error) { const [seconds, nanoseconds] = process.hrtime(startTime); const duration = seconds * 1000 + nanoseconds / 1000000; await logQuery({ timestamp: new Date(), operation, sql, params, duration, success: false, error: error instanceof Error ? error.message : '未知錯誤' }); throw error; } } async function executeModifyQuery(sql, params = []) { const connection = await getConnectionFromPool(connectionPool); const securityCheck = checkSQLSecurity(sql); if (!securityCheck.safe) { return { success: false, message: securityCheck.reason }; } const sqlType = sql.trim().split(' ')[0].toUpperCase(); try { return await withPerformanceMonitoring(sqlType, sql, params, async () => { await startTransaction(connection); const result = await executeQueryWithConnection(connection, sql, params); await connection.commit(); return { success: true, affectedRows: result.affectedRows, insertId: result.insertId, message: result.message }; }); } catch (error) { await rollbackTransaction(connection); return { success: false, message: error instanceof Error ? error.message : '未知錯誤' }; } finally { connection.release(); } } async function executeReadOnlyQuery(sql) { const connection = await getConnectionFromPool(connectionPool); const securityCheck = checkSQLSecurity(sql); if (!securityCheck.safe) { throw new Error(securityCheck.reason); } try { return await withPerformanceMonitoring(SQLOperationType.SELECT, sql, [], async () => { await executeQueryWithConnection(connection, "SET SESSION TRANSACTION READ ONLY"); await startTransaction(connection); const results = await executeQueryWithConnection(connection, sql); await rollbackTransaction(connection); await executeQueryWithConnection(connection, "SET SESSION TRANSACTION READ WRITE"); return { content: [ { type: "text", text: JSON.stringify(results, null, 2), }, ], isError: false, }; }); } catch (error) { await rollbackTransaction(connection); throw error; } finally { connection.release(); } } mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => { 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}/${serverConfig.paths.schema}`, `${serverConfig.mysql.host}:${serverConfig.mysql.port}`).href, mimeType: "application/json", name: `"${row.table_name}" database schema`, })), }; }); mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { const resourceUrl = new URL(request.params.uri); const pathComponents = resourceUrl.pathname.split("/"); const schema = pathComponents.pop(); const tableName = pathComponents.pop(); if (schema !== serverConfig.paths.schema) { throw new Error("Invalid resource URI"); } const results = (await executeQuery(COLUMN_METADATA_QUERY, [tableName])); return { contents: [ { uri: request.params.uri, mimeType: "application/json", text: JSON.stringify(results, null, 2), }, ], }; }); mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "mysql_query", description: "Execute read-only SELECT queries against the MySQL database.\n" + "- Maximum query length: 4096 characters\n" + "- Maximum result rows: 1000\n" + "- Query timeout: 30 seconds", inputSchema: { type: "object", properties: { sql: { type: "string", description: "SQL SELECT query to execute" } }, required: ["sql"] } }, { name: "mysql_execute", description: "Execute data modification queries (INSERT/UPDATE/DELETE).\n" + "- Returns affected rows count and insert ID\n" + "- Supports parameterized queries\n" + "- Automatic transaction handling", inputSchema: { type: "object", properties: { sql: { type: "string", description: "SQL statement (INSERT, UPDATE, or DELETE)" }, params: { type: "array", items: { type: "string" }, description: "Parameters for the SQL statement" } }, required: ["sql"] } }, { name: "list_tables", description: "List all tables in current database", inputSchema: { type: "object", properties: {}, required: [] } }, { name: "describe_table", description: "Show table structure", inputSchema: { type: "object", properties: { table: { type: "string", description: "Table name" } }, required: ["table"] } } ] })); mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "mysql_query": return executeReadOnlyQuery(args?.sql); case "mysql_execute": { const sql = args?.sql; const params = args?.params || []; const sqlType = sql.trim().split(' ')[0].toUpperCase(); if (sqlType === SQLOperationType.SELECT) { throw new Error("請使用 mysql_query 執行查詢操作"); } const result = await executeModifyQuery(sql, params); return { content: [ { type: "text", text: JSON.stringify(result, null, 2) } ], isError: !result.success }; } case "list_tables": { const results = await executeQuery("SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE()"); return { content: [ { type: "text", text: JSON.stringify(results, null, 2) } ], isError: false }; } case "describe_table": { const tableName = args?.table; if (!tableName) { throw new Error("Table name is required"); } const results = await executeQuery(COLUMN_METADATA_QUERY, [tableName]); return { content: [ { type: "text", text: JSON.stringify(results, null, 2) } ], isError: false }; } default: throw new Error(`未知的工具: ${name}`); } }); async function executeQuery(sql, params = []) { const connection = await getConnectionFromPool(connectionPool); const securityCheck = checkSQLSecurity(sql); if (!securityCheck.safe) { throw new Error(securityCheck.reason); } const limitsCheck = checkQueryLimits(sql); if (!limitsCheck.safe) { throw new Error(limitsCheck.reason); } try { return await withPerformanceMonitoring(SQLOperationType.SELECT, sql, params, async () => await executeQueryWithConnection(connection, sql, params)); } finally { connection.release(); } } async function runServer() { const transport = new StdioServerTransport(); await mcpServer.connect(transport); } const shutdown = async (signal) => { console.log(`Received ${signal}. Shutting down...`); return new Promise((resolve, reject) => { connectionPool.end((err) => { if (err) { console.error("Error closing pool:", err); reject(err); } else { resolve(); } }); }); }; process.on("SIGINT", async () => { try { await shutdown("SIGINT"); process.exit(0); } catch (err) { process.exit(1); } }); process.on("SIGTERM", async () => { try { await shutdown("SIGTERM"); process.exit(0); } catch (err) { process.exit(1); } }); runServer().catch((error) => { console.error("Server error:", error); process.exit(1); });