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 +69 -12
- mcp_dbutils/config.py +2 -2
- mcp_dbutils/log.py +5 -10
- mcp_dbutils/mysql/__init__.py +6 -0
- mcp_dbutils/mysql/config.py +219 -0
- mcp_dbutils/mysql/handler.py +467 -0
- mcp_dbutils/mysql/server.py +216 -0
- mcp_dbutils-0.10.3.dist-info/METADATA +487 -0
- {mcp_dbutils-0.10.0.dist-info → mcp_dbutils-0.10.3.dist-info}/RECORD +12 -8
- mcp_dbutils-0.10.0.dist-info/METADATA +0 -227
- {mcp_dbutils-0.10.0.dist-info → mcp_dbutils-0.10.3.dist-info}/WHEEL +0 -0
- {mcp_dbutils-0.10.0.dist-info → mcp_dbutils-0.10.3.dist-info}/entry_points.txt +0 -0
- {mcp_dbutils-0.10.0.dist-info → mcp_dbutils-0.10.3.dist-info}/licenses/LICENSE +0 -0
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.
|
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.
|
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.
|
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.
|
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.
|
284
|
+
self.send_log(LOG_LEVEL_DEBUG, "Handling list_prompts request")
|
235
285
|
return []
|
236
286
|
except Exception as e:
|
237
|
-
self.
|
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.
|
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.
|
281
|
-
self.
|
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.
|
346
|
+
self.send_log(LOG_LEVEL_DEBUG, f"Cleaning up handler for {connection}")
|
290
347
|
handler.stats.record_connection_end()
|
291
|
-
self.
|
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.
|
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
|
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
|
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
|
-
#
|
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,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
|
+
}
|