Guide · MCP Database Integration

MCP Server MySQL — mysql2 driver, connection pooling, and prepared statements

MySQL is widely used in web application backends that MCP servers extend with tool access. This guide covers connecting to MySQL from a TypeScript MCP server using mysql2's promise API, managing a connection pool, writing SQL injection-safe parameterized queries, handling transactions, introspecting schemas as MCP resources, and instrumenting a /health/mysql endpoint for AliveMCP to monitor.

TL;DR

Use mysql2/promise with createPool() — not individual connections. Pass all parameters as the second argument to pool.query(sql, params) using ? placeholders — never interpolate into the SQL string. For transactions, call pool.getConnection(), use connection.beginTransaction()/connection.commit()/connection.rollback(), and always call connection.release() in finally. Wire a /health/mysql endpoint that pings the pool and reports active/idle/queued connection counts to AliveMCP.

Driver setup: mysql2 promise API with connection pooling

The mysql2 package is the modern replacement for the original mysql package — it offers prepared statement support, a promise-based API, and better performance. Always import from mysql2/promise (not mysql2 directly) to get the async/await interface.

import mysql from 'mysql2/promise';

// Singleton pool — initialized once at startup
const pool = mysql.createPool({
  host: process.env.MYSQL_HOST ?? 'localhost',
  port: parseInt(process.env.MYSQL_PORT ?? '3306'),
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASSWORD,
  database: process.env.MYSQL_DATABASE,

  // SSL required for PlanetScale, RDS, Cloud SQL
  ssl: process.env.MYSQL_SSL === 'true'
    ? { rejectUnauthorized: true }
    : undefined,

  // Pool sizing
  connectionLimit: 10,    // max concurrent connections
  queueLimit: 50,         // max waiting requests (0 = unlimited — avoid this)
  waitForConnections: true,

  // Type coercions
  typeCast: true,         // auto-convert DATE/DATETIME to JS Date objects
  timezone: '+00:00',     // store/retrieve timestamps as UTC

  // Connection health
  enableKeepAlive: true,
  keepAliveInitialDelay: 10000
});

export { pool };

One MySQL-specific gotcha: timezone: '+00:00' is critical for correct DATETIME handling. MySQL DATETIME columns store literal time strings without timezone info. If your Node.js process runs in a different timezone than the MySQL server, Date objects will be serialized and deserialized with the wrong offset unless both sides agree on UTC. Set +00:00 on the connection and store all timestamps in UTC.

Setting Default Recommended for MCP servers
connectionLimit 10 10–20; tune to your DB instance's max_connections
queueLimit 0 (unlimited) 50–100; prevents unbounded queuing under load
waitForConnections true true; false causes immediate error when pool is full
timezone local '+00:00'; always use UTC
typeCast true true; converts MySQL types to JS types automatically

Parameterized queries: ? placeholder syntax

MySQL uses ? as the parameter placeholder (unlike PostgreSQL's $1, $2 syntax). Pass an array of values as the second argument to pool.query(). The driver replaces each ? with the corresponding array element, properly escaped at the driver level — not by string concatenation.

import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { pool } from './db.js';
import type { RowDataPacket, ResultSetHeader } from 'mysql2';

// ---- query_orders ----
server.tool(
  'query_orders',
  {
    customer_id: z.string().optional(),
    status: z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']).optional(),
    min_amount: z.number().positive().optional(),
    limit: z.number().int().min(1).max(100).default(20),
    offset: z.number().int().min(0).default(0)
  },
  async ({ customer_id, status, min_amount, limit, offset }) => {
    const conditions: string[] = [];
    const params: unknown[] = [];

    if (customer_id) {
      conditions.push('customer_id = ?');
      params.push(customer_id);
    }
    if (status) {
      conditions.push('status = ?');
      params.push(status);
    }
    if (min_amount !== undefined) {
      conditions.push('total_amount >= ?');
      params.push(min_amount);
    }

    const where = conditions.length > 0
      ? `WHERE ${conditions.join(' AND ')}`
      : '';

    // LIMIT and OFFSET via params — prevents injection even for numeric values
    const sql = `
      SELECT id, customer_id, status, total_amount, created_at
      FROM orders
      ${where}
      ORDER BY created_at DESC
      LIMIT ? OFFSET ?
    `;
    params.push(limit, offset);

    const [rows] = await pool.query<RowDataPacket[]>(sql, params);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ rows, count: rows.length }, null, 2)
      }]
    };
  }
);

// ---- create_order ----
server.tool(
  'create_order',
  {
    customer_id: z.string().uuid(),
    items: z.array(z.object({
      product_id: z.string(),
      quantity: z.number().int().positive(),
      unit_price: z.number().positive()
    })).min(1),
    notes: z.string().max(500).optional()
  },
  async ({ customer_id, items, notes }) => {
    const totalAmount = items.reduce(
      (sum, item) => sum + item.quantity * item.unit_price,
      0
    );

    const [result] = await pool.query<ResultSetHeader>(
      `INSERT INTO orders (customer_id, status, total_amount, notes, created_at)
       VALUES (?, 'pending', ?, ?, NOW())`,
      [customer_id, totalAmount, notes ?? null]
    );

    const orderId = result.insertId;
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ order_id: orderId, total_amount: totalAmount })
      }]
    };
  }
);

The generic type parameter to pool.query<RowDataPacket[]> makes TypeScript understand the destructured return type. pool.query() returns [rows, fields] — destructure to [rows] and ignore fields unless you need column metadata. For INSERT/UPDATE/DELETE, use ResultSetHeader to get insertId, affectedRows, and changedRows.

Transactions with getConnection()

Multi-statement transactions require a pinned connection. Use pool.getConnection() to check out a dedicated connection, then manually manage the transaction lifecycle. The try/catch/finally pattern is critical — failing to release the connection leaks it from the pool permanently.

server.tool(
  'process_payment',
  {
    order_id: z.number().int().positive(),
    payment_method: z.enum(['card', 'bank_transfer', 'wallet']),
    amount: z.number().positive()
  },
  async ({ order_id, payment_method, amount }) => {
    const connection = await pool.getConnection();
    try {
      await connection.beginTransaction();

      // Lock the order row during processing
      const [orderRows] = await connection.query<RowDataPacket[]>(
        'SELECT id, status, total_amount FROM orders WHERE id = ? FOR UPDATE',
        [order_id]
      );

      if (orderRows.length === 0) {
        throw new McpError(ErrorCode.InvalidParams, `Order ${order_id} not found`);
      }
      const order = orderRows[0];

      if (order.status !== 'pending') {
        throw new McpError(
          ErrorCode.InvalidParams,
          `Order ${order_id} is ${order.status}, not pending`
        );
      }

      if (Math.abs(amount - order.total_amount) > 0.01) {
        throw new McpError(
          ErrorCode.InvalidParams,
          `Payment amount ${amount} does not match order total ${order.total_amount}`
        );
      }

      // Record payment
      const [paymentResult] = await connection.query<ResultSetHeader>(
        `INSERT INTO payments (order_id, payment_method, amount, status, created_at)
         VALUES (?, ?, ?, 'captured', NOW())`,
        [order_id, payment_method, amount]
      );

      // Update order status
      await connection.query(
        'UPDATE orders SET status = ?, updated_at = NOW() WHERE id = ?',
        ['processing', order_id]
      );

      await connection.commit();

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            payment_id: paymentResult.insertId,
            order_status: 'processing'
          })
        }]
      };
    } catch (err) {
      await connection.rollback();
      throw err;
    } finally {
      connection.release();  // must always run — even on error
    }
  }
);

The FOR UPDATE row lock on the order prevents two simultaneous payment attempts for the same order from both succeeding. Without it, a duplicate payment race condition could process two payments before either commits.

Schema introspection as MCP resources

MySQL's information_schema exposes table and column definitions. Use it to let calling agents discover available tables before constructing queries.

import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema
} from '@modelcontextprotocol/sdk/types.js';

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  const [tables] = await pool.query<RowDataPacket[]>(
    `SELECT
       TABLE_NAME,
       TABLE_TYPE,
       TABLE_ROWS,
       ENGINE,
       CREATE_TIME
     FROM information_schema.TABLES
     WHERE TABLE_SCHEMA = DATABASE()
       AND TABLE_TYPE IN ('BASE TABLE', 'VIEW')
     ORDER BY TABLE_NAME`
  );

  return {
    resources: tables.map((t) => ({
      uri: `mysql://${t.TABLE_SCHEMA}/${t.TABLE_NAME}`,
      name: t.TABLE_NAME as string,
      description: `${t.TABLE_TYPE} (${t.ENGINE}) — ~${Number(t.TABLE_ROWS ?? 0).toLocaleString()} rows`,
      mimeType: 'application/json'
    }))
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async ({ params }) => {
  const match = params.uri.match(/^mysql:\/\/[^/]+\/([a-zA-Z_][a-zA-Z0-9_]*)$/);
  if (!match) {
    throw new McpError(ErrorCode.InvalidParams, `Unrecognised URI: ${params.uri}`);
  }
  const [, tableName] = match;

  const [columns] = await pool.query<RowDataPacket[]>(
    `SELECT
       COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT,
       CHARACTER_MAXIMUM_LENGTH, COLUMN_KEY, EXTRA
     FROM information_schema.COLUMNS
     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
     ORDER BY ORDINAL_POSITION`,
    [tableName]
  );

  if (columns.length === 0) {
    throw new McpError(ErrorCode.InvalidParams, `Table ${tableName} not found`);
  }

  const [indexes] = await pool.query<RowDataPacket[]>(
    `SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX
     FROM information_schema.STATISTICS
     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?
     ORDER BY INDEX_NAME, SEQ_IN_INDEX`,
    [tableName]
  );

  return {
    contents: [{
      uri: params.uri,
      mimeType: 'application/json',
      text: JSON.stringify({ table: tableName, columns, indexes }, null, 2)
    }]
  };
});

Note that TABLE_ROWS in information_schema.TABLES is an estimate for InnoDB tables — it can be off by 40–50% for large tables. InnoDB maintains this statistic via sampling, not an exact count. For exact counts on specific tables, run SELECT COUNT(*).

Health endpoint: /health/mysql

The MySQL pool exposes connection counts through the pool object itself. Wire a health endpoint that checks query latency and pool stats.

import express from 'express';

const app = express();

app.get('/health/mysql', async (_req, res) => {
  const start = Date.now();
  try {
    await pool.query('SELECT 1');
    const queryMs = Date.now() - start;

    // mysql2 pool exposes these via the internal pool object
    // Access via the underlying pool reference
    const internalPool = (pool as unknown as {
      pool: {
        _allConnections: { length: number };
        _freeConnections: { length: number };
        _connectionQueue: { length: number };
        config: { connectionLimit: number };
      }
    }).pool;

    const stats = {
      total: internalPool._allConnections.length,
      idle: internalPool._freeConnections.length,
      queued: internalPool._connectionQueue.length,
      limit: internalPool.config.connectionLimit
    };

    const healthy = stats.queued === 0;

    res.status(healthy ? 200 : 503).json({
      status: healthy ? 'ok' : 'degraded',
      query_ms: queryMs,
      pool: stats
    });
  } catch (err) {
    res.status(503).json({
      status: 'error',
      error: (err as Error).message,
      elapsed_ms: Date.now() - start
    });
  }
});

app.listen(3001);

Wire AliveMCP to this endpoint. A 503 response means the database is unreachable. A 200 response with pool.queued > 0 means all connections are occupied and requests are waiting — a precursor to timeouts if load continues.

MySQL error codes in MCP tool handlers

MySQL errors have numeric codes. Map the most common ones to McpError so callers receive actionable messages.

function mapMysqlError(err: unknown): never {
  const mysqlErr = err as { errno?: number; sqlMessage?: string; sqlState?: string };

  switch (mysqlErr.errno) {
    case 1062: // ER_DUP_ENTRY — unique constraint violation
      throw new McpError(
        ErrorCode.InvalidParams,
        `Duplicate entry: ${mysqlErr.sqlMessage}`
      );
    case 1452: // ER_NO_REFERENCED_ROW_2 — foreign key violation (insert/update)
      throw new McpError(
        ErrorCode.InvalidParams,
        `Foreign key violation: referenced row does not exist`
      );
    case 1451: // ER_ROW_IS_REFERENCED_2 — foreign key violation (delete)
      throw new McpError(
        ErrorCode.InvalidParams,
        `Cannot delete: row is referenced by another table`
      );
    case 1146: // ER_NO_SUCH_TABLE
      throw new McpError(
        ErrorCode.InvalidParams,
        `Table does not exist`
      );
    case 1054: // ER_BAD_FIELD_ERROR — unknown column
      throw new McpError(
        ErrorCode.InvalidParams,
        `Unknown column: ${mysqlErr.sqlMessage}`
      );
    case 1205: // ER_LOCK_WAIT_TIMEOUT
      throw new McpError(
        ErrorCode.InternalError,
        'Lock wait timeout — another transaction is holding a row lock'
      );
    case 1213: // ER_LOCK_DEADLOCK
      throw new McpError(
        ErrorCode.InternalError,
        'Deadlock detected — please retry the operation'
      );
    default:
      throw err;
  }
}

Errors 1205 (lock wait timeout) and 1213 (deadlock) are transient and should be retried with exponential backoff. Return them as ErrorCode.InternalError with a "please retry" message rather than InvalidParams, since the issue is contention, not a caller error.

Frequently asked questions

Should I use mysql vs mysql2?

Use mysql2. The original mysql package lacks promise support and has slower performance. mysql2 is backward-compatible with mysql for most use cases, has an active maintenance team, and includes true prepared statement support (not just client-side escaping). mysql2's mysql2/promise export gives you a proper async/await API. There's no reason to use the original mysql package for new MCP server projects.

MySQL vs PostgreSQL for a new MCP server — which should I choose?

If you're building a new backend from scratch, PostgreSQL offers better JSON support (jsonb with indexing), full-text search, better window functions, and stricter type handling. MySQL is better if you're extending an existing MySQL application with MCP tool access, need PlanetScale's branching model, or are running on a provider that only offers MySQL. The MCP server patterns are nearly identical — the main differences are ? vs $1 placeholders and the pool API shape.

How do I connect to PlanetScale from an MCP server?

PlanetScale now uses a standard MySQL protocol endpoint. Use mysql2/promise with the connection string PlanetScale provides, and set ssl: { rejectUnauthorized: true }. PlanetScale disables foreign key enforcement by default — don't rely on MySQL's 1452/1451 foreign key errors for data integrity on PlanetScale. Use Zod validation at the tool parameter boundary and application-level checks instead. PlanetScale also supports Drizzle ORM natively — see the MCP Server Drizzle ORM guide for typed query building.

How do I prevent SQL injection when sorting by a column name?

Column names cannot be parameterized with ? — only values can. If a tool parameter specifies a sort column (e.g., sort_by: 'created_at'), validate it against an explicit allow-list before interpolating: const ALLOWED_COLUMNS = ['created_at', 'total_amount', 'status'] as const; if (!ALLOWED_COLUMNS.includes(sortBy)) throw new McpError(ErrorCode.InvalidParams, ...). The Zod schema approach is cleaner: sort_by: z.enum(['created_at', 'total_amount', 'status']) — Zod's type system guarantees only allowed values reach the SQL string, and the TypeScript type is automatically the union of allowed column names.

Further reading

Know when your MySQL connection fails

AliveMCP monitors your /health/mysql endpoint and alerts you the moment pool exhaustion or a database failure affects tool availability.

Start monitoring free