mcp-dbutils 0.10.0__py3-none-any.whl → 0.10.3__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.
mcp_dbutils/base.py CHANGED
@@ -31,6 +31,16 @@ from .stats import ResourceStats
31
31
  # 获取包信息用于日志命名
32
32
  pkg_meta = metadata("mcp-dbutils")
33
33
 
34
+ # MCP日志级别常量
35
+ LOG_LEVEL_DEBUG = "debug" # 0
36
+ LOG_LEVEL_INFO = "info" # 1
37
+ LOG_LEVEL_NOTICE = "notice" # 2
38
+ LOG_LEVEL_WARNING = "warning" # 3
39
+ LOG_LEVEL_ERROR = "error" # 4
40
+ LOG_LEVEL_CRITICAL = "critical" # 5
41
+ LOG_LEVEL_ALERT = "alert" # 6
42
+ LOG_LEVEL_EMERGENCY = "emergency" # 7
43
+
34
44
  class ConnectionHandler(ABC):
35
45
  """Abstract base class defining common interface for connection handlers"""
36
46
 
@@ -45,8 +55,27 @@ class ConnectionHandler(ABC):
45
55
  self.config_path = config_path
46
56
  self.connection = connection
47
57
  self.debug = debug
58
+ # 创建stderr日志记录器用于本地调试
48
59
  self.log = create_logger(f"{pkg_meta['Name']}.handler.{connection}", debug)
49
60
  self.stats = ResourceStats()
61
+ self._session = None
62
+
63
+ def send_log(self, level: str, message: str):
64
+ """通过MCP发送日志消息和写入stderr
65
+
66
+ Args:
67
+ level: 日志级别 (debug/info/notice/warning/error/critical/alert/emergency)
68
+ message: 日志内容
69
+ """
70
+ # 本地stderr日志
71
+ self.log(level, message)
72
+
73
+ # MCP日志通知
74
+ if self._session and hasattr(self._session, 'request_context'):
75
+ self._session.request_context.session.send_log_message(
76
+ level=level,
77
+ data=message
78
+ )
50
79
 
51
80
  @property
52
81
  @abstractmethod
@@ -78,12 +107,12 @@ class ConnectionHandler(ABC):
78
107
  duration = (datetime.now() - start_time).total_seconds()
79
108
  self.stats.record_query_duration(sql, duration)
80
109
  self.stats.update_memory_usage(result)
81
- self.log("info", f"Query executed in {duration*1000:.2f}ms. Resource stats: {json.dumps(self.stats.to_dict())}")
110
+ self.send_log(LOG_LEVEL_INFO, f"Query executed in {duration*1000:.2f}ms. Resource stats: {json.dumps(self.stats.to_dict())}")
82
111
  return result
83
112
  except Exception as e:
84
113
  duration = (datetime.now() - start_time).total_seconds()
85
114
  self.stats.record_error(e.__class__.__name__)
86
- self.log("error", f"Query error after {duration*1000:.2f}ms - {str(e)}\nResource stats: {json.dumps(self.stats.to_dict())}")
115
+ self.send_log(LOG_LEVEL_ERROR, f"Query error after {duration*1000:.2f}ms - {str(e)}\nResource stats: {json.dumps(self.stats.to_dict())}")
87
116
  raise
88
117
 
89
118
  @abstractmethod
@@ -195,12 +224,12 @@ class ConnectionHandler(ABC):
195
224
  raise ValueError(f"Unknown tool: {tool_name}")
196
225
 
197
226
  self.stats.update_memory_usage(result)
198
- self.log("info", f"Resource stats: {json.dumps(self.stats.to_dict())}")
227
+ self.send_log(LOG_LEVEL_INFO, f"Resource stats: {json.dumps(self.stats.to_dict())}")
199
228
  return f"[{self.db_type}]\n{result}"
200
229
 
201
230
  except Exception as e:
202
231
  self.stats.record_error(e.__class__.__name__)
203
- self.log("error", f"Tool error - {str(e)}\nResource stats: {json.dumps(self.stats.to_dict())}")
232
+ self.send_log(LOG_LEVEL_ERROR, f"Tool error - {str(e)}\nResource stats: {json.dumps(self.stats.to_dict())}")
204
233
  raise
205
234
 
206
235
  class ConnectionServer:
@@ -222,19 +251,40 @@ class ConnectionServer:
222
251
  name=pkg_meta["Name"],
223
252
  version=pkg_meta["Version"]
224
253
  )
254
+ self._session = None
225
255
  self._setup_handlers()
226
256
  self._setup_prompts()
227
257
 
258
+ def send_log(self, level: str, message: str):
259
+ """通过MCP发送日志消息和写入stderr
260
+
261
+ Args:
262
+ level: 日志级别 (debug/info/notice/warning/error/critical/alert/emergency)
263
+ message: 日志内容
264
+ """
265
+ # 本地stderr日志
266
+ self.logger(level, message)
267
+
268
+ # MCP日志通知
269
+ if hasattr(self.server, 'session') and self.server.session:
270
+ try:
271
+ self.server.session.send_log_message(
272
+ level=level,
273
+ data=message
274
+ )
275
+ except Exception as e:
276
+ self.logger("error", f"Failed to send MCP log message: {str(e)}")
277
+
228
278
  def _setup_prompts(self):
229
279
  """Setup prompts handlers"""
230
280
  @self.server.list_prompts()
231
281
  async def handle_list_prompts() -> list[types.Prompt]:
232
282
  """Handle prompts/list request"""
233
283
  try:
234
- self.logger("debug", "Handling list_prompts request")
284
+ self.send_log(LOG_LEVEL_DEBUG, "Handling list_prompts request")
235
285
  return []
236
286
  except Exception as e:
237
- self.logger("error", f"Error in list_prompts: {str(e)}")
287
+ self.send_log(LOG_LEVEL_ERROR, f"Error in list_prompts: {str(e)}")
238
288
  raise
239
289
 
240
290
  @asynccontextmanager
@@ -266,19 +316,26 @@ class ConnectionServer:
266
316
  raise ConfigurationError("Database configuration must include 'type' field")
267
317
 
268
318
  db_type = db_config['type']
269
- self.logger("debug", f"Creating handler for database type: {db_type}")
319
+ self.send_log(LOG_LEVEL_DEBUG, f"Creating handler for database type: {db_type}")
270
320
  if db_type == 'sqlite':
271
321
  from .sqlite.handler import SQLiteHandler
272
322
  handler = SQLiteHandler(self.config_path, connection, self.debug)
273
323
  elif db_type == 'postgres':
274
324
  from .postgres.handler import PostgreSQLHandler
275
325
  handler = PostgreSQLHandler(self.config_path, connection, self.debug)
326
+ elif db_type == 'mysql':
327
+ from .mysql.handler import MySQLHandler
328
+ handler = MySQLHandler(self.config_path, connection, self.debug)
276
329
  else:
277
330
  raise ConfigurationError(f"Unsupported database type: {db_type}")
278
331
 
332
+ # Set session for MCP logging
333
+ if hasattr(self.server, 'session'):
334
+ handler._session = self.server.session
335
+
279
336
  handler.stats.record_connection_start()
280
- self.logger("debug", f"Handler created successfully for {connection}")
281
- self.logger("info", f"Resource stats: {json.dumps(handler.stats.to_dict())}")
337
+ self.send_log(LOG_LEVEL_DEBUG, f"Handler created successfully for {connection}")
338
+ self.send_log(LOG_LEVEL_INFO, f"Resource stats: {json.dumps(handler.stats.to_dict())}")
282
339
  yield handler
283
340
  except yaml.YAMLError as e:
284
341
  raise ConfigurationError(f"Invalid YAML configuration: {str(e)}")
@@ -286,9 +343,9 @@ class ConnectionServer:
286
343
  raise ConfigurationError(f"Failed to import handler for {db_type}: {str(e)}")
287
344
  finally:
288
345
  if handler:
289
- self.logger("debug", f"Cleaning up handler for {connection}")
346
+ self.send_log(LOG_LEVEL_DEBUG, f"Cleaning up handler for {connection}")
290
347
  handler.stats.record_connection_end()
291
- self.logger("info", f"Final resource stats: {json.dumps(handler.stats.to_dict())}")
348
+ self.send_log(LOG_LEVEL_INFO, f"Final resource stats: {json.dumps(handler.stats.to_dict())}")
292
349
  await handler.cleanup()
293
350
 
294
351
  def _setup_handlers(self):
@@ -567,7 +624,7 @@ class ConnectionServer:
567
624
  await handler.execute_query(sql)
568
625
  except Exception as e:
569
626
  # If query fails, we still provide the execution plan
570
- self.logger("error", f"Query execution failed during analysis: {str(e)}")
627
+ self.send_log(LOG_LEVEL_ERROR, f"Query execution failed during analysis: {str(e)}")
571
628
  duration = (datetime.now() - start_time).total_seconds()
572
629
 
573
630
  # Combine analysis results
mcp_dbutils/config.py CHANGED
@@ -8,7 +8,7 @@ from typing import Optional, Dict, Any, Literal
8
8
  from pathlib import Path
9
9
 
10
10
  # Supported connection types
11
- ConnectionType = Literal['sqlite', 'postgres']
11
+ ConnectionType = Literal['sqlite', 'postgres', 'mysql']
12
12
 
13
13
  class ConnectionConfig(ABC):
14
14
  """Base class for connection configuration"""
@@ -48,7 +48,7 @@ class ConnectionConfig(ABC):
48
48
  if 'type' not in db_config:
49
49
  raise ValueError(f"Database configuration {conn_name} missing required 'type' field")
50
50
  db_type = db_config['type']
51
- if db_type not in ('sqlite', 'postgres'):
51
+ if db_type not in ('sqlite', 'postgres', 'mysql'):
52
52
  raise ValueError(f"Invalid type value in database configuration {conn_name}: {db_type}")
53
53
 
54
54
  return connections
mcp_dbutils/log.py CHANGED
@@ -2,20 +2,19 @@
2
2
 
3
3
  import sys
4
4
  from datetime import datetime
5
- from typing import Callable, Optional
5
+ from typing import Callable
6
6
 
7
7
  def create_logger(name: str, is_debug: bool = False) -> Callable:
8
- """创建日志函数
8
+ """创建stderr日志函数,用于本地调试
9
9
  Args:
10
10
  name: 服务名称
11
11
  is_debug: 是否输出debug级别日志
12
12
  """
13
- def log(level: str, message: str, notify: Optional[Callable] = None):
14
- """输出日志
13
+ def log(level: str, message: str):
14
+ """输出日志到stderr
15
15
  Args:
16
16
  level: 日志级别 (debug/info/warning/error)
17
17
  message: 日志内容
18
- notify: MCP通知函数(可选)
19
18
  """
20
19
  if level == "debug" and not is_debug:
21
20
  return
@@ -23,11 +22,7 @@ def create_logger(name: str, is_debug: bool = False) -> Callable:
23
22
  timestamp = datetime.utcnow().isoformat(timespec='milliseconds') + 'Z'
24
23
  log_message = f"{timestamp} [{name}] [{level}] {message}"
25
24
 
26
- # 始终输出到stderr
25
+ # 输出到stderr
27
26
  print(log_message, file=sys.stderr, flush=True)
28
27
 
29
- # 如果提供了notify函数,同时发送MCP通知
30
- if notify:
31
- notify(level=level, data=message)
32
-
33
28
  return log
@@ -0,0 +1,6 @@
1
+ """MySQL module"""
2
+
3
+ from .handler import MySQLHandler
4
+ from .config import MySQLConfig
5
+
6
+ __all__ = ['MySQLHandler', 'MySQLConfig']
@@ -0,0 +1,219 @@
1
+ """MySQL configuration module"""
2
+ from dataclasses import dataclass
3
+ from typing import Optional, Dict, Any, Literal
4
+ from urllib.parse import urlparse, parse_qs
5
+ from ..config import ConnectionConfig
6
+
7
+ @dataclass
8
+ class SSLConfig:
9
+ """SSL configuration for MySQL connection"""
10
+ mode: Literal['disabled', 'required', 'verify_ca', 'verify_identity'] = 'disabled'
11
+ ca: Optional[str] = None
12
+ cert: Optional[str] = None
13
+ key: Optional[str] = None
14
+
15
+ def parse_url(url: str) -> Dict[str, Any]:
16
+ """Parse MySQL URL into connection parameters
17
+
18
+ Args:
19
+ url: URL (e.g. mysql://host:port/dbname?ssl-mode=verify_identity)
20
+
21
+ Returns:
22
+ Dictionary of connection parameters including SSL settings
23
+ """
24
+ if not url.startswith('mysql://'):
25
+ raise ValueError("Invalid MySQL URL format")
26
+
27
+ if '@' in url:
28
+ raise ValueError("URL should not contain credentials. Please provide username and password separately.")
29
+
30
+ # Parse URL and query parameters
31
+ parsed = urlparse(url)
32
+ query_params = parse_qs(parsed.query)
33
+
34
+ params = {
35
+ 'host': parsed.hostname or 'localhost',
36
+ 'port': str(parsed.port or 3306),
37
+ 'database': parsed.path.lstrip('/') if parsed.path else '',
38
+ 'charset': query_params.get('charset', ['utf8mb4'])[0]
39
+ }
40
+
41
+ if not params['database']:
42
+ raise ValueError("MySQL database name must be specified in URL")
43
+
44
+ # Parse SSL parameters if present
45
+ ssl_params = {}
46
+ if 'ssl-mode' in query_params:
47
+ mode = query_params['ssl-mode'][0]
48
+ if mode not in ['disabled', 'required', 'verify_ca', 'verify_identity']:
49
+ raise ValueError(f"Invalid ssl-mode: {mode}")
50
+ ssl_params['mode'] = mode
51
+
52
+ if 'ssl-ca' in query_params:
53
+ ssl_params['ca'] = query_params['ssl-ca'][0]
54
+ if 'ssl-cert' in query_params:
55
+ ssl_params['cert'] = query_params['ssl-cert'][0]
56
+ if 'ssl-key' in query_params:
57
+ ssl_params['key'] = query_params['ssl-key'][0]
58
+
59
+ if ssl_params:
60
+ params['ssl'] = SSLConfig(**ssl_params)
61
+
62
+ return params
63
+
64
+ @dataclass
65
+ class MySQLConfig(ConnectionConfig):
66
+ database: str
67
+ user: str
68
+ password: str
69
+ host: str = 'localhost'
70
+ port: str = '3306'
71
+ charset: str = 'utf8mb4'
72
+ local_host: Optional[str] = None
73
+ type: Literal['mysql'] = 'mysql'
74
+ url: Optional[str] = None
75
+ ssl: Optional[SSLConfig] = None
76
+
77
+ @classmethod
78
+ def from_yaml(cls, yaml_path: str, db_name: str, local_host: Optional[str] = None) -> 'MySQLConfig':
79
+ """Create configuration from YAML file
80
+
81
+ Args:
82
+ yaml_path: Path to YAML configuration file
83
+ db_name: Connection configuration name to use
84
+ local_host: Optional local host address
85
+ """
86
+ configs = cls.load_yaml_config(yaml_path)
87
+ if not db_name:
88
+ raise ValueError("Connection name must be specified")
89
+ if db_name not in configs:
90
+ available_dbs = list(configs.keys())
91
+ raise ValueError(f"Connection configuration not found: {db_name}. Available configurations: {available_dbs}")
92
+
93
+ db_config = configs[db_name]
94
+ if 'type' not in db_config:
95
+ raise ValueError("Connection configuration must include 'type' field")
96
+ if db_config['type'] != 'mysql':
97
+ raise ValueError(f"Configuration is not MySQL type: {db_config['type']}")
98
+
99
+ # Check required credentials
100
+ if not db_config.get('user'):
101
+ raise ValueError("User must be specified in connection configuration")
102
+ if not db_config.get('password'):
103
+ raise ValueError("Password must be specified in connection configuration")
104
+
105
+ # Get connection parameters
106
+ if 'url' in db_config:
107
+ # Parse URL for connection parameters
108
+ params = parse_url(db_config['url'])
109
+ config = cls(
110
+ database=params['database'],
111
+ user=db_config['user'],
112
+ password=db_config['password'],
113
+ host=params['host'],
114
+ port=params['port'],
115
+ charset=params['charset'],
116
+ local_host=local_host,
117
+ url=db_config['url'],
118
+ ssl=params.get('ssl')
119
+ )
120
+ else:
121
+ if not db_config.get('database'):
122
+ raise ValueError("MySQL database name must be specified in configuration")
123
+ if not db_config.get('host'):
124
+ raise ValueError("Host must be specified in connection configuration")
125
+ if not db_config.get('port'):
126
+ raise ValueError("Port must be specified in connection configuration")
127
+
128
+ # Parse SSL configuration if present
129
+ ssl_config = None
130
+ if 'ssl' in db_config:
131
+ ssl_params = db_config['ssl']
132
+ if not isinstance(ssl_params, dict):
133
+ raise ValueError("SSL configuration must be a dictionary")
134
+
135
+ if ssl_params.get('mode') not in [None, 'disabled', 'required', 'verify_ca', 'verify_identity']:
136
+ raise ValueError(f"Invalid ssl-mode: {ssl_params.get('mode')}")
137
+
138
+ ssl_config = SSLConfig(
139
+ mode=ssl_params.get('mode', 'disabled'),
140
+ ca=ssl_params.get('ca'),
141
+ cert=ssl_params.get('cert'),
142
+ key=ssl_params.get('key')
143
+ )
144
+
145
+ config = cls(
146
+ database=db_config['database'],
147
+ user=db_config['user'],
148
+ password=db_config['password'],
149
+ host=db_config['host'],
150
+ port=str(db_config['port']),
151
+ charset=db_config.get('charset', 'utf8mb4'),
152
+ local_host=local_host,
153
+ ssl=ssl_config
154
+ )
155
+ config.debug = cls.get_debug_mode()
156
+ return config
157
+
158
+ @classmethod
159
+ def from_url(cls, url: str, user: str, password: str,
160
+ local_host: Optional[str] = None) -> 'MySQLConfig':
161
+ """Create configuration from URL and credentials
162
+
163
+ Args:
164
+ url: URL (mysql://host:port/dbname)
165
+ user: Username for connection
166
+ password: Password for connection
167
+ local_host: Optional local host address
168
+
169
+ Raises:
170
+ ValueError: If URL format is invalid or required parameters are missing
171
+ """
172
+ params = parse_url(url)
173
+
174
+ config = cls(
175
+ database=params['database'],
176
+ user=user,
177
+ password=password,
178
+ host=params['host'],
179
+ port=params['port'],
180
+ charset=params['charset'],
181
+ local_host=local_host,
182
+ url=url,
183
+ ssl=params.get('ssl')
184
+ )
185
+ config.debug = cls.get_debug_mode()
186
+ return config
187
+
188
+ def get_connection_params(self) -> Dict[str, Any]:
189
+ """Get MySQL connection parameters"""
190
+ params = {
191
+ 'database': self.database,
192
+ 'user': self.user,
193
+ 'password': self.password,
194
+ 'host': self.local_host or self.host,
195
+ 'port': int(self.port),
196
+ 'charset': self.charset,
197
+ 'use_unicode': True
198
+ }
199
+
200
+ # Add SSL parameters if configured
201
+ if self.ssl:
202
+ params['ssl_mode'] = self.ssl.mode
203
+ if self.ssl.ca:
204
+ params['ssl_ca'] = self.ssl.ca
205
+ if self.ssl.cert:
206
+ params['ssl_cert'] = self.ssl.cert
207
+ if self.ssl.key:
208
+ params['ssl_key'] = self.ssl.key
209
+
210
+ return {k: v for k, v in params.items() if v is not None}
211
+
212
+ def get_masked_connection_info(self) -> Dict[str, Any]:
213
+ """Return masked connection information for logging"""
214
+ return {
215
+ 'database': self.database,
216
+ 'host': self.local_host or self.host,
217
+ 'port': self.port,
218
+ 'charset': self.charset
219
+ }