awslabs.mysql-mcp-server 1.0.7__py3-none-any.whl → 1.0.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,4 +14,4 @@
14
14
 
15
15
  """awslabs.mysql_mcp_server"""
16
16
 
17
- __version__ = '1.0.7'
17
+ __version__ = '1.0.9'
@@ -0,0 +1,18 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """aws.mysql-mcp-server.connection"""
16
+
17
+ from awslabs.mysql_mcp_server.connection.db_connection_singleton import DBConnectionSingleton
18
+ from awslabs.mysql_mcp_server.connection.abstract_db_connection import AbstractDBConnection
@@ -0,0 +1,68 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Abstract database connection interface for MySQL MCP Server."""
16
+
17
+ from abc import ABC, abstractmethod
18
+ from typing import Any, Dict, List, Optional
19
+
20
+
21
+ class AbstractDBConnection(ABC):
22
+ """Abstract base class for database connections."""
23
+
24
+ def __init__(self, readonly: bool):
25
+ """Initialize the database connection.
26
+
27
+ Args:
28
+ readonly: Whether the connection should be read-only
29
+ """
30
+ self._readonly = readonly
31
+
32
+ @property
33
+ def readonly_query(self) -> bool:
34
+ """Get whether this connection is read-only.
35
+
36
+ Returns:
37
+ bool: True if the connection is read-only, False otherwise
38
+ """
39
+ return self._readonly
40
+
41
+ @abstractmethod
42
+ async def execute_query(
43
+ self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
44
+ ) -> Dict[str, Any]:
45
+ """Execute a SQL query.
46
+
47
+ Args:
48
+ sql: The SQL query to execute
49
+ parameters: Optional parameters for the query
50
+
51
+ Returns:
52
+ Dict containing query results with column metadata and records
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ async def close(self) -> None:
58
+ """Close the database connection."""
59
+ pass
60
+
61
+ @abstractmethod
62
+ async def check_connection_health(self) -> bool:
63
+ """Check if the database connection is healthy.
64
+
65
+ Returns:
66
+ bool: True if the connection is healthy, False otherwise
67
+ """
68
+ pass
@@ -0,0 +1,267 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Python connector for MySQL and MariaDB MCP Server.
16
+
17
+ This connector provides direct connection to MySQL/MariaDB databases using asyncmy.
18
+ It supports both Aurora MySQL and RDS Mysql/MariaDB instances via direct connection.
19
+ """
20
+
21
+ import boto3
22
+ import json
23
+ from asyncmy import Pool, create_pool
24
+ from awslabs.mysql_mcp_server.connection.abstract_db_connection import AbstractDBConnection
25
+ from loguru import logger
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+
29
+ class AsyncmyPoolConnection(AbstractDBConnection):
30
+ """Class that wraps DB connection using asyncmy connection pool.
31
+
32
+ This class can connect directly to any MySQL and MariaDB database, including:
33
+ - Aurora MySQL (using the cluster endpoint)
34
+ - RDS MySQL and RDS MariaDB (using the instance endpoint)
35
+ - Self-hosted MySQL and MariaDB
36
+
37
+ It uses AWS Secrets Manager (secret_arn and region) for authentication.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ hostname: str,
43
+ port: int,
44
+ database: str,
45
+ readonly: bool,
46
+ secret_arn: str,
47
+ region: str,
48
+ min_size: int = 1,
49
+ max_size: int = 10,
50
+ ):
51
+ """Initialize a new DB connection pool.
52
+
53
+ Args:
54
+ hostname: Database host (Aurora cluster endpoint or RDS instance endpoint)
55
+ port: Database port (default 3306)
56
+ database: Database name
57
+ readonly: Whether connections should be read-only
58
+ secret_arn: ARN of the secret containing credentials
59
+ region: AWS region for Secrets Manager
60
+ min_size: Minimum number of connections in the pool
61
+ max_size: Maximum number of connections in the pool
62
+ """
63
+ super().__init__(readonly)
64
+ self.hostname = hostname
65
+ self.port = port
66
+ self.min_size = min_size
67
+ self.max_size = max_size
68
+ self.pool: Optional[Pool] = None
69
+ self.database = database
70
+
71
+ # Get credentials from Secrets Manager
72
+ logger.info(f'Retrieving credentials from Secrets Manager: {secret_arn}')
73
+ self.user, self.password = _get_credentials_from_secret(secret_arn, region)
74
+ logger.info(f'Successfully retrieved credentials for user: {self.user}')
75
+
76
+ async def initialize_pool(self):
77
+ """Initialize the connection pool."""
78
+ if self.pool is None:
79
+ logger.info(
80
+ f'Initializing connection pool with min_size={self.min_size}, max_size={self.max_size}'
81
+ )
82
+
83
+ self.pool = await create_pool(
84
+ minsize=self.min_size,
85
+ maxsize=self.max_size,
86
+ host=self.hostname,
87
+ port=self.port,
88
+ user=self.user,
89
+ password=self.password,
90
+ db=self.database,
91
+ autocommit=True,
92
+ )
93
+
94
+ logger.info('Connection pool initialized successfully')
95
+
96
+ if self._readonly:
97
+ await self._set_all_connections_readonly()
98
+
99
+ async def _set_all_connections_readonly(self):
100
+ """Set all connections in the pool to read-only mode."""
101
+ if self.pool is None:
102
+ logger.warning('Connection pool is not initialized, cannot set read-only mode')
103
+ return
104
+
105
+ try:
106
+ async with self.pool.acquire() as conn:
107
+ async with conn.cursor() as cursor:
108
+ await cursor.execute('SET SESSION TRANSACTION READ ONLY;')
109
+ logger.info('Successfully set connection to read-only mode')
110
+ except Exception as e:
111
+ logger.warning(f'Failed to set connections to read-only mode: {str(e)}')
112
+ logger.warning('Continuing without setting read-only mode')
113
+
114
+ async def _get_connection(self):
115
+ """Get a database connection from the pool."""
116
+ if self.pool is None:
117
+ await self.initialize_pool()
118
+
119
+ if self.pool is None:
120
+ raise ValueError('Failed to initialize connection pool')
121
+
122
+ return self.pool.acquire()
123
+
124
+ async def execute_query(
125
+ self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
126
+ ) -> Dict[str, Any]:
127
+ """Execute a SQL query using async connection."""
128
+ try:
129
+ async with await self._get_connection() as conn:
130
+ async with conn.cursor() as cursor:
131
+ if self._readonly:
132
+ await cursor.execute('SET TRANSACTION READ ONLY')
133
+ # Execute the query
134
+ if parameters:
135
+ params = list(_convert_parameters(self, parameters).values())
136
+ await cursor.execute(sql, params)
137
+ else:
138
+ await cursor.execute(sql)
139
+
140
+ # Check if there are results to fetch by examining the cursor's description
141
+ if cursor.description:
142
+ # Get column names
143
+ columns = [desc[0] for desc in cursor.description]
144
+
145
+ # Fetch all rows
146
+ rows = await cursor.fetchall()
147
+
148
+ # Structure the response to match the interface contract required by server.py
149
+ column_metadata = [{'label': col} for col in columns]
150
+ records = []
151
+
152
+ # Convert each row to the expected format
153
+ for row in rows:
154
+ record = []
155
+ for value in row:
156
+ if value is None:
157
+ record.append({'isNull': True})
158
+ elif isinstance(value, str):
159
+ record.append({'stringValue': value})
160
+ elif isinstance(value, int):
161
+ record.append({'longValue': value})
162
+ elif isinstance(value, float):
163
+ record.append({'doubleValue': value})
164
+ elif isinstance(value, bool):
165
+ record.append({'booleanValue': value})
166
+ elif isinstance(value, bytes):
167
+ record.append({'blobValue': value})
168
+ else:
169
+ # Convert other types to string
170
+ record.append({'stringValue': str(value)})
171
+ records.append(record)
172
+
173
+ return {'columnMetadata': column_metadata, 'records': records}
174
+ else:
175
+ # No results (e.g., for INSERT, UPDATE, etc.)
176
+ return {'columnMetadata': [], 'records': []}
177
+
178
+ except Exception as e:
179
+ logger.error(f'Database connection error: {str(e)}')
180
+ raise e
181
+
182
+ async def close(self) -> None:
183
+ """Close all connections in the pool."""
184
+ if self.pool is not None:
185
+ logger.info('Closing connection pool')
186
+ await self.pool.close()
187
+ self.pool = None
188
+ logger.info('Connection pool closed successfully')
189
+
190
+ async def check_connection_health(self) -> bool:
191
+ """Check if the connection is healthy."""
192
+ try:
193
+ result = await self.execute_query('SELECT 1')
194
+ return len(result.get('records', [])) > 0
195
+ except Exception as e:
196
+ logger.error(f'Connection health check failed: {str(e)}')
197
+ return False
198
+
199
+
200
+ def _convert_parameters(self, parameters: List[Dict[str, Any]]) -> Dict[str, Any]:
201
+ """Transform structured parameter format to psycopg's native parameter format."""
202
+ result = {}
203
+ for param in parameters:
204
+ name = param.get('name')
205
+ value = param.get('value', {})
206
+
207
+ # Extract the value based on its type
208
+ if 'stringValue' in value:
209
+ result[name] = value['stringValue']
210
+ elif 'longValue' in value:
211
+ result[name] = value['longValue']
212
+ elif 'doubleValue' in value:
213
+ result[name] = value['doubleValue']
214
+ elif 'booleanValue' in value:
215
+ result[name] = value['booleanValue']
216
+ elif 'blobValue' in value:
217
+ result[name] = value['blobValue']
218
+ elif 'isNull' in value and value['isNull']:
219
+ result[name] = None
220
+
221
+ return result
222
+
223
+
224
+ def _get_credentials_from_secret(secret_arn: str, region: str) -> Tuple[str, str]:
225
+ """Get database credentials from AWS Secrets Manager."""
226
+ try:
227
+ # Create a Secrets Manager client
228
+ logger.info(f'Creating Secrets Manager client in region {region}')
229
+ session = boto3.Session()
230
+ client = session.client(service_name='secretsmanager', region_name=region)
231
+
232
+ # Get the secret value
233
+ logger.info(f'Retrieving secret value for {secret_arn}')
234
+ get_secret_value_response = client.get_secret_value(SecretId=secret_arn)
235
+ logger.info('Successfully retrieved secret value')
236
+
237
+ # Parse the secret string
238
+ if 'SecretString' in get_secret_value_response:
239
+ secret = json.loads(get_secret_value_response['SecretString'])
240
+ logger.info(f'Secret keys: {", ".join(secret.keys())}')
241
+
242
+ # Extract username and password
243
+ username = secret.get('username') or secret.get('user') or secret.get('Username')
244
+ password = secret.get('password') or secret.get('Password')
245
+
246
+ if not username:
247
+ logger.error(
248
+ f'Username not found in secret. Available keys: {", ".join(secret.keys())}'
249
+ )
250
+ raise ValueError(
251
+ f'Secret does not contain username. Available keys: {", ".join(secret.keys())}'
252
+ )
253
+
254
+ if not password:
255
+ logger.error('Password not found in secret')
256
+ raise ValueError(
257
+ f'Secret does not contain password. Available keys: {", ".join(secret.keys())}'
258
+ )
259
+
260
+ logger.info(f'Successfully extracted credentials for user: {username}')
261
+ return username, password
262
+ else:
263
+ logger.error('Secret does not contain a SecretString')
264
+ raise ValueError('Secret does not contain a SecretString')
265
+ except Exception as e:
266
+ logger.error(f'Error retrieving secret: {str(e)}')
267
+ raise ValueError(f'Failed to retrieve credentials from Secrets Manager: {str(e)}')
@@ -0,0 +1,142 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Database connection singleton for MySQL and MariaDB MCP Server."""
16
+
17
+ from awslabs.mysql_mcp_server.connection.asyncmy_pool_connection import AsyncmyPoolConnection
18
+ from awslabs.mysql_mcp_server.connection.rds_data_api_connection import RDSDataAPIConnection
19
+
20
+
21
+ class DBConnectionSingleton:
22
+ """Manages a single database connection instance across the application."""
23
+
24
+ _instance = None
25
+
26
+ def __init__(
27
+ self,
28
+ secret_arn: str,
29
+ database: str,
30
+ region: str,
31
+ readonly: bool = True,
32
+ is_test: bool = False,
33
+ resource_arn: str | None = None,
34
+ hostname: str | None = None,
35
+ port: int | None = None,
36
+ ):
37
+ """Initialize a new DB connection singleton using one of the two connection types.
38
+
39
+ 1. RDS Data API Connection by specifying resource ARN
40
+ 2. Direct MySQL or MariaDB connection by specifying hostname and port
41
+
42
+ Args:
43
+ secret_arn: The ARN of the secret containing credentials
44
+ database: The name of the database to connect to
45
+ region: The AWS region where the RDS instance is located
46
+ readonly: Whether the connection should be read-only (default: True)
47
+ resource_arn: The ARN of the RDS cluster (for using RDS Data API)
48
+ hostname: Database hostname (for using direct MySQL connection)
49
+ port: Database port (for using direct MySQL connection)
50
+ is_test: Whether this is a test connection (default: False)
51
+ """
52
+ if resource_arn:
53
+ if not all([resource_arn, secret_arn, database, region]):
54
+ raise ValueError(
55
+ 'Missing required connection parameters for RDS Data API. '
56
+ 'Please provide resource_arn, secret_arn, database, and region.'
57
+ )
58
+
59
+ self._db_connection = RDSDataAPIConnection(
60
+ cluster_arn=resource_arn,
61
+ secret_arn=secret_arn,
62
+ database=database,
63
+ region=region,
64
+ readonly=readonly,
65
+ is_test=is_test,
66
+ )
67
+ else:
68
+ # Direct connection to MySQL/MariaDB
69
+ if not all([hostname, port, secret_arn, database, region]):
70
+ raise ValueError(
71
+ 'Missing required connection parameters for direct MySQL connection. '
72
+ 'Please provide hostname, port, secret_arn, database, and region.'
73
+ )
74
+ assert hostname is not None
75
+ assert port is not None
76
+
77
+ self._db_connection = AsyncmyPoolConnection(
78
+ hostname=hostname,
79
+ port=port,
80
+ secret_arn=secret_arn,
81
+ database=database,
82
+ region=region,
83
+ readonly=readonly,
84
+ )
85
+
86
+ @classmethod
87
+ def initialize(
88
+ cls,
89
+ secret_arn: str,
90
+ database: str,
91
+ region: str,
92
+ readonly: bool = True,
93
+ is_test: bool = False,
94
+ resource_arn: str | None = None,
95
+ hostname: str | None = None,
96
+ port: int | None = None,
97
+ ):
98
+ """Initialize the singleton instance if it doesn't exist.
99
+
100
+ Args:
101
+ resource_arn: The ARN of the RDS cluster (for using RDS Data API)
102
+ hostname: Database hostname (for using direct MySQL/MariaDB connection)
103
+ port: Database port (for using direct MySQL/MariaDB connection)
104
+ secret_arn: The ARN of the secret containing credentials
105
+ database: The name of the database to connect to
106
+ region: The AWS region where the RDS instance is located
107
+ readonly: Whether the connection should be read-only (default: True)
108
+ is_test: Whether this is a test connection (default: False)
109
+ """
110
+ if cls._instance is None:
111
+ cls._instance = cls(
112
+ secret_arn=secret_arn,
113
+ database=database,
114
+ region=region,
115
+ readonly=readonly,
116
+ resource_arn=resource_arn,
117
+ hostname=hostname,
118
+ port=port,
119
+ is_test=is_test,
120
+ )
121
+
122
+ @classmethod
123
+ def get(cls):
124
+ """Get the singleton instance.
125
+
126
+ Returns:
127
+ DBConnectionSingleton: The singleton instance
128
+ Raises:
129
+ RuntimeError: If the singleton has not been initialized
130
+ """
131
+ if cls._instance is None:
132
+ raise RuntimeError('DBConnectionSingleton is not initialized.')
133
+ return cls._instance
134
+
135
+ @property
136
+ def db_connection(self):
137
+ """Get the database connection.
138
+
139
+ Returns:
140
+ DBConnection: The database connection instance
141
+ """
142
+ return self._db_connection
@@ -0,0 +1,93 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """RDS Data API connector for MySQL MCP Server."""
16
+
17
+ import asyncio
18
+ import boto3
19
+ from awslabs.mysql_mcp_server.connection.abstract_db_connection import AbstractDBConnection
20
+ from loguru import logger
21
+ from typing import Any, Dict, List, Optional
22
+
23
+
24
+ class RDSDataAPIConnection(AbstractDBConnection):
25
+ """Class that wraps DB connection client by RDS Data API."""
26
+
27
+ def __init__(
28
+ self,
29
+ cluster_arn: str,
30
+ secret_arn: str,
31
+ database: str,
32
+ region: str,
33
+ readonly: bool,
34
+ is_test: bool = False,
35
+ ):
36
+ """Initialize a new DB connection.
37
+
38
+ Args:
39
+ cluster_arn: The ARN of the Aurora MySQL cluster
40
+ secret_arn: The ARN of the secret containing credentials
41
+ database: The name of the database to connect to
42
+ region: The AWS region where the RDS instance is located
43
+ readonly: Whether the connection should be read-only
44
+ is_test: Whether this is a test connection
45
+ """
46
+ super().__init__(readonly)
47
+ self.cluster_arn = cluster_arn
48
+ self.secret_arn = secret_arn
49
+ self.database = database
50
+ if not is_test:
51
+ self.data_client = boto3.client('rds-data', region_name=region)
52
+
53
+ async def execute_query(
54
+ self, sql: str, parameters: Optional[List[Dict[str, Any]]] = None
55
+ ) -> Dict[str, Any]:
56
+ """Execute a SQL query using RDS Data API.
57
+
58
+ Args:
59
+ sql: The SQL query to execute
60
+ parameters: Optional parameters for the query
61
+ Returns:
62
+ Dict containing query results with column metadata and records
63
+ """
64
+ execute_params = {
65
+ 'resourceArn': self.cluster_arn,
66
+ 'secretArn': self.secret_arn,
67
+ 'database': self.database,
68
+ 'sql': sql,
69
+ 'includeResultMetadata': True,
70
+ }
71
+
72
+ if parameters:
73
+ execute_params['parameters'] = parameters
74
+
75
+ return await asyncio.to_thread(self.data_client.execute_statement, **execute_params)
76
+
77
+ async def close(self) -> None:
78
+ """Close the database connection asynchronously."""
79
+ # RDS Data API doesn't maintain persistent connections
80
+ pass
81
+
82
+ async def check_connection_health(self) -> bool:
83
+ """Check if the RDS Data API connection is healthy.
84
+
85
+ Returns:
86
+ bool: True if the connection is healthy, False otherwise
87
+ """
88
+ try:
89
+ result = await self.execute_query('SELECT 1')
90
+ return len(result.get('records', [])) > 0
91
+ except Exception as e:
92
+ logger.error(f'RDS Data API connection health check failed: {str(e)}')
93
+ return False
@@ -16,13 +16,14 @@
16
16
 
17
17
  import argparse
18
18
  import asyncio
19
- import boto3
20
19
  import sys
20
+ from awslabs.mysql_mcp_server.connection import DBConnectionSingleton
21
+ from awslabs.mysql_mcp_server.connection.asyncmy_pool_connection import AsyncmyPoolConnection
21
22
  from awslabs.mysql_mcp_server.mutable_sql_detector import (
22
23
  check_sql_injection_risk,
23
24
  detect_mutating_keywords,
24
25
  )
25
- from botocore.exceptions import BotoCoreError, ClientError
26
+ from botocore.exceptions import ClientError
26
27
  from loguru import logger
27
28
  from mcp.server.fastmcp import Context, FastMCP
28
29
  from pydantic import Field
@@ -48,104 +49,6 @@ class DummyCtx:
48
49
  pass
49
50
 
50
51
 
51
- class DBConnection:
52
- """Class that wraps DB connection client by RDS API."""
53
-
54
- def __init__(self, cluster_arn, secret_arn, database, region, readonly, is_test=False):
55
- """Initialize a new DB connection.
56
-
57
- Args:
58
- cluster_arn: The ARN of the RDS cluster
59
- secret_arn: The ARN of the secret containing credentials
60
- database: The name of the database to connect to
61
- region: The AWS region where the RDS instance is located
62
- readonly: Whether the connection should be read-only
63
- is_test: Whether this is a test connection
64
- """
65
- self.cluster_arn = cluster_arn
66
- self.secret_arn = secret_arn
67
- self.database = database
68
- self.readonly = readonly
69
- if not is_test:
70
- self.data_client = boto3.client('rds-data', region_name=region)
71
-
72
- @property
73
- def readonly_query(self):
74
- """Get whether this connection is read-only.
75
-
76
- Returns:
77
- bool: True if the connection is read-only, False otherwise
78
- """
79
- return self.readonly
80
-
81
-
82
- class DBConnectionSingleton:
83
- """Manages a single DBConnection instance across the application.
84
-
85
- This singleton ensures that only one DBConnection is created and reused.
86
- """
87
-
88
- _instance = None
89
-
90
- def __init__(self, resource_arn, secret_arn, database, region, readonly, is_test=False):
91
- """Initialize a new DB connection singleton.
92
-
93
- Args:
94
- resource_arn: The ARN of the RDS resource
95
- secret_arn: The ARN of the secret containing credentials
96
- database: The name of the database to connect to
97
- region: The AWS region where the RDS instance is located
98
- readonly: Whether the connection should be read-only
99
- is_test: Whether this is a test connection
100
- """
101
- if not all([resource_arn, secret_arn, database, region]):
102
- raise ValueError(
103
- 'Missing required connection parameters. '
104
- 'Please provide resource_arn, secret_arn, database, and region.'
105
- )
106
- self._db_connection = DBConnection(
107
- resource_arn, secret_arn, database, region, readonly, is_test
108
- )
109
-
110
- @classmethod
111
- def initialize(cls, resource_arn, secret_arn, database, region, readonly, is_test=False):
112
- """Initialize the singleton instance if it doesn't exist.
113
-
114
- Args:
115
- resource_arn: The ARN of the RDS resource
116
- secret_arn: The ARN of the secret containing credentials
117
- database: The name of the database to connect to
118
- region: The AWS region where the RDS instance is located
119
- readonly: Whether the connection should be read-only
120
- is_test: Whether this is a test connection
121
- """
122
- if cls._instance is None:
123
- cls._instance = cls(resource_arn, secret_arn, database, region, readonly, is_test)
124
-
125
- @classmethod
126
- def get(cls):
127
- """Get the singleton instance.
128
-
129
- Returns:
130
- DBConnectionSingleton: The singleton instance
131
-
132
- Raises:
133
- RuntimeError: If the singleton has not been initialized
134
- """
135
- if cls._instance is None:
136
- raise RuntimeError('DBConnectionSingleton is not initialized.')
137
- return cls._instance
138
-
139
- @property
140
- def db_connection(self):
141
- """Get the database connection.
142
-
143
- Returns:
144
- DBConnection: The database connection instance
145
- """
146
- return self._db_connection
147
-
148
-
149
52
  def extract_cell(cell: dict):
150
53
  """Extracts the scalar or array value from a single cell."""
151
54
  if cell.get('isNull'):
@@ -209,6 +112,9 @@ async def run_query(
209
112
  if db_connection is None:
210
113
  db_connection = DBConnectionSingleton.get().db_connection
211
114
 
115
+ if db_connection is None:
116
+ raise AssertionError('db_connection should never be None')
117
+
212
118
  if db_connection.readonly_query:
213
119
  matches = detect_mutating_keywords(sql)
214
120
  if (bool)(matches):
@@ -232,20 +138,8 @@ async def run_query(
232
138
  try:
233
139
  logger.info(f'run_query: readonly:{db_connection.readonly_query}, SQL:{sql}')
234
140
 
235
- execute_params = {
236
- 'resourceArn': db_connection.cluster_arn,
237
- 'secretArn': db_connection.secret_arn,
238
- 'database': db_connection.database,
239
- 'sql': sql,
240
- 'includeResultMetadata': True,
241
- }
242
-
243
- if query_parameters:
244
- execute_params['parameters'] = query_parameters
245
-
246
- response = await asyncio.to_thread(
247
- db_connection.data_client.execute_statement, **execute_params
248
- )
141
+ # Execute the query using the abstract connection interface
142
+ response = await db_connection.execute_query(sql, query_parameters)
249
143
 
250
144
  logger.success('run_query successfully executed query:{}', sql)
251
145
  return parse_execute_response(response)
@@ -300,9 +194,16 @@ async def get_table_schema(
300
194
  ORDER BY
301
195
  ORDINAL_POSITION
302
196
  """
197
+ db_connection = DBConnectionSingleton.get().db_connection
198
+
199
+ if isinstance(db_connection, AsyncmyPoolConnection):
200
+ # Convert to positional parameters for asyncmy
201
+ sql = sql.replace(':database_name', '%s').replace(':table_name', '%s')
202
+
203
+ # Use consistent parameter order matching SQL placeholders
303
204
  params = [
304
- {'name': 'table_name', 'value': {'stringValue': table_name}},
305
205
  {'name': 'database_name', 'value': {'stringValue': database_name}},
206
+ {'name': 'table_name', 'value': {'stringValue': table_name}},
306
207
  ]
307
208
 
308
209
  return await run_query(sql=sql, ctx=ctx, query_parameters=params)
@@ -316,51 +217,87 @@ def main():
316
217
  parser = argparse.ArgumentParser(
317
218
  description='An AWS Labs Model Context Protocol (MCP) server for MySQL'
318
219
  )
319
- parser.add_argument('--resource_arn', required=True, help='ARN of the RDS cluster')
220
+
221
+ # Connection method 1: RDS Data API for Aurora MySQL
222
+ parser.add_argument('--resource_arn', help='ARN of the Aurora MySQL cluster')
223
+
224
+ # Connection method 2: asyncmy for RDS MySQL and RDS MariaDB
225
+ parser.add_argument('--hostname', help='RDS MySQL Database hostname')
226
+ parser.add_argument('--port', type=int, default=3306, help='Database port (default: 3306)')
227
+
320
228
  parser.add_argument(
321
229
  '--secret_arn',
322
230
  required=True,
323
231
  help='ARN of the Secrets Manager secret for database credentials',
324
232
  )
325
233
  parser.add_argument('--database', required=True, help='Database name')
326
- parser.add_argument(
327
- '--region', required=True, help='AWS region for RDS Data API (default: us-west-2)'
328
- )
234
+ parser.add_argument('--region', required=True, help='AWS region')
329
235
  parser.add_argument(
330
236
  '--readonly', required=True, help='Enforce NL to SQL to only allow readonly sql statement'
331
237
  )
332
238
  args = parser.parse_args()
333
239
 
334
- logger.info(
335
- 'MySQL MCP init with CLUSTER_ARN:{}, SECRET_ARN:{}, REGION:{}, DATABASE:{}, READONLY:{}',
336
- args.resource_arn,
337
- args.secret_arn,
338
- args.region,
339
- args.database,
340
- args.readonly,
341
- )
240
+ # Validate connection parameters
241
+ if not args.resource_arn and not args.hostname:
242
+ parser.error('Either --resource_arn or --hostname must be provided')
342
243
 
343
- try:
344
- DBConnectionSingleton.initialize(
345
- args.resource_arn, args.secret_arn, args.database, args.region, args.readonly
244
+ if args.resource_arn and args.hostname:
245
+ parser.error(
246
+ 'Cannot specify both --resource_arn and --hostname. Choose one connection method.'
346
247
  )
347
- except BotoCoreError:
348
- logger.exception('Failed to RDS API client object for MySQL. Exit the MCP server')
349
- sys.exit(1)
350
248
 
351
- # Test RDS API connection
352
- ctx = DummyCtx()
353
- response = asyncio.run(run_query('SELECT 1', ctx))
354
- if (
355
- isinstance(response, list)
356
- and len(response) == 1
357
- and isinstance(response[0], dict)
358
- and 'error' in response[0]
359
- ):
360
- logger.error('Failed to validate RDS API db connection to MySQL. Exit the MCP server')
361
- sys.exit(1)
249
+ if args.resource_arn:
250
+ logger.info(
251
+ f'MySQL MCP init with RDS Data API: CONNECTION_TARGET:{args.resource_arn}, SECRET_ARN:{args.secret_arn}, REGION:{args.region}, DATABASE:{args.database}, READONLY:{args.readonly}'
252
+ )
253
+ else:
254
+ logger.info(
255
+ f'MySQL/MariaDB MCP init with asyncmy: CONNECTION_TARGET:{args.hostname}, PORT:{args.port}, DATABASE:{args.database}, READONLY:{args.readonly}'
256
+ )
257
+
258
+ # Create the appropriate database connection based on the provided parameters
259
+ try:
260
+ if args.resource_arn:
261
+ # Use RDS Data API with singleton pattern
262
+ DBConnectionSingleton.initialize(
263
+ resource_arn=args.resource_arn,
264
+ secret_arn=args.secret_arn,
265
+ database=args.database,
266
+ region=args.region,
267
+ readonly=args.readonly.lower(),
268
+ )
362
269
 
363
- logger.success('Successfully validated RDS API db connection to MySQL')
270
+ # Test database connection
271
+ db_connection = DBConnectionSingleton.get().db_connection
272
+ ctx = DummyCtx()
273
+ response = asyncio.run(run_query('SELECT 1', ctx, db_connection))
274
+
275
+ if (
276
+ isinstance(response, list)
277
+ and len(response) == 1
278
+ and isinstance(response[0], dict)
279
+ and 'error' in response[0]
280
+ ):
281
+ logger.error(
282
+ 'Failed to validate database connection to MySQL. Exit the MCP server'
283
+ )
284
+ sys.exit(1)
285
+
286
+ logger.success('Successfully validated database connection to MySQL')
287
+ else:
288
+ # Use direct MySQL connection singleton with asyncmy
289
+ # note: asyncmy pools are tied to their event loop, so testing DB connection must run inside MCP's loop.
290
+ DBConnectionSingleton.initialize(
291
+ secret_arn=args.secret_arn,
292
+ database=args.database,
293
+ region=args.region,
294
+ readonly=args.readonly.lower(),
295
+ hostname=args.hostname,
296
+ port=args.port,
297
+ )
298
+ except Exception as e:
299
+ logger.exception(f'Failed to create MySQL connection: {str(e)}')
300
+ sys.exit(1)
364
301
 
365
302
  # Run server with appropriate transport
366
303
  logger.info('Starting MySQL MCP server')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: awslabs.mysql-mcp-server
3
- Version: 1.0.7
3
+ Version: 1.0.9
4
4
  Summary: An AWS Labs Model Context Protocol (MCP) server for mysql
5
5
  Project-URL: homepage, https://awslabs.github.io/mcp/
6
6
  Project-URL: docs, https://awslabs.github.io/mcp/servers/mysql-mcp-server/
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.11
21
21
  Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Programming Language :: Python :: 3.13
23
23
  Requires-Python: >=3.10
24
+ Requires-Dist: asyncmy>=0.2.10
24
25
  Requires-Dist: boto3>=1.38.14
25
26
  Requires-Dist: botocore>=1.38.14
26
27
  Requires-Dist: loguru>=0.7.0
@@ -58,6 +59,18 @@ An AWS Labs Model Context Protocol (MCP) server for Aurora MySQL
58
59
 
59
60
  Configure the MCP server in your MCP client configuration (e.g., for Amazon Q Developer CLI, edit `~/.aws/amazonq/mcp.json`):
60
61
 
62
+ ## Connection Methods
63
+
64
+ This MCP server supports two connection methods:
65
+
66
+ 1. **RDS Data API Connection** (using `--resource_arn`): Uses the AWS RDS Data API to connect to Aurora MySQL. This method requires that your Aurora cluster has the Data API enabled.
67
+
68
+ 2. **Direct MySQL Connection** (using `--hostname`): Uses asyncmy to connect directly to any MySQL database, including Aurora MySQL, RDS MySQL, RDS MariaDB, or self-hosted MySQL/MariaDB instances.
69
+
70
+ Choose the connection method that best fits your environment and requirements.
71
+
72
+ ### Option 1: Using RDS Data API Connection (for Aurora MySQL)
73
+
61
74
  ```json
62
75
  {
63
76
  "mcpServers": {
@@ -82,6 +95,36 @@ Configure the MCP server in your MCP client configuration (e.g., for Amazon Q De
82
95
  }
83
96
  }
84
97
  ```
98
+
99
+ ### Option 2: Using Direct MySQL Connection (for Aurora MySQL, RDS MySQL, and RDS MariaDB)
100
+
101
+ ```json
102
+ {
103
+ "mcpServers": {
104
+ "awslabs.mysql-mcp-server": {
105
+ "command": "uvx",
106
+ "args": [
107
+ "awslabs.mysql-mcp-server@latest",
108
+ "--hostname", "[your data]",
109
+ "--secret_arn", "[your data]",
110
+ "--database", "[your data]",
111
+ "--region", "[your data]",
112
+ "--readonly", "True"
113
+ ],
114
+ "env": {
115
+ "AWS_PROFILE": "your-aws-profile",
116
+ "AWS_REGION": "us-east-1",
117
+ "FASTMCP_LOG_LEVEL": "ERROR"
118
+ },
119
+ "disabled": false,
120
+ "autoApprove": []
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ Note: The `--port` parameter is optional and defaults to 3306 (the standard MySQL port). You only need to specify it if your MySQL instance uses a non-default port.
127
+
85
128
  ### Windows Installation
86
129
 
87
130
  For Windows users, the MCP server configuration format is slightly different:
@@ -0,0 +1,15 @@
1
+ awslabs/__init__.py,sha256=WuqxdDgUZylWNmVoPKiK7qGsTB_G4UmuXIrJ-VBwDew,731
2
+ awslabs/mysql_mcp_server/__init__.py,sha256=4YCpk9z418hrbzBvxvmfiO5CUS2jsotSGe91htH2hTM,670
3
+ awslabs/mysql_mcp_server/mutable_sql_detector.py,sha256=0apKy3GK8WOxKZshj7vKTRcLdSIM7GA6Ee_8-dr41UQ,4224
4
+ awslabs/mysql_mcp_server/server.py,sha256=whlRuMTLOGTh13VRXDq8-LFydRwebEJubBSpZxoHM6Q,10977
5
+ awslabs/mysql_mcp_server/connection/__init__.py,sha256=q63Wdy4cI71kRtNhxh8NHi_O_DWIIZiyv3Cg9usiBcY,841
6
+ awslabs/mysql_mcp_server/connection/abstract_db_connection.py,sha256=OpOxex4gxq8AY7Fp3rztFG6uJmGa_lx3gK9v_7WScqM,2077
7
+ awslabs/mysql_mcp_server/connection/asyncmy_pool_connection.py,sha256=Z6MdLiW7fNHy2EpXcCc7Q4tjupvpSpqKTwfqceluZ5A,11097
8
+ awslabs/mysql_mcp_server/connection/db_connection_singleton.py,sha256=RMJJyAX_IE3S8k_zmXGQ-dqvUUaEKELGAAYieFWjkP0,5371
9
+ awslabs/mysql_mcp_server/connection/rds_data_api_connection.py,sha256=OAQHuRXxXcjrPYY9bJPh-b8q2XvNrv1aXiCO5eDwQKc,3310
10
+ awslabs_mysql_mcp_server-1.0.9.dist-info/METADATA,sha256=E_MjjVxP2Du3xDEl3cyWMdnb9mW9Pwuz3LuYiYeRxlU,8059
11
+ awslabs_mysql_mcp_server-1.0.9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ awslabs_mysql_mcp_server-1.0.9.dist-info/entry_points.txt,sha256=dQAG1BpfKE6diUKtCqzxwvWfPxDWGIEa87YcEb0T4rc,82
13
+ awslabs_mysql_mcp_server-1.0.9.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
14
+ awslabs_mysql_mcp_server-1.0.9.dist-info/licenses/NOTICE,sha256=lpdr2_2JGgD-RqTEnqMJttKCZVvC88gX4rbk0m8UifU,92
15
+ awslabs_mysql_mcp_server-1.0.9.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- awslabs/__init__.py,sha256=WuqxdDgUZylWNmVoPKiK7qGsTB_G4UmuXIrJ-VBwDew,731
2
- awslabs/mysql_mcp_server/__init__.py,sha256=nt4WYQGNSIByqO4cQ12XSr8WxsWBhV9jfk4DEAT_L_A,670
3
- awslabs/mysql_mcp_server/mutable_sql_detector.py,sha256=0apKy3GK8WOxKZshj7vKTRcLdSIM7GA6Ee_8-dr41UQ,4224
4
- awslabs/mysql_mcp_server/server.py,sha256=3K50dr1-snYZwcMIB7OwRI3eF0ietpmCddfF02LBKrI,12502
5
- awslabs_mysql_mcp_server-1.0.7.dist-info/METADATA,sha256=3G8J-HaXeb-Sa34xMpDdM_QuKn0I0S4GkfqFvWXdFH4,6634
6
- awslabs_mysql_mcp_server-1.0.7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
- awslabs_mysql_mcp_server-1.0.7.dist-info/entry_points.txt,sha256=dQAG1BpfKE6diUKtCqzxwvWfPxDWGIEa87YcEb0T4rc,82
8
- awslabs_mysql_mcp_server-1.0.7.dist-info/licenses/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
9
- awslabs_mysql_mcp_server-1.0.7.dist-info/licenses/NOTICE,sha256=lpdr2_2JGgD-RqTEnqMJttKCZVvC88gX4rbk0m8UifU,92
10
- awslabs_mysql_mcp_server-1.0.7.dist-info/RECORD,,