CVE-2026-11529
Description
SQL injection vulnerability in mysql-mcp-server up to 0.2.2 allows remote attackers to execute arbitrary SQL via crafted URIs.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
SQL injection vulnerability in mysql-mcp-server up to 0.2.2 allows remote attackers to execute arbitrary SQL via crafted URIs.
Vulnerability
A SQL injection vulnerability exists in the read_resource function within src/mysql_mcp_server/server.py in designcomputer mysql-mcp-server versions up to and including 0.2.2. The component handles MySQL URIs, and a manipulation of the uri_str argument, specifically the table name, allows for SQL injection due to unsanitized input being directly interpolated into SQL queries [2].
Exploitation
An attacker can exploit this vulnerability by crafting a malicious URI containing SQL injection payloads in the table name portion. This can be achieved through prompt injection against an AI client, a malicious MCP client, or man-in-the-middle attacks on the stdio transport. The injected SQL is executed with the privileges of the MySQL connection [2].
Impact
Successful exploitation allows an attacker to read arbitrary data from any table in the connected MySQL database, including sensitive information like user credentials. Attackers can also enumerate databases, tables, and columns, and potentially extract MySQL user password hashes for offline cracking [2].
Mitigation
This vulnerability is resolved in version 0.3.0, released on 2026-05-31 [3]. The fix includes strict regex validation for database and table identifiers [3, 1]. Upgrading to version 0.3.0 or later is recommended [1].
AI Insight generated on Jun 8, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1- Range: <=0.2.2
Patches
2080bef9a96d6feat: 添加多个新文件并改进功能
8 files changed · +370 −91
CHANGELOG.md+41 −0 modified@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-17 + +### Fixed +- Fixed `SHOW DATABASES` and all `SHOW` commands returning `Error calling tool execute_sql: {}` (issue #48) — removed special-case handling for `SHOW TABLES`; all result-set queries now use unified CSV output +- Fixed error messages being empty when `str(e)` was blank — now uses `e.msg` attribute from MySQL connector +- Fixed NULL values being rendered as the string `"None"` in query results — now rendered as empty strings + +### Added +- `MYSQL_DATABASE` is now optional (issue #68, #81) — when omitted, the server operates in multi-database mode: `list_resources` returns all user databases, and you can switch with `USE <database>` +- SSE/HTTP transport support (issue #60) — set `MCP_TRANSPORT=sse` to run as an HTTP server; configurable via `MCP_SSE_HOST` (default `127.0.0.1`) and `MCP_SSE_PORT` (default `8000`); requires `pip install mysql_mcp_server[sse]` +- `MYSQL_CONNECT_TIMEOUT` environment variable (default `10` seconds) for controlling connection timeout +- `validate_identifier()` utility function for strict MySQL identifier validation (`^[a-zA-Z0-9_$]+$`) +- `read_resource` now supports `mysql://database/<name>` URIs to list tables within a specific database + +### Security +- Replaced ad-hoc table name validation with `validate_identifier()` using a strict regex whitelist +- Upgraded `black` dev dependency to `>=24.0.0` to fix ReDoS vulnerability (CVE-2024-21503) +- Synchronized `mcp>=1.2.0` in `requirements.txt` to match `pyproject.toml` + +### Changed +- `get_db_config()` now only requires `MYSQL_USER` and `MYSQL_PASSWORD`; `MYSQL_DATABASE` is optional +- `main()` refactored into `_run_stdio_server()` and `_run_sse_server()` for clarity +- `list_resources()` returns databases (filtered by system databases) when no default database is configured + +## [0.2.3] - 2026-03-17 + +### Fixed +- Fixed empty password validation: `MYSQL_PASSWORD` can now be an empty string for passwordless MySQL (issue #43) +- Added `list_resource_templates` handler to prevent errors in Visual Studio Code (issue #77) +- Fixed SQL injection risk in `read_resource` by validating table names and using backtick-quoted identifiers (issue #84) +- Fixed README VSCode `mcp.json` example — missing closing brace (issue #42) + +### Added +- SSL/TLS support via `MYSQL_SSL_MODE` env var (`DISABLED`, `REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY`) (issue #71) +- Optional `MYSQL_SSL_CA` env var for specifying CA certificate path + +### Security +- Dockerfile no longer exposes `MYSQL_PASSWORD` via `ENV`; secrets should be passed at `docker run` time +- Dockerfile now runs as a non-root user (`appuser`) +- Upgraded minimum `mcp` dependency to `>=1.2.0` to resolve known DoS and DNS-rebinding vulnerabilities + ## [0.2.2] - 2025-04-18 ### Fixed
Dockerfile+10 −2 modified@@ -14,7 +14,15 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the current directory contents into the container at /app COPY src/ /app/src -# Set environment variables for MySQL (these can be overwritten with `docker run -e`) +ENV PYTHONPATH=/app/src + +# Create a non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# NOTE: Do NOT set MYSQL_PASSWORD or other secrets as ENV here. +# Pass secrets at runtime via `docker run -e MYSQL_PASSWORD=...` +# or use Docker secrets / environment files. ENV MYSQL_HOST=host.docker.internal ENV MYSQL_PORT=3306 ENV MYSQL_USER=your_username @@ -23,4 +31,4 @@ ENV MYSQL_DATABASE=your_database ENV PYTHONPATH=/app/src # Command to run the server -CMD ["python", "-m", "mysql_mcp_server.server"] \ No newline at end of file +CMD ["python", "-m", "mysql_mcp_server.server"]
pyproject.toml+9 −2 modified@@ -1,13 +1,20 @@ [project] name = "mysql_mcp_server" -version = "0.2.2" +version = "0.3.0" description = "A Model Context Protocol (MCP) server that enables secure interaction with MySQL databases. This server allows AI assistants to list tables, read data, and execute SQL queries through a controlled interface, making database exploration and analysis safer and more structured." readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp>=1.0.0", + "mcp>=1.2.0", "mysql-connector-python>=9.1.0", ] + +[project.optional-dependencies] +sse = [ + "starlette>=0.35.0", + "uvicorn>=0.27.0", +] + [[project.authors]] name = "Dana K. Williams" email = "dana_w@designcomputer.com"
README.md+76 −18 modified@@ -13,6 +13,9 @@ A Model Context Protocol (MCP) implementation that enables secure interaction wi - Execute SQL queries with proper error handling - Secure database access through environment variables - Comprehensive logging +- SSL/TLS connection support +- Multi-database mode (optional `MYSQL_DATABASE`) +- SSE/HTTP transport support (`MCP_TRANSPORT=sse`) ## Installation ### Manual Installation @@ -29,25 +32,61 @@ npx -y @smithery/cli install mysql-mcp-server --client claude ## Configuration Set the following environment variables: ```bash -MYSQL_HOST=localhost # Database host -MYSQL_PORT=3306 # Optional: Database port (defaults to 3306 if not specified) -MYSQL_USER=your_username -MYSQL_PASSWORD=your_password -MYSQL_DATABASE=your_database +MYSQL_HOST=localhost # Database host (default: localhost) +MYSQL_PORT=3306 # Optional: Database port (default: 3306) +MYSQL_USER=your_username # Required +MYSQL_PASSWORD=your_password # Required (can be empty string for no password) +MYSQL_DATABASE=your_database # Optional: Default database (omit for multi-database mode) +MYSQL_CHARSET=utf8mb4 # Optional: Character set (default: utf8mb4) +MYSQL_COLLATION=utf8mb4_unicode_ci # Optional: Collation (default: utf8mb4_unicode_ci) +MYSQL_SSL_MODE=DISABLED # Optional: SSL mode (DISABLED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY) +MYSQL_CONNECT_TIMEOUT=10 # Optional: Connection timeout in seconds (default: 10) +MCP_TRANSPORT=stdio # Optional: Transport mode (stdio [default] or sse) +MCP_SSE_HOST=127.0.0.1 # Optional: SSE server host (default: 127.0.0.1, only used with MCP_TRANSPORT=sse) +MCP_SSE_PORT=8000 # Optional: SSE server port (default: 8000, only used with MCP_TRANSPORT=sse) ``` +### Multi-Database Mode +When `MYSQL_DATABASE` is not set, the server operates in multi-database mode: +- `list_resources` returns all user databases (system databases are filtered out) +- Use `USE <database>` in SQL queries to select a database +- Use fully qualified table names like `mydb.mytable` + +```json +"env": { + "MYSQL_HOST": "localhost", + "MYSQL_USER": "your_username", + "MYSQL_PASSWORD": "your_password" +} +``` + +### SSE/HTTP Transport +To run the server as an HTTP/SSE server (useful for private deployments or agent frameworks): + +```bash +pip install "mysql-mcp-server[sse]" +MCP_TRANSPORT=sse mysql_mcp_server +``` + +Or in Docker: +```bash +docker run -e MCP_TRANSPORT=sse -e MCP_SSE_HOST=0.0.0.0 -e MCP_SSE_PORT=8000 \ + -e MYSQL_USER=... -e MYSQL_PASSWORD=... mysql-mcp-server +``` + +> **Security note:** The SSE server binds to `127.0.0.1` by default. Only expose to `0.0.0.0` in trusted network environments. + ## Usage ### With Claude Desktop Add this to your `claude_desktop_config.json`: ```json { "mcpServers": { "mysql": { - "command": "uv", + "command": "uvx", "args": [ - "--directory", - "path/to/mysql_mcp_server", - "run", + "--from", + "mysql-mcp-server", "mysql_mcp_server" ], "env": { @@ -67,14 +106,14 @@ Add this to your `mcp.json`: ```json { "servers": { - "mysql": { - "type": "stdio", - "command": "uvx", - "args": [ - "--from", - "mysql-mcp-server", - "mysql_mcp_server" - ], + "mysql": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "mysql-mcp-server", + "mysql_mcp_server" + ], "env": { "MYSQL_HOST": "localhost", "MYSQL_PORT": "3306", @@ -86,7 +125,25 @@ Add this to your `mcp.json`: } } ``` -Note: Will need to install uv for this to work +Note: Will need to install [uv](https://docs.astral.sh/uv/) for this to work. + +### SSL Connection Issues +If you encounter SSL-related errors (e.g., `error:0A000102:SSL routines::unsupported protocol`), you can disable SSL: +```json +"env": { + "MYSQL_SSL_MODE": "DISABLED", + ... +} +``` + +### Empty Password +If your MySQL installation uses no password, set `MYSQL_PASSWORD` to an empty string: +```json +"env": { + "MYSQL_PASSWORD": "", + ... +} +``` ### Debugging with MCP Inspector While MySQL MCP Server isn't intended to be run standalone or directly from the command line with Python, you can use the MCP Inspector to debug it. @@ -120,6 +177,7 @@ pytest - Use a database user with minimal required permissions - Consider implementing query whitelisting for production use - Monitor and log all database operations +- Do not expose `MYSQL_PASSWORD` as a Docker `ENV` instruction — pass it at runtime ## Security Best Practices This MCP implementation requires database access to function. For security:
requirements-dev.txt+1 −1 modified@@ -1,6 +1,6 @@ pytest>=7.0.0 pytest-asyncio>=0.23.0 pytest-cov>=4.1.0 -black>=23.0.0 +black>=24.0.0 isort>=5.12.0 mypy>=1.0.0
requirements.txt+1 −1 modified@@ -1,2 +1,2 @@ -mcp>=1.0.0 +mcp>=1.2.0 mysql-connector-python>=9.1.0
src/mysql_mcp_server/server.py+166 −64 modified@@ -1,82 +1,134 @@ import asyncio import logging import os +import re import sys from mysql.connector import connect, Error from mcp.server import Server -from mcp.types import Resource, Tool, TextContent +from mcp.types import Resource, Tool, TextContent, ResourceTemplate from pydantic import AnyUrl -# Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger("mysql_mcp_server") +SYSTEM_DATABASES = {'information_schema', 'mysql', 'performance_schema', 'sys'} + + +def validate_identifier(name: str) -> str: + """Validate a MySQL identifier (table/database name) to prevent SQL injection.""" + if not re.match(r'^[a-zA-Z0-9_$]+$', name): + raise ValueError(f"Invalid identifier '{name}': only alphanumeric, underscore, and $ are allowed") + return name + + def get_db_config(): """Get database configuration from environment variables.""" + user = os.getenv("MYSQL_USER") + password = os.getenv("MYSQL_PASSWORD") + database = os.getenv("MYSQL_DATABASE") + + if not user: + logger.error("Missing required database configuration: MYSQL_USER is required") + raise ValueError("Missing required database configuration") + + if password is None: + logger.error("MYSQL_PASSWORD environment variable must be set (can be empty string for no password)") + raise ValueError("Missing required database configuration") + config = { "host": os.getenv("MYSQL_HOST", "localhost"), "port": int(os.getenv("MYSQL_PORT", "3306")), - "user": os.getenv("MYSQL_USER"), - "password": os.getenv("MYSQL_PASSWORD"), - "database": os.getenv("MYSQL_DATABASE"), - # Add charset and collation to avoid utf8mb4_0900_ai_ci issues with older MySQL versions - # These can be overridden via environment variables for specific MySQL versions + "user": user, + "password": password, "charset": os.getenv("MYSQL_CHARSET", "utf8mb4"), "collation": os.getenv("MYSQL_COLLATION", "utf8mb4_unicode_ci"), - # Disable autocommit for better transaction control "autocommit": True, - # Set SQL mode for better compatibility - can be overridden - "sql_mode": os.getenv("MYSQL_SQL_MODE", "TRADITIONAL") + "sql_mode": os.getenv("MYSQL_SQL_MODE", "TRADITIONAL"), + "connect_timeout": int(os.getenv("MYSQL_CONNECT_TIMEOUT", "10")), } - # Remove None values to let MySQL connector use defaults if not specified - config = {k: v for k, v in config.items() if v is not None} + if database: + config["database"] = database + logger.info(f"Using default database: {database}") + else: + logger.info("No default database specified (multi-database mode). Use 'USE <database>' or fully qualified table names.") - if not all([config.get("user"), config.get("password"), config.get("database")]): - logger.error("Missing required database configuration. Please check environment variables:") - logger.error("MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE are required") - raise ValueError("Missing required database configuration") + ssl_mode = os.getenv("MYSQL_SSL_MODE", "").upper() + if ssl_mode == "DISABLED": + config["ssl_disabled"] = True + elif ssl_mode == "REQUIRED": + config["ssl_verify_cert"] = True + elif ssl_mode == "VERIFY_CA": + config["ssl_verify_cert"] = True + ssl_ca = os.getenv("MYSQL_SSL_CA") + if ssl_ca: + config["ssl_ca"] = ssl_ca + elif ssl_mode == "VERIFY_IDENTITY": + config["ssl_verify_cert"] = True + config["ssl_verify_identity"] = True + ssl_ca = os.getenv("MYSQL_SSL_CA") + if ssl_ca: + config["ssl_ca"] = ssl_ca return config -# Initialize server + app = Server("mysql_mcp_server") + @app.list_resources() async def list_resources() -> list[Resource]: - """List MySQL tables as resources.""" + """List MySQL tables (or databases if no default database) as resources.""" config = get_db_config() try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: - cursor.execute("SHOW TABLES") - tables = cursor.fetchall() - logger.info(f"Found tables: {tables}") - - resources = [] - for table in tables: - resources.append( + if "database" not in config: + cursor.execute("SHOW DATABASES") + databases = cursor.fetchall() + logger.info(f"Found databases: {databases}") + return [ + Resource( + uri=f"mysql://database/{db[0]}", + name=f"Database: {db[0]}", + mimeType="text/plain", + description=f"MySQL database: {db[0]}" + ) + for db in databases if db[0] not in SYSTEM_DATABASES + ] + else: + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + logger.info(f"Found tables: {tables}") + return [ Resource( uri=f"mysql://{table[0]}/data", name=f"Table: {table[0]}", mimeType="text/plain", description=f"Data in table: {table[0]}" ) - ) - return resources + for table in tables + ] except Error as e: - logger.error(f"Failed to list resources: {str(e)}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Failed to list resources: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") return [] + +@app.list_resource_templates() +async def list_resource_templates() -> list[ResourceTemplate]: + """Return available resource templates.""" + return [] + + @app.read_resource() async def read_resource(uri: AnyUrl) -> str: - """Read table contents.""" + """Read table contents or list tables in a database.""" config = get_db_config() uri_str = str(uri) logger.info(f"Reading resource: {uri_str}") @@ -85,23 +137,41 @@ async def read_resource(uri: AnyUrl) -> str: raise ValueError(f"Invalid URI scheme: {uri_str}") parts = uri_str[8:].split('/') - table = parts[0] + + if len(parts) >= 2 and parts[0] == "database": + db_name = validate_identifier(parts[1]) + try: + with connect(**config) as conn: + with conn.cursor() as cursor: + cursor.execute(f"USE `{db_name}`") + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + result = [f"Tables in database '{db_name}':"] + result.extend([table[0] for table in tables]) + return "\n".join(result) + except Error as e: + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Database error reading database {db_name}: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + raise RuntimeError(f"Database error: {error_msg}") + + table = validate_identifier(parts[0]) try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: - cursor.execute(f"SELECT * FROM {table} LIMIT 100") + cursor.execute(f"SELECT * FROM `{table}` LIMIT 100") columns = [desc[0] for desc in cursor.description] rows = cursor.fetchall() - result = [",".join(map(str, row)) for row in rows] + result = [",".join("" if v is None else str(v) for v in row) for row in rows] return "\n".join([",".join(columns)] + result) except Error as e: - logger.error(f"Database error reading resource {uri}: {str(e)}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") - raise RuntimeError(f"Database error: {str(e)}") + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Database error reading resource {uri}: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + raise RuntimeError(f"Database error: {error_msg}") + @app.list_tools() async def list_tools() -> list[Tool]: @@ -124,10 +194,10 @@ async def list_tools() -> list[Tool]: ) ] + @app.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: """Execute SQL commands.""" - config = get_db_config() logger.info(f"Calling tool: {name} with arguments: {arguments}") if name != "execute_sql": @@ -137,56 +207,53 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: if not query: raise ValueError("Query is required") + config = get_db_config() + try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: cursor.execute(query) - # Special handling for SHOW TABLES - if query.strip().upper().startswith("SHOW TABLES"): - tables = cursor.fetchall() - result = ["Tables_in_" + config["database"]] # Header - result.extend([table[0] for table in tables]) - return [TextContent(type="text", text="\n".join(result))] - - # Handle all other queries that return result sets (SELECT, SHOW, DESCRIBE etc.) - elif cursor.description is not None: + if cursor.description is not None: columns = [desc[0] for desc in cursor.description] - try: - rows = cursor.fetchall() - result = [",".join(map(str, row)) for row in rows] - return [TextContent(type="text", text="\n".join([",".join(columns)] + result))] - except Error as e: - logger.warning(f"Error fetching results: {str(e)}") - return [TextContent(type="text", text=f"Query executed but error fetching results: {str(e)}")] - - # Non-SELECT queries + rows = cursor.fetchall() + result_lines = [",".join(columns)] + result_lines.extend([",".join("" if v is None else str(v) for v in row) for row in rows]) + return [TextContent(type="text", text="\n".join(result_lines))] else: conn.commit() return [TextContent(type="text", text=f"Query executed successfully. Rows affected: {cursor.rowcount}")] except Error as e: - logger.error(f"Error executing SQL '{query}': {e}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") - return [TextContent(type="text", text=f"Error executing query: {str(e)}")] + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Error executing SQL '{query}': {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + return [TextContent(type="text", text=f"Error executing query: {error_msg}")] + async def main(): """Main entry point to run the MCP server.""" - from mcp.server.stdio import stdio_server + transport = os.getenv("MCP_TRANSPORT", "stdio").lower() - # Add additional debug output - print("Starting MySQL MCP server with config:", file=sys.stderr) + print(f"Starting MySQL MCP server (transport: {transport}) with config:", file=sys.stderr) config = get_db_config() print(f"Host: {config['host']}", file=sys.stderr) print(f"Port: {config['port']}", file=sys.stderr) print(f"User: {config['user']}", file=sys.stderr) - print(f"Database: {config['database']}", file=sys.stderr) + print(f"Database: {config.get('database', '(not specified, multi-database mode)')}", file=sys.stderr) logger.info("Starting MySQL MCP server...") - logger.info(f"Database config: {config['host']}/{config['database']} as {config['user']}") + logger.info(f"Database config: {config['host']}/{config.get('database', '*')} as {config['user']}") + if transport == "sse": + await _run_sse_server() + else: + await _run_stdio_server() + + +async def _run_stdio_server(): + from mcp.server.stdio import stdio_server async with stdio_server() as (read_stream, write_stream): try: await app.run( @@ -198,5 +265,40 @@ async def main(): logger.error(f"Server error: {str(e)}", exc_info=True) raise + +async def _run_sse_server(): + try: + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.routing import Mount, Route + from starlette.responses import Response + import uvicorn + except ImportError: + logger.error("SSE transport requires additional dependencies. Install with: pip install mysql_mcp_server[sse]") + raise + + sse = SseServerTransport("/messages/") + + async def handle_sse(request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() + + starlette_app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ] + ) + + host = os.getenv("MCP_SSE_HOST", "127.0.0.1") + port = int(os.getenv("MCP_SSE_PORT", "8000")) + logger.info(f"Starting SSE server on {host}:{port}") + + server_config = uvicorn.Config(starlette_app, host=host, port=port, log_level="info") + server = uvicorn.Server(server_config) + await server.serve() + + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main())
tests/test_server.py+66 −3 modified@@ -1,11 +1,13 @@ import pytest -from mysql_mcp_server.server import app, list_tools, list_resources, read_resource, call_tool +from mysql_mcp_server.server import app, list_tools, list_resources, read_resource, call_tool, validate_identifier, get_db_config from pydantic import AnyUrl + def test_server_initialization(): """Test that the server initializes correctly.""" assert app.name == "mysql_mcp_server" + @pytest.mark.asyncio async def test_list_tools(): """Test that list_tools returns expected tools.""" @@ -14,19 +16,80 @@ async def test_list_tools(): assert tools[0].name == "execute_sql" assert "query" in tools[0].inputSchema["properties"] + @pytest.mark.asyncio async def test_call_tool_invalid_name(): """Test calling a tool with an invalid name.""" with pytest.raises(ValueError, match="Unknown tool"): await call_tool("invalid_tool", {}) + @pytest.mark.asyncio async def test_call_tool_missing_query(): """Test calling execute_sql without a query.""" with pytest.raises(ValueError, match="Query is required"): await call_tool("execute_sql", {}) -# Skip database-dependent tests if no database connection + +def test_validate_identifier_valid(): + """Test validate_identifier with valid names.""" + assert validate_identifier("users") == "users" + assert validate_identifier("user_table") == "user_table" + assert validate_identifier("Table123") == "Table123" + assert validate_identifier("my$table") == "my$table" + + +def test_validate_identifier_invalid(): + """Test validate_identifier rejects dangerous input.""" + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users; DROP TABLE users") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("user table") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users'--") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users`inject") + + +def test_get_db_config_optional_database(monkeypatch): + """Test that MYSQL_DATABASE is optional.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + monkeypatch.delenv("MYSQL_DATABASE", raising=False) + + config = get_db_config() + assert "database" not in config + assert config["user"] == "testuser" + + +def test_get_db_config_with_database(monkeypatch): + """Test that MYSQL_DATABASE is included when set.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + monkeypatch.setenv("MYSQL_DATABASE", "mydb") + + config = get_db_config() + assert config["database"] == "mydb" + + +def test_get_db_config_missing_user(monkeypatch): + """Test that missing MYSQL_USER raises ValueError.""" + monkeypatch.delenv("MYSQL_USER", raising=False) + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + + with pytest.raises(ValueError, match="Missing required database configuration"): + get_db_config() + + +def test_get_db_config_missing_password(monkeypatch): + """Test that missing MYSQL_PASSWORD raises ValueError.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.delenv("MYSQL_PASSWORD", raising=False) + + with pytest.raises(ValueError, match="Missing required database configuration"): + get_db_config() + + @pytest.mark.asyncio @pytest.mark.skipif( not all([ @@ -43,4 +106,4 @@ async def test_list_resources(): except ValueError as e: if "Missing required database configuration" in str(e): pytest.skip("Database configuration not available") - raise \ No newline at end of file + raise
76d1712b4d50Merge 080bef9a96d625ce0dfbde573a08b93497871981 into 07f6a303a0fc5c0680bb1f8a6ae5988acf4fbff7
8 files changed · +370 −91
CHANGELOG.md+41 −0 modified@@ -5,6 +5,47 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-03-17 + +### Fixed +- Fixed `SHOW DATABASES` and all `SHOW` commands returning `Error calling tool execute_sql: {}` (issue #48) — removed special-case handling for `SHOW TABLES`; all result-set queries now use unified CSV output +- Fixed error messages being empty when `str(e)` was blank — now uses `e.msg` attribute from MySQL connector +- Fixed NULL values being rendered as the string `"None"` in query results — now rendered as empty strings + +### Added +- `MYSQL_DATABASE` is now optional (issue #68, #81) — when omitted, the server operates in multi-database mode: `list_resources` returns all user databases, and you can switch with `USE <database>` +- SSE/HTTP transport support (issue #60) — set `MCP_TRANSPORT=sse` to run as an HTTP server; configurable via `MCP_SSE_HOST` (default `127.0.0.1`) and `MCP_SSE_PORT` (default `8000`); requires `pip install mysql_mcp_server[sse]` +- `MYSQL_CONNECT_TIMEOUT` environment variable (default `10` seconds) for controlling connection timeout +- `validate_identifier()` utility function for strict MySQL identifier validation (`^[a-zA-Z0-9_$]+$`) +- `read_resource` now supports `mysql://database/<name>` URIs to list tables within a specific database + +### Security +- Replaced ad-hoc table name validation with `validate_identifier()` using a strict regex whitelist +- Upgraded `black` dev dependency to `>=24.0.0` to fix ReDoS vulnerability (CVE-2024-21503) +- Synchronized `mcp>=1.2.0` in `requirements.txt` to match `pyproject.toml` + +### Changed +- `get_db_config()` now only requires `MYSQL_USER` and `MYSQL_PASSWORD`; `MYSQL_DATABASE` is optional +- `main()` refactored into `_run_stdio_server()` and `_run_sse_server()` for clarity +- `list_resources()` returns databases (filtered by system databases) when no default database is configured + +## [0.2.3] - 2026-03-17 + +### Fixed +- Fixed empty password validation: `MYSQL_PASSWORD` can now be an empty string for passwordless MySQL (issue #43) +- Added `list_resource_templates` handler to prevent errors in Visual Studio Code (issue #77) +- Fixed SQL injection risk in `read_resource` by validating table names and using backtick-quoted identifiers (issue #84) +- Fixed README VSCode `mcp.json` example — missing closing brace (issue #42) + +### Added +- SSL/TLS support via `MYSQL_SSL_MODE` env var (`DISABLED`, `REQUIRED`, `VERIFY_CA`, `VERIFY_IDENTITY`) (issue #71) +- Optional `MYSQL_SSL_CA` env var for specifying CA certificate path + +### Security +- Dockerfile no longer exposes `MYSQL_PASSWORD` via `ENV`; secrets should be passed at `docker run` time +- Dockerfile now runs as a non-root user (`appuser`) +- Upgraded minimum `mcp` dependency to `>=1.2.0` to resolve known DoS and DNS-rebinding vulnerabilities + ## [0.2.2] - 2025-04-18 ### Fixed
Dockerfile+10 −2 modified@@ -14,7 +14,15 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy the current directory contents into the container at /app COPY src/ /app/src -# Set environment variables for MySQL (these can be overwritten with `docker run -e`) +ENV PYTHONPATH=/app/src + +# Create a non-root user for security +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# NOTE: Do NOT set MYSQL_PASSWORD or other secrets as ENV here. +# Pass secrets at runtime via `docker run -e MYSQL_PASSWORD=...` +# or use Docker secrets / environment files. ENV MYSQL_HOST=host.docker.internal ENV MYSQL_PORT=3306 ENV MYSQL_USER=your_username @@ -23,4 +31,4 @@ ENV MYSQL_DATABASE=your_database ENV PYTHONPATH=/app/src # Command to run the server -CMD ["python", "-m", "mysql_mcp_server.server"] \ No newline at end of file +CMD ["python", "-m", "mysql_mcp_server.server"]
pyproject.toml+9 −2 modified@@ -1,13 +1,20 @@ [project] name = "mysql_mcp_server" -version = "0.2.2" +version = "0.3.0" description = "A Model Context Protocol (MCP) server that enables secure interaction with MySQL databases. This server allows AI assistants to list tables, read data, and execute SQL queries through a controlled interface, making database exploration and analysis safer and more structured." readme = "README.md" requires-python = ">=3.11" dependencies = [ - "mcp>=1.0.0", + "mcp>=1.2.0", "mysql-connector-python>=9.1.0", ] + +[project.optional-dependencies] +sse = [ + "starlette>=0.35.0", + "uvicorn>=0.27.0", +] + [[project.authors]] name = "Dana K. Williams" email = "dana_w@designcomputer.com"
README.md+76 −18 modified@@ -13,6 +13,9 @@ A Model Context Protocol (MCP) implementation that enables secure interaction wi - Execute SQL queries with proper error handling - Secure database access through environment variables - Comprehensive logging +- SSL/TLS connection support +- Multi-database mode (optional `MYSQL_DATABASE`) +- SSE/HTTP transport support (`MCP_TRANSPORT=sse`) ## Installation ### Manual Installation @@ -29,25 +32,61 @@ npx -y @smithery/cli install mysql-mcp-server --client claude ## Configuration Set the following environment variables: ```bash -MYSQL_HOST=localhost # Database host -MYSQL_PORT=3306 # Optional: Database port (defaults to 3306 if not specified) -MYSQL_USER=your_username -MYSQL_PASSWORD=your_password -MYSQL_DATABASE=your_database +MYSQL_HOST=localhost # Database host (default: localhost) +MYSQL_PORT=3306 # Optional: Database port (default: 3306) +MYSQL_USER=your_username # Required +MYSQL_PASSWORD=your_password # Required (can be empty string for no password) +MYSQL_DATABASE=your_database # Optional: Default database (omit for multi-database mode) +MYSQL_CHARSET=utf8mb4 # Optional: Character set (default: utf8mb4) +MYSQL_COLLATION=utf8mb4_unicode_ci # Optional: Collation (default: utf8mb4_unicode_ci) +MYSQL_SSL_MODE=DISABLED # Optional: SSL mode (DISABLED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY) +MYSQL_CONNECT_TIMEOUT=10 # Optional: Connection timeout in seconds (default: 10) +MCP_TRANSPORT=stdio # Optional: Transport mode (stdio [default] or sse) +MCP_SSE_HOST=127.0.0.1 # Optional: SSE server host (default: 127.0.0.1, only used with MCP_TRANSPORT=sse) +MCP_SSE_PORT=8000 # Optional: SSE server port (default: 8000, only used with MCP_TRANSPORT=sse) ``` +### Multi-Database Mode +When `MYSQL_DATABASE` is not set, the server operates in multi-database mode: +- `list_resources` returns all user databases (system databases are filtered out) +- Use `USE <database>` in SQL queries to select a database +- Use fully qualified table names like `mydb.mytable` + +```json +"env": { + "MYSQL_HOST": "localhost", + "MYSQL_USER": "your_username", + "MYSQL_PASSWORD": "your_password" +} +``` + +### SSE/HTTP Transport +To run the server as an HTTP/SSE server (useful for private deployments or agent frameworks): + +```bash +pip install "mysql-mcp-server[sse]" +MCP_TRANSPORT=sse mysql_mcp_server +``` + +Or in Docker: +```bash +docker run -e MCP_TRANSPORT=sse -e MCP_SSE_HOST=0.0.0.0 -e MCP_SSE_PORT=8000 \ + -e MYSQL_USER=... -e MYSQL_PASSWORD=... mysql-mcp-server +``` + +> **Security note:** The SSE server binds to `127.0.0.1` by default. Only expose to `0.0.0.0` in trusted network environments. + ## Usage ### With Claude Desktop Add this to your `claude_desktop_config.json`: ```json { "mcpServers": { "mysql": { - "command": "uv", + "command": "uvx", "args": [ - "--directory", - "path/to/mysql_mcp_server", - "run", + "--from", + "mysql-mcp-server", "mysql_mcp_server" ], "env": { @@ -67,14 +106,14 @@ Add this to your `mcp.json`: ```json { "servers": { - "mysql": { - "type": "stdio", - "command": "uvx", - "args": [ - "--from", - "mysql-mcp-server", - "mysql_mcp_server" - ], + "mysql": { + "type": "stdio", + "command": "uvx", + "args": [ + "--from", + "mysql-mcp-server", + "mysql_mcp_server" + ], "env": { "MYSQL_HOST": "localhost", "MYSQL_PORT": "3306", @@ -86,7 +125,25 @@ Add this to your `mcp.json`: } } ``` -Note: Will need to install uv for this to work +Note: Will need to install [uv](https://docs.astral.sh/uv/) for this to work. + +### SSL Connection Issues +If you encounter SSL-related errors (e.g., `error:0A000102:SSL routines::unsupported protocol`), you can disable SSL: +```json +"env": { + "MYSQL_SSL_MODE": "DISABLED", + ... +} +``` + +### Empty Password +If your MySQL installation uses no password, set `MYSQL_PASSWORD` to an empty string: +```json +"env": { + "MYSQL_PASSWORD": "", + ... +} +``` ### Debugging with MCP Inspector While MySQL MCP Server isn't intended to be run standalone or directly from the command line with Python, you can use the MCP Inspector to debug it. @@ -120,6 +177,7 @@ pytest - Use a database user with minimal required permissions - Consider implementing query whitelisting for production use - Monitor and log all database operations +- Do not expose `MYSQL_PASSWORD` as a Docker `ENV` instruction — pass it at runtime ## Security Best Practices This MCP implementation requires database access to function. For security:
requirements-dev.txt+1 −1 modified@@ -1,6 +1,6 @@ pytest>=7.0.0 pytest-asyncio>=0.23.0 pytest-cov>=4.1.0 -black>=23.0.0 +black>=24.0.0 isort>=5.12.0 mypy>=1.0.0
requirements.txt+1 −1 modified@@ -1,2 +1,2 @@ -mcp>=1.0.0 +mcp>=1.2.0 mysql-connector-python>=9.1.0
src/mysql_mcp_server/server.py+166 −64 modified@@ -1,82 +1,134 @@ import asyncio import logging import os +import re import sys from mysql.connector import connect, Error from mcp.server import Server -from mcp.types import Resource, Tool, TextContent +from mcp.types import Resource, Tool, TextContent, ResourceTemplate from pydantic import AnyUrl -# Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger("mysql_mcp_server") +SYSTEM_DATABASES = {'information_schema', 'mysql', 'performance_schema', 'sys'} + + +def validate_identifier(name: str) -> str: + """Validate a MySQL identifier (table/database name) to prevent SQL injection.""" + if not re.match(r'^[a-zA-Z0-9_$]+$', name): + raise ValueError(f"Invalid identifier '{name}': only alphanumeric, underscore, and $ are allowed") + return name + + def get_db_config(): """Get database configuration from environment variables.""" + user = os.getenv("MYSQL_USER") + password = os.getenv("MYSQL_PASSWORD") + database = os.getenv("MYSQL_DATABASE") + + if not user: + logger.error("Missing required database configuration: MYSQL_USER is required") + raise ValueError("Missing required database configuration") + + if password is None: + logger.error("MYSQL_PASSWORD environment variable must be set (can be empty string for no password)") + raise ValueError("Missing required database configuration") + config = { "host": os.getenv("MYSQL_HOST", "localhost"), "port": int(os.getenv("MYSQL_PORT", "3306")), - "user": os.getenv("MYSQL_USER"), - "password": os.getenv("MYSQL_PASSWORD"), - "database": os.getenv("MYSQL_DATABASE"), - # Add charset and collation to avoid utf8mb4_0900_ai_ci issues with older MySQL versions - # These can be overridden via environment variables for specific MySQL versions + "user": user, + "password": password, "charset": os.getenv("MYSQL_CHARSET", "utf8mb4"), "collation": os.getenv("MYSQL_COLLATION", "utf8mb4_unicode_ci"), - # Disable autocommit for better transaction control "autocommit": True, - # Set SQL mode for better compatibility - can be overridden - "sql_mode": os.getenv("MYSQL_SQL_MODE", "TRADITIONAL") + "sql_mode": os.getenv("MYSQL_SQL_MODE", "TRADITIONAL"), + "connect_timeout": int(os.getenv("MYSQL_CONNECT_TIMEOUT", "10")), } - # Remove None values to let MySQL connector use defaults if not specified - config = {k: v for k, v in config.items() if v is not None} + if database: + config["database"] = database + logger.info(f"Using default database: {database}") + else: + logger.info("No default database specified (multi-database mode). Use 'USE <database>' or fully qualified table names.") - if not all([config.get("user"), config.get("password"), config.get("database")]): - logger.error("Missing required database configuration. Please check environment variables:") - logger.error("MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE are required") - raise ValueError("Missing required database configuration") + ssl_mode = os.getenv("MYSQL_SSL_MODE", "").upper() + if ssl_mode == "DISABLED": + config["ssl_disabled"] = True + elif ssl_mode == "REQUIRED": + config["ssl_verify_cert"] = True + elif ssl_mode == "VERIFY_CA": + config["ssl_verify_cert"] = True + ssl_ca = os.getenv("MYSQL_SSL_CA") + if ssl_ca: + config["ssl_ca"] = ssl_ca + elif ssl_mode == "VERIFY_IDENTITY": + config["ssl_verify_cert"] = True + config["ssl_verify_identity"] = True + ssl_ca = os.getenv("MYSQL_SSL_CA") + if ssl_ca: + config["ssl_ca"] = ssl_ca return config -# Initialize server + app = Server("mysql_mcp_server") + @app.list_resources() async def list_resources() -> list[Resource]: - """List MySQL tables as resources.""" + """List MySQL tables (or databases if no default database) as resources.""" config = get_db_config() try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: - cursor.execute("SHOW TABLES") - tables = cursor.fetchall() - logger.info(f"Found tables: {tables}") - - resources = [] - for table in tables: - resources.append( + if "database" not in config: + cursor.execute("SHOW DATABASES") + databases = cursor.fetchall() + logger.info(f"Found databases: {databases}") + return [ + Resource( + uri=f"mysql://database/{db[0]}", + name=f"Database: {db[0]}", + mimeType="text/plain", + description=f"MySQL database: {db[0]}" + ) + for db in databases if db[0] not in SYSTEM_DATABASES + ] + else: + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + logger.info(f"Found tables: {tables}") + return [ Resource( uri=f"mysql://{table[0]}/data", name=f"Table: {table[0]}", mimeType="text/plain", description=f"Data in table: {table[0]}" ) - ) - return resources + for table in tables + ] except Error as e: - logger.error(f"Failed to list resources: {str(e)}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Failed to list resources: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") return [] + +@app.list_resource_templates() +async def list_resource_templates() -> list[ResourceTemplate]: + """Return available resource templates.""" + return [] + + @app.read_resource() async def read_resource(uri: AnyUrl) -> str: - """Read table contents.""" + """Read table contents or list tables in a database.""" config = get_db_config() uri_str = str(uri) logger.info(f"Reading resource: {uri_str}") @@ -85,23 +137,41 @@ async def read_resource(uri: AnyUrl) -> str: raise ValueError(f"Invalid URI scheme: {uri_str}") parts = uri_str[8:].split('/') - table = parts[0] + + if len(parts) >= 2 and parts[0] == "database": + db_name = validate_identifier(parts[1]) + try: + with connect(**config) as conn: + with conn.cursor() as cursor: + cursor.execute(f"USE `{db_name}`") + cursor.execute("SHOW TABLES") + tables = cursor.fetchall() + result = [f"Tables in database '{db_name}':"] + result.extend([table[0] for table in tables]) + return "\n".join(result) + except Error as e: + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Database error reading database {db_name}: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + raise RuntimeError(f"Database error: {error_msg}") + + table = validate_identifier(parts[0]) try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: - cursor.execute(f"SELECT * FROM {table} LIMIT 100") + cursor.execute(f"SELECT * FROM `{table}` LIMIT 100") columns = [desc[0] for desc in cursor.description] rows = cursor.fetchall() - result = [",".join(map(str, row)) for row in rows] + result = [",".join("" if v is None else str(v) for v in row) for row in rows] return "\n".join([",".join(columns)] + result) except Error as e: - logger.error(f"Database error reading resource {uri}: {str(e)}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") - raise RuntimeError(f"Database error: {str(e)}") + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Database error reading resource {uri}: {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + raise RuntimeError(f"Database error: {error_msg}") + @app.list_tools() async def list_tools() -> list[Tool]: @@ -124,10 +194,10 @@ async def list_tools() -> list[Tool]: ) ] + @app.call_tool() async def call_tool(name: str, arguments: dict) -> list[TextContent]: """Execute SQL commands.""" - config = get_db_config() logger.info(f"Calling tool: {name} with arguments: {arguments}") if name != "execute_sql": @@ -137,56 +207,53 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: if not query: raise ValueError("Query is required") + config = get_db_config() + try: logger.info(f"Connecting to MySQL with charset: {config.get('charset')}, collation: {config.get('collation')}") with connect(**config) as conn: logger.info(f"Successfully connected to MySQL server version: {conn.get_server_info()}") with conn.cursor() as cursor: cursor.execute(query) - # Special handling for SHOW TABLES - if query.strip().upper().startswith("SHOW TABLES"): - tables = cursor.fetchall() - result = ["Tables_in_" + config["database"]] # Header - result.extend([table[0] for table in tables]) - return [TextContent(type="text", text="\n".join(result))] - - # Handle all other queries that return result sets (SELECT, SHOW, DESCRIBE etc.) - elif cursor.description is not None: + if cursor.description is not None: columns = [desc[0] for desc in cursor.description] - try: - rows = cursor.fetchall() - result = [",".join(map(str, row)) for row in rows] - return [TextContent(type="text", text="\n".join([",".join(columns)] + result))] - except Error as e: - logger.warning(f"Error fetching results: {str(e)}") - return [TextContent(type="text", text=f"Query executed but error fetching results: {str(e)}")] - - # Non-SELECT queries + rows = cursor.fetchall() + result_lines = [",".join(columns)] + result_lines.extend([",".join("" if v is None else str(v) for v in row) for row in rows]) + return [TextContent(type="text", text="\n".join(result_lines))] else: conn.commit() return [TextContent(type="text", text=f"Query executed successfully. Rows affected: {cursor.rowcount}")] except Error as e: - logger.error(f"Error executing SQL '{query}': {e}") - logger.error(f"Error code: {e.errno}, SQL state: {e.sqlstate}") - return [TextContent(type="text", text=f"Error executing query: {str(e)}")] + error_msg = getattr(e, 'msg', None) or str(e) or 'Unknown MySQL error' + logger.error(f"Error executing SQL '{query}': {error_msg} (errno: {e.errno}, sqlstate: {e.sqlstate})") + return [TextContent(type="text", text=f"Error executing query: {error_msg}")] + async def main(): """Main entry point to run the MCP server.""" - from mcp.server.stdio import stdio_server + transport = os.getenv("MCP_TRANSPORT", "stdio").lower() - # Add additional debug output - print("Starting MySQL MCP server with config:", file=sys.stderr) + print(f"Starting MySQL MCP server (transport: {transport}) with config:", file=sys.stderr) config = get_db_config() print(f"Host: {config['host']}", file=sys.stderr) print(f"Port: {config['port']}", file=sys.stderr) print(f"User: {config['user']}", file=sys.stderr) - print(f"Database: {config['database']}", file=sys.stderr) + print(f"Database: {config.get('database', '(not specified, multi-database mode)')}", file=sys.stderr) logger.info("Starting MySQL MCP server...") - logger.info(f"Database config: {config['host']}/{config['database']} as {config['user']}") + logger.info(f"Database config: {config['host']}/{config.get('database', '*')} as {config['user']}") + if transport == "sse": + await _run_sse_server() + else: + await _run_stdio_server() + + +async def _run_stdio_server(): + from mcp.server.stdio import stdio_server async with stdio_server() as (read_stream, write_stream): try: await app.run( @@ -198,5 +265,40 @@ async def main(): logger.error(f"Server error: {str(e)}", exc_info=True) raise + +async def _run_sse_server(): + try: + from mcp.server.sse import SseServerTransport + from starlette.applications import Starlette + from starlette.routing import Mount, Route + from starlette.responses import Response + import uvicorn + except ImportError: + logger.error("SSE transport requires additional dependencies. Install with: pip install mysql_mcp_server[sse]") + raise + + sse = SseServerTransport("/messages/") + + async def handle_sse(request): + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + return Response() + + starlette_app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ] + ) + + host = os.getenv("MCP_SSE_HOST", "127.0.0.1") + port = int(os.getenv("MCP_SSE_PORT", "8000")) + logger.info(f"Starting SSE server on {host}:{port}") + + server_config = uvicorn.Config(starlette_app, host=host, port=port, log_level="info") + server = uvicorn.Server(server_config) + await server.serve() + + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main())
tests/test_server.py+66 −3 modified@@ -1,11 +1,13 @@ import pytest -from mysql_mcp_server.server import app, list_tools, list_resources, read_resource, call_tool +from mysql_mcp_server.server import app, list_tools, list_resources, read_resource, call_tool, validate_identifier, get_db_config from pydantic import AnyUrl + def test_server_initialization(): """Test that the server initializes correctly.""" assert app.name == "mysql_mcp_server" + @pytest.mark.asyncio async def test_list_tools(): """Test that list_tools returns expected tools.""" @@ -14,19 +16,80 @@ async def test_list_tools(): assert tools[0].name == "execute_sql" assert "query" in tools[0].inputSchema["properties"] + @pytest.mark.asyncio async def test_call_tool_invalid_name(): """Test calling a tool with an invalid name.""" with pytest.raises(ValueError, match="Unknown tool"): await call_tool("invalid_tool", {}) + @pytest.mark.asyncio async def test_call_tool_missing_query(): """Test calling execute_sql without a query.""" with pytest.raises(ValueError, match="Query is required"): await call_tool("execute_sql", {}) -# Skip database-dependent tests if no database connection + +def test_validate_identifier_valid(): + """Test validate_identifier with valid names.""" + assert validate_identifier("users") == "users" + assert validate_identifier("user_table") == "user_table" + assert validate_identifier("Table123") == "Table123" + assert validate_identifier("my$table") == "my$table" + + +def test_validate_identifier_invalid(): + """Test validate_identifier rejects dangerous input.""" + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users; DROP TABLE users") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("user table") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users'--") + with pytest.raises(ValueError, match="Invalid identifier"): + validate_identifier("users`inject") + + +def test_get_db_config_optional_database(monkeypatch): + """Test that MYSQL_DATABASE is optional.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + monkeypatch.delenv("MYSQL_DATABASE", raising=False) + + config = get_db_config() + assert "database" not in config + assert config["user"] == "testuser" + + +def test_get_db_config_with_database(monkeypatch): + """Test that MYSQL_DATABASE is included when set.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + monkeypatch.setenv("MYSQL_DATABASE", "mydb") + + config = get_db_config() + assert config["database"] == "mydb" + + +def test_get_db_config_missing_user(monkeypatch): + """Test that missing MYSQL_USER raises ValueError.""" + monkeypatch.delenv("MYSQL_USER", raising=False) + monkeypatch.setenv("MYSQL_PASSWORD", "testpass") + + with pytest.raises(ValueError, match="Missing required database configuration"): + get_db_config() + + +def test_get_db_config_missing_password(monkeypatch): + """Test that missing MYSQL_PASSWORD raises ValueError.""" + monkeypatch.setenv("MYSQL_USER", "testuser") + monkeypatch.delenv("MYSQL_PASSWORD", raising=False) + + with pytest.raises(ValueError, match="Missing required database configuration"): + get_db_config() + + @pytest.mark.asyncio @pytest.mark.skipif( not all([ @@ -43,4 +106,4 @@ async def test_list_resources(): except ValueError as e: if "Missing required database configuration" in str(e): pytest.skip("Database configuration not available") - raise \ No newline at end of file + raise
Vulnerability mechanics
Root cause
"The `read_resource` function in `server.py` directly interpolates user-supplied URI components into SQL queries without sanitization."
Attack vector
An attacker can exploit this vulnerability by crafting a malicious URI that injects arbitrary SQL code into the table name parameter. This can be achieved through prompt injection attacks against an AI client that uses the affected server, or via compromised MCP client implementations. The injected SQL is executed with the privileges of the MySQL connection, potentially leading to data exfiltration or modification [ref_id=2].
Affected code
The vulnerability resides in the `read_resource` function within the file `src/mysql_mcp_server/server.py`. Specifically, lines 87-95 of the vulnerable code parse the URI to extract a table name and directly interpolate it into an SQL query using f-string formatting, which is the SQL injection point [ref_id=2].
What the fix does
The patch, identified by commit hash `080bef9a96d625ce0dfbde573a08b93497871981`, addresses the SQL injection vulnerability. While the patch itself is not detailed in the provided information, the advisory indicates that upgrading to version 0.3.0 is sufficient to resolve the issue [ref_id=1]. This implies that the fix involves proper input validation or parameterized queries for the table name parameter.
Preconditions
- inputThe attacker must be able to send a crafted URI to the `read_resource` handler.
- configThe MySQL server must be configured with a user that has sufficient privileges to execute arbitrary SQL queries.
Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/designcomputer/mysql_mcp_server/commit/080bef9a96d625ce0dfbde573a08b93497871981nvd
- github.com/designcomputer/mysql_mcp_server/issues/89nvd
- github.com/designcomputer/mysql_mcp_server/pull/86nvd
- github.com/designcomputer/mysql_mcp_server/releases/tag/v0.3.0nvd
- vuldb.com/cve/CVE-2026-11529nvd
- vuldb.com/submit/836490nvd
- vuldb.com/vuln/369146nvd
- vuldb.com/vuln/369146/ctinvd
News mentions
0No linked articles in our index yet.