UNPKG

@benborla29/mcp-server-mysql

Version:

MCP server for interacting with MySQL databases based on Node

374 lines (373 loc) 14.6 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { log } from "./src/utils/index.js"; import { ALLOW_DELETE_OPERATION, ALLOW_DDL_OPERATION, ALLOW_INSERT_OPERATION, ALLOW_UPDATE_OPERATION, SCHEMA_DELETE_PERMISSIONS, SCHEMA_DDL_PERMISSIONS, SCHEMA_INSERT_PERMISSIONS, SCHEMA_UPDATE_PERMISSIONS, isMultiDbMode, mcpConfig as config, MCP_VERSION as version, IS_REMOTE_MCP, REMOTE_SECRET_KEY, PORT, } from "./src/config/index.js"; import { safeExit, getPool, executeQuery, executeReadOnlyQuery, poolPromise, } from "./src/db/index.js"; import express from "express"; log("info", `Starting MySQL MCP server v${version}...`); const toolVersion = `MySQL MCP Server [v${process.env.npm_package_version}]`; let toolDescription = `[${toolVersion}] 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)"; } log("info", "MySQL Configuration:", JSON.stringify({ ...(process.env.MYSQL_SOCKET_PATH ? { socketPath: process.env.MYSQL_SOCKET_PATH, connectionType: "Unix Socket", } : { host: process.env.MYSQL_HOST || "127.0.0.1", port: process.env.MYSQL_PORT || "3306", connectionType: "TCP/IP", }), 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)); export const configSchema = z.object({ debug: z.boolean().default(false).describe("Enable debug logging"), }); export default function createMcpServer({ sessionId, config, }) { const server = new Server({ name: "MySQL MCP Server", version: process.env.npm_package_version || "1.0.0", }, { 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("info", "Handling ListResourcesRequest"); const connectionInfo = process.env.MYSQL_SOCKET_PATH ? `socket: ${process.env.MYSQL_SOCKET_PATH}` : `host: ${process.env.MYSQL_HOST || "localhost"}, port: ${process.env.MYSQL_PORT || 3306}`; log("info", `Connection info: ${connectionInfo}`); const tablesQuery = ` SELECT table_name as name, table_schema as \`database\`, table_comment as description, table_rows as rowCount, data_length as dataSize, index_length as indexSize, create_time as createTime, update_time as updateTime FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY table_schema, table_name `; const queryResult = (await executeReadOnlyQuery(tablesQuery)); const tables = JSON.parse(queryResult.content[0].text); log("info", `Found ${tables.length} tables`); const resources = tables.map((table) => ({ uri: `mysql://tables/${table.name}`, name: table.name, title: `${table.database}.${table.name}`, description: table.description || `Table ${table.name} in database ${table.database}`, mimeType: "application/json", })); resources.push({ uri: "mysql://tables", name: "Tables", title: "MySQL Tables", description: "List of all MySQL tables", mimeType: "application/json", }); return { resources }; } catch (error) { log("error", "Error in ListResourcesRequest handler:", error); throw error; } }); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { log("info", "Handling ReadResourceRequest:", request.params.uri); const uriParts = request.params.uri.split("/"); const tableName = uriParts.pop(); const dbName = uriParts.length > 0 ? uriParts.pop() : null; if (!tableName) { throw new Error(`Invalid resource URI: ${request.params.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(CallToolRequestSchema, async (request) => { try { log("info", "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 await executeReadOnlyQuery(sql); } catch (err) { const error = err; log("error", "Error in CallToolRequest handler:", error); return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true }; } }); server.setRequestHandler(ListToolsRequestSchema, async () => { log("info", "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("info", "ListToolsRequest response:", JSON.stringify(toolsResponse, null, 2)); return toolsResponse; }); (async () => { try { log("info", "Attempting to test database connection..."); const pool = await getPool(); const connection = await pool.getConnection(); log("info", "Database connection test successful"); connection.release(); } 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(); } } 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); }); return server; } const isMainModule = () => { if (typeof import.meta !== "undefined" && import.meta.url) { try { return (import.meta.url.startsWith("file:") && process.argv[1] && import.meta.url === `file://${process.argv[1]}`); } catch (e) { return false; } } return false; }; if (isMainModule()) { log("info", "Running in standalone mode"); (async () => { try { const mcpServer = createMcpServer({ config: { debug: false } }); if (IS_REMOTE_MCP && REMOTE_SECRET_KEY?.length) { const app = express(); app.use(express.json()); app.post("/mcp", async (req, res) => { if (!req.get("Authorization") || !req.get("Authorization")?.startsWith("Bearer ") || !req.get("Authorization")?.endsWith(REMOTE_SECRET_KEY)) { console.error("Missing or invalid Authorization header"); res.status(401).json({ jsonrpc: "2.0", error: { code: -32603, message: "Missing or invalid Authorization header", }, id: null, }); return; } try { const server = mcpServer; const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, }); res.on("close", () => { log("info", "Request closed"); transport.close(); server.close(); }); await server.connect(transport); await transport.handleRequest(req, res, req.body); } catch (error) { log("error", "Error handling MCP request:", error); if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: { code: -32603, message: error.message, }, id: null, }); } } }); app.get("/mcp", async (req, res) => { console.log("Received GET MCP request"); res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed.", }, id: null, })); }); app.delete("/mcp", async (req, res) => { console.log("Received DELETE MCP request"); res.writeHead(405).end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed.", }, id: null, })); }); app.listen(PORT, (error) => { if (error) { console.error("Failed to start server:", error); process.exit(1); } console.log(`MCP Stateless Streamable HTTP Server listening on port ${PORT}`); }); } else { const transport = new StdioServerTransport(); await mcpServer.connect(transport); log("info", "Server started and listening on stdio"); } } catch (error) { log("error", "Server error:", error); safeExit(1); } })(); }