diff --git a/README.md b/README.md index 4a8c0b2..fdc9297 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ Create or edit your MCP configuration file (e.g., `.cursor/mcp.json` for Cursor "DEBUG": "false", "MCP_MYSQL_SSL": "true", - "MCP_MYSQL_REJECT_UNAUTHORIZED": "false" + "MCP_MYSQL_REJECT_UNAUTHORIZED": "false", + "MYSQL_TIMEZONE": "Z" } } } @@ -129,6 +130,7 @@ Choose the approach that best fits your workflow. Both methods will work correct - At least one environment (typically "local") must be configured - You only need to configure the environments you plan to use - For security reasons, consider using environment variables or secure credential storage for production credentials +- DATETIME, DATE, and TIMESTAMP columns are returned as strings to preserve the exact value stored in MySQL without host timezone shifting ## Configuration Options @@ -143,6 +145,15 @@ Choose the approach that best fits your workflow. Both methods will work correct | [ENV]_DB_SSL | Enable SSL connection | false | | MCP_MYSQL_SSL | Enable SSL for all connections | false | | MCP_MYSQL_REJECT_UNAUTHORIZED | Verify SSL certificates | true | +| MYSQL_TIMEZONE | Timezone mysql2 uses when sending JavaScript Date values in queries | Z | + +### Date and Time Values + +MySQL DATETIME, DATE, and TIMESTAMP columns are returned as raw strings, for example `2026-05-13 16:12:08`. This preserves the exact stored value and prevents the Node host timezone from shifting results during JSON serialization. + +`MYSQL_TIMEZONE` defaults to `Z` (UTC). Override it only if your application intentionally sends JavaScript `Date` objects to MySQL using a different connection timezone. + +Callers that already handle date/time values as strings do not need to change. Callers that previously expected JavaScript `Date` objects from this MCP server should parse the returned string explicitly. ## Integration with AI Assistants @@ -316,4 +327,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- -For more information or support, please [open an issue](https://github.com/devakone/mysql-query-mcp-server/issues) on the GitHub repository. \ No newline at end of file +For more information or support, please [open an issue](https://github.com/devakone/mysql-query-mcp-server/issues) on the GitHub repository. diff --git a/src/db/options.ts b/src/db/options.ts new file mode 100644 index 0000000..454604a --- /dev/null +++ b/src/db/options.ts @@ -0,0 +1,23 @@ +import type { PoolOptions } from "mysql2/promise"; + +export const DEFAULT_MYSQL_TIMEZONE = "Z"; + +export function getMysqlTimezone(): string { + return process.env.MYSQL_TIMEZONE || DEFAULT_MYSQL_TIMEZONE; +} + +export function buildPoolOptions(envPrefix: string): PoolOptions { + return { + host: process.env[`${envPrefix}_DB_HOST`], + user: process.env[`${envPrefix}_DB_USER`], + password: process.env[`${envPrefix}_DB_PASS`], + database: process.env[`${envPrefix}_DB_NAME`], + port: process.env[`${envPrefix}_DB_PORT`] ? Number(process.env[`${envPrefix}_DB_PORT`]) : undefined, + ssl: process.env.MCP_MYSQL_SSL === "true" ? {} : undefined, + connectionLimit: 5, + enableKeepAlive: true, + keepAliveInitialDelay: 10000, + dateStrings: true, + timezone: getMysqlTimezone(), + }; +} diff --git a/src/db/pools.ts b/src/db/pools.ts index 1dd8db1..2d29b7e 100644 --- a/src/db/pools.ts +++ b/src/db/pools.ts @@ -1,5 +1,6 @@ -import { createPool, Pool, PoolOptions } from "mysql2/promise"; +import { createPool, Pool } from "mysql2/promise"; import { Environment } from "../types/index.js"; +import { buildPoolOptions } from "./options.js"; function debug(message: string, ...args: any[]) { process.stderr.write(`DEBUG [Pools]: ${message} ${args.map(arg => JSON.stringify(arg)).join(' ')}\n`); @@ -34,16 +35,7 @@ Object.values(Environment.enum).forEach((env) => { debug(`PASS: ${process.env[`${envPrefix}_DB_PASS`] ? 'set' : 'not set'}`); debug(`SSL: ${process.env.MCP_MYSQL_SSL}`); - const config: PoolOptions = { - host: process.env[`${envPrefix}_DB_HOST`], - user: process.env[`${envPrefix}_DB_USER`], - password: process.env[`${envPrefix}_DB_PASS`], - database: process.env[`${envPrefix}_DB_NAME`], - ssl: process.env.MCP_MYSQL_SSL === "true" ? {} : undefined, - connectionLimit: 5, - enableKeepAlive: true, - keepAliveInitialDelay: 10000, - }; + const config = buildPoolOptions(envPrefix); if (config.host && config.user && config.password && config.database) { debug(`Creating pool for ${env} with config:`, { @@ -51,6 +43,8 @@ Object.values(Environment.enum).forEach((env) => { user: config.user, database: config.database, ssl: config.ssl, + dateStrings: config.dateStrings, + timezone: config.timezone, hasPassword: !!config.password }); diff --git a/tests/db/pools.test.ts b/tests/db/pools.test.ts new file mode 100644 index 0000000..2b671cd --- /dev/null +++ b/tests/db/pools.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { buildPoolOptions, DEFAULT_MYSQL_TIMEZONE } from '../../src/db/options.js'; + +describe('database pool options', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('returns date/time values as raw strings and defaults connection timezone to UTC', () => { + process.env.LOCAL_DB_HOST = 'localhost'; + process.env.LOCAL_DB_USER = 'root'; + process.env.LOCAL_DB_PASS = 'password'; + process.env.LOCAL_DB_NAME = 'test'; + + const options = buildPoolOptions('LOCAL'); + + expect(options.dateStrings).toBe(true); + expect(options.timezone).toBe(DEFAULT_MYSQL_TIMEZONE); + }); + + it('allows the mysql timezone to be overridden', () => { + process.env.MYSQL_TIMEZONE = '+00:00'; + + const options = buildPoolOptions('LOCAL'); + + expect(options.timezone).toBe('+00:00'); + }); +}); diff --git a/tests/integration/datetime-strings.test.ts b/tests/integration/datetime-strings.test.ts new file mode 100644 index 0000000..18fb357 --- /dev/null +++ b/tests/integration/datetime-strings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { createConnection, type ConnectionOptions, type RowDataPacket } from 'mysql2/promise'; +import { DEFAULT_MYSQL_TIMEZONE } from '../../src/db/options.js'; + +const runIntegrationTest = process.env.MYSQL_DATETIME_INTEGRATION === 'true'; +const maybeIt = runIntegrationTest ? it : it.skip; + +describe('datetime string integration', () => { + maybeIt('round-trips a known UTC DATETIME as the stored wall-clock string', async () => { + const options: ConnectionOptions = { + host: process.env.MYSQL_DATETIME_TEST_HOST || process.env.LOCAL_DB_HOST, + user: process.env.MYSQL_DATETIME_TEST_USER || process.env.LOCAL_DB_USER, + password: process.env.MYSQL_DATETIME_TEST_PASS || process.env.LOCAL_DB_PASS, + database: process.env.MYSQL_DATETIME_TEST_DB || process.env.LOCAL_DB_NAME, + port: Number(process.env.MYSQL_DATETIME_TEST_PORT || process.env.LOCAL_DB_PORT || 3306), + dateStrings: true, + timezone: process.env.MYSQL_TIMEZONE || DEFAULT_MYSQL_TIMEZONE, + }; + const insertedDatetime = '2026-05-13 16:12:08'; + const connection = await createConnection(options); + + try { + await connection.query('CREATE TEMPORARY TABLE datetime_string_test (created_at DATETIME NOT NULL)'); + await connection.execute('INSERT INTO datetime_string_test (created_at) VALUES (?)', [insertedDatetime]); + + const [rows] = await connection.query('SELECT created_at FROM datetime_string_test'); + + expect(typeof rows[0].created_at).toBe('string'); + expect(rows[0].created_at).toBe(insertedDatetime); + } finally { + await connection.end(); + } + }); +});