mcp-dbutils 0.9.0__py3-none-any.whl → 0.10.2__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/postgres/config.py +80 -19
- {mcp_dbutils-0.9.0.dist-info → mcp_dbutils-0.10.2.dist-info}/METADATA +137 -42
- mcp_dbutils-0.10.2.dist-info/RECORD +22 -0
- mcp_dbutils-0.9.0.dist-info/RECORD +0 -18
- {mcp_dbutils-0.9.0.dist-info → mcp_dbutils-0.10.2.dist-info}/WHEEL +0 -0
- {mcp_dbutils-0.9.0.dist-info → mcp_dbutils-0.10.2.dist-info}/entry_points.txt +0 -0
- {mcp_dbutils-0.9.0.dist-info → mcp_dbutils-0.10.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,467 @@
|
|
1
|
+
"""MySQL connection handler implementation"""
|
2
|
+
|
3
|
+
import mysql.connector
|
4
|
+
from mysql.connector.pooling import MySQLConnectionPool
|
5
|
+
import mcp.types as types
|
6
|
+
|
7
|
+
from ..base import ConnectionHandler, ConnectionHandlerError
|
8
|
+
from .config import MySQLConfig
|
9
|
+
|
10
|
+
class MySQLHandler(ConnectionHandler):
|
11
|
+
@property
|
12
|
+
def db_type(self) -> str:
|
13
|
+
return 'mysql'
|
14
|
+
|
15
|
+
def __init__(self, config_path: str, connection: str, debug: bool = False):
|
16
|
+
"""Initialize MySQL handler
|
17
|
+
|
18
|
+
Args:
|
19
|
+
config_path: Path to configuration file
|
20
|
+
connection: Database connection name
|
21
|
+
debug: Enable debug mode
|
22
|
+
"""
|
23
|
+
super().__init__(config_path, connection, debug)
|
24
|
+
self.config = MySQLConfig.from_yaml(config_path, connection)
|
25
|
+
|
26
|
+
# No connection pool creation during initialization
|
27
|
+
masked_params = self.config.get_masked_connection_info()
|
28
|
+
self.log("debug", f"Configuring connection with parameters: {masked_params}")
|
29
|
+
self.pool = None
|
30
|
+
|
31
|
+
async def get_tables(self) -> list[types.Resource]:
|
32
|
+
"""Get all table resources"""
|
33
|
+
try:
|
34
|
+
conn_params = self.config.get_connection_params()
|
35
|
+
conn = mysql.connector.connect(**conn_params)
|
36
|
+
with conn.cursor(dictionary=True) as cur:
|
37
|
+
cur.execute("""
|
38
|
+
SELECT
|
39
|
+
TABLE_NAME as table_name,
|
40
|
+
TABLE_COMMENT as description
|
41
|
+
FROM information_schema.tables
|
42
|
+
WHERE TABLE_SCHEMA = %s
|
43
|
+
""", (self.config.database,))
|
44
|
+
tables = cur.fetchall()
|
45
|
+
return [
|
46
|
+
types.Resource(
|
47
|
+
uri=f"mysql://{self.connection}/{table['table_name']}/schema",
|
48
|
+
name=f"{table['table_name']} schema",
|
49
|
+
description=table['description'] if table['description'] else None,
|
50
|
+
mimeType="application/json"
|
51
|
+
) for table in tables
|
52
|
+
]
|
53
|
+
except mysql.connector.Error as e:
|
54
|
+
error_msg = f"Failed to get tables: {str(e)}"
|
55
|
+
self.stats.record_error(e.__class__.__name__)
|
56
|
+
raise ConnectionHandlerError(error_msg)
|
57
|
+
finally:
|
58
|
+
if conn:
|
59
|
+
conn.close()
|
60
|
+
|
61
|
+
async def get_schema(self, table_name: str) -> str:
|
62
|
+
"""Get table schema information"""
|
63
|
+
try:
|
64
|
+
conn_params = self.config.get_connection_params()
|
65
|
+
conn = mysql.connector.connect(**conn_params)
|
66
|
+
with conn.cursor(dictionary=True) as cur:
|
67
|
+
# Get column information
|
68
|
+
cur.execute("""
|
69
|
+
SELECT
|
70
|
+
COLUMN_NAME as column_name,
|
71
|
+
DATA_TYPE as data_type,
|
72
|
+
IS_NULLABLE as is_nullable,
|
73
|
+
COLUMN_COMMENT as description
|
74
|
+
FROM information_schema.columns
|
75
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
76
|
+
ORDER BY ORDINAL_POSITION
|
77
|
+
""", (self.config.database, table_name))
|
78
|
+
columns = cur.fetchall()
|
79
|
+
|
80
|
+
# Get constraint information
|
81
|
+
cur.execute("""
|
82
|
+
SELECT
|
83
|
+
CONSTRAINT_NAME as constraint_name,
|
84
|
+
CONSTRAINT_TYPE as constraint_type
|
85
|
+
FROM information_schema.table_constraints
|
86
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
87
|
+
""", (self.config.database, table_name))
|
88
|
+
constraints = cur.fetchall()
|
89
|
+
|
90
|
+
return str({
|
91
|
+
'columns': [{
|
92
|
+
'name': col['column_name'],
|
93
|
+
'type': col['data_type'],
|
94
|
+
'nullable': col['is_nullable'] == 'YES',
|
95
|
+
'description': col['description']
|
96
|
+
} for col in columns],
|
97
|
+
'constraints': [{
|
98
|
+
'name': con['constraint_name'],
|
99
|
+
'type': con['constraint_type']
|
100
|
+
} for con in constraints]
|
101
|
+
})
|
102
|
+
except mysql.connector.Error as e:
|
103
|
+
error_msg = f"Failed to read table schema: {str(e)}"
|
104
|
+
self.stats.record_error(e.__class__.__name__)
|
105
|
+
raise ConnectionHandlerError(error_msg)
|
106
|
+
finally:
|
107
|
+
if conn:
|
108
|
+
conn.close()
|
109
|
+
|
110
|
+
async def _execute_query(self, sql: str) -> str:
|
111
|
+
"""Execute SQL query"""
|
112
|
+
conn = None
|
113
|
+
try:
|
114
|
+
conn_params = self.config.get_connection_params()
|
115
|
+
conn = mysql.connector.connect(**conn_params)
|
116
|
+
self.log("debug", f"Executing query: {sql}")
|
117
|
+
|
118
|
+
with conn.cursor(dictionary=True) as cur:
|
119
|
+
# Start read-only transaction
|
120
|
+
cur.execute("SET TRANSACTION READ ONLY")
|
121
|
+
try:
|
122
|
+
cur.execute(sql)
|
123
|
+
results = cur.fetchall()
|
124
|
+
columns = [desc[0] for desc in cur.description]
|
125
|
+
|
126
|
+
result_text = str({
|
127
|
+
'type': self.db_type,
|
128
|
+
'columns': columns,
|
129
|
+
'rows': results,
|
130
|
+
'row_count': len(results)
|
131
|
+
})
|
132
|
+
|
133
|
+
self.log("debug", f"Query completed, returned {len(results)} rows")
|
134
|
+
return result_text
|
135
|
+
finally:
|
136
|
+
cur.execute("ROLLBACK")
|
137
|
+
except mysql.connector.Error as e:
|
138
|
+
error_msg = f"[{self.db_type}] Query execution failed: {str(e)}"
|
139
|
+
raise ConnectionHandlerError(error_msg)
|
140
|
+
finally:
|
141
|
+
if conn:
|
142
|
+
conn.close()
|
143
|
+
|
144
|
+
async def get_table_description(self, table_name: str) -> str:
|
145
|
+
"""Get detailed table description"""
|
146
|
+
conn = None
|
147
|
+
try:
|
148
|
+
conn_params = self.config.get_connection_params()
|
149
|
+
conn = mysql.connector.connect(**conn_params)
|
150
|
+
with conn.cursor(dictionary=True) as cur:
|
151
|
+
# Get table information and comment
|
152
|
+
cur.execute("""
|
153
|
+
SELECT
|
154
|
+
TABLE_COMMENT as table_comment
|
155
|
+
FROM information_schema.tables
|
156
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
157
|
+
""", (self.config.database, table_name))
|
158
|
+
table_info = cur.fetchone()
|
159
|
+
table_comment = table_info['table_comment'] if table_info else None
|
160
|
+
|
161
|
+
# Get column information
|
162
|
+
cur.execute("""
|
163
|
+
SELECT
|
164
|
+
COLUMN_NAME as column_name,
|
165
|
+
DATA_TYPE as data_type,
|
166
|
+
COLUMN_DEFAULT as column_default,
|
167
|
+
IS_NULLABLE as is_nullable,
|
168
|
+
CHARACTER_MAXIMUM_LENGTH as character_maximum_length,
|
169
|
+
NUMERIC_PRECISION as numeric_precision,
|
170
|
+
NUMERIC_SCALE as numeric_scale,
|
171
|
+
COLUMN_COMMENT as column_comment
|
172
|
+
FROM information_schema.columns
|
173
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
174
|
+
ORDER BY ORDINAL_POSITION
|
175
|
+
""", (self.config.database, table_name))
|
176
|
+
columns = cur.fetchall()
|
177
|
+
|
178
|
+
# Format output
|
179
|
+
description = [
|
180
|
+
f"Table: {table_name}",
|
181
|
+
f"Comment: {table_comment or 'No comment'}\n",
|
182
|
+
"Columns:"
|
183
|
+
]
|
184
|
+
|
185
|
+
for col in columns:
|
186
|
+
col_info = [
|
187
|
+
f" {col['column_name']} ({col['data_type']})",
|
188
|
+
f" Nullable: {col['is_nullable']}",
|
189
|
+
f" Default: {col['column_default'] or 'None'}"
|
190
|
+
]
|
191
|
+
|
192
|
+
if col['character_maximum_length']:
|
193
|
+
col_info.append(f" Max Length: {col['character_maximum_length']}")
|
194
|
+
if col['numeric_precision']:
|
195
|
+
col_info.append(f" Precision: {col['numeric_precision']}")
|
196
|
+
if col['numeric_scale']:
|
197
|
+
col_info.append(f" Scale: {col['numeric_scale']}")
|
198
|
+
if col['column_comment']:
|
199
|
+
col_info.append(f" Comment: {col['column_comment']}")
|
200
|
+
|
201
|
+
description.extend(col_info)
|
202
|
+
description.append("") # Empty line between columns
|
203
|
+
|
204
|
+
return "\n".join(description)
|
205
|
+
|
206
|
+
except mysql.connector.Error as e:
|
207
|
+
error_msg = f"Failed to get table description: {str(e)}"
|
208
|
+
self.stats.record_error(e.__class__.__name__)
|
209
|
+
raise ConnectionHandlerError(error_msg)
|
210
|
+
finally:
|
211
|
+
if conn:
|
212
|
+
conn.close()
|
213
|
+
|
214
|
+
async def get_table_ddl(self, table_name: str) -> str:
|
215
|
+
"""Get DDL statement for creating table"""
|
216
|
+
conn = None
|
217
|
+
try:
|
218
|
+
conn_params = self.config.get_connection_params()
|
219
|
+
conn = mysql.connector.connect(**conn_params)
|
220
|
+
with conn.cursor(dictionary=True) as cur:
|
221
|
+
# MySQL provides a SHOW CREATE TABLE statement
|
222
|
+
cur.execute(f"SHOW CREATE TABLE {table_name}")
|
223
|
+
result = cur.fetchone()
|
224
|
+
if result:
|
225
|
+
return result['Create Table']
|
226
|
+
return f"Failed to get DDL for table {table_name}"
|
227
|
+
|
228
|
+
except mysql.connector.Error as e:
|
229
|
+
error_msg = f"Failed to get table DDL: {str(e)}"
|
230
|
+
self.stats.record_error(e.__class__.__name__)
|
231
|
+
raise ConnectionHandlerError(error_msg)
|
232
|
+
finally:
|
233
|
+
if conn:
|
234
|
+
conn.close()
|
235
|
+
|
236
|
+
async def get_table_indexes(self, table_name: str) -> str:
|
237
|
+
"""Get index information for table"""
|
238
|
+
conn = None
|
239
|
+
try:
|
240
|
+
conn_params = self.config.get_connection_params()
|
241
|
+
conn = mysql.connector.connect(**conn_params)
|
242
|
+
with conn.cursor(dictionary=True) as cur:
|
243
|
+
# Get index information
|
244
|
+
cur.execute("""
|
245
|
+
SELECT
|
246
|
+
INDEX_NAME as index_name,
|
247
|
+
COLUMN_NAME as column_name,
|
248
|
+
NON_UNIQUE as non_unique,
|
249
|
+
INDEX_TYPE as index_type,
|
250
|
+
INDEX_COMMENT as index_comment
|
251
|
+
FROM information_schema.statistics
|
252
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
253
|
+
ORDER BY INDEX_NAME, SEQ_IN_INDEX
|
254
|
+
""", (self.config.database, table_name))
|
255
|
+
indexes = cur.fetchall()
|
256
|
+
|
257
|
+
if not indexes:
|
258
|
+
return f"No indexes found on table {table_name}"
|
259
|
+
|
260
|
+
# Group by index name
|
261
|
+
current_index = None
|
262
|
+
formatted_indexes = []
|
263
|
+
index_info = []
|
264
|
+
|
265
|
+
for idx in indexes:
|
266
|
+
if current_index != idx['index_name']:
|
267
|
+
if index_info:
|
268
|
+
formatted_indexes.extend(index_info)
|
269
|
+
formatted_indexes.append("")
|
270
|
+
current_index = idx['index_name']
|
271
|
+
index_info = [
|
272
|
+
f"Index: {idx['index_name']}",
|
273
|
+
f"Type: {'UNIQUE' if not idx['non_unique'] else 'INDEX'}",
|
274
|
+
f"Method: {idx['index_type']}",
|
275
|
+
"Columns:",
|
276
|
+
]
|
277
|
+
if idx['index_comment']:
|
278
|
+
index_info.insert(1, f"Comment: {idx['index_comment']}")
|
279
|
+
|
280
|
+
index_info.append(f" - {idx['column_name']}")
|
281
|
+
|
282
|
+
if index_info:
|
283
|
+
formatted_indexes.extend(index_info)
|
284
|
+
|
285
|
+
return "\n".join(formatted_indexes)
|
286
|
+
|
287
|
+
except mysql.connector.Error as e:
|
288
|
+
error_msg = f"Failed to get index information: {str(e)}"
|
289
|
+
self.stats.record_error(e.__class__.__name__)
|
290
|
+
raise ConnectionHandlerError(error_msg)
|
291
|
+
finally:
|
292
|
+
if conn:
|
293
|
+
conn.close()
|
294
|
+
|
295
|
+
async def get_table_stats(self, table_name: str) -> str:
|
296
|
+
"""Get table statistics information"""
|
297
|
+
conn = None
|
298
|
+
try:
|
299
|
+
conn_params = self.config.get_connection_params()
|
300
|
+
conn = mysql.connector.connect(**conn_params)
|
301
|
+
with conn.cursor(dictionary=True) as cur:
|
302
|
+
# Get table statistics
|
303
|
+
cur.execute("""
|
304
|
+
SELECT
|
305
|
+
TABLE_ROWS as table_rows,
|
306
|
+
AVG_ROW_LENGTH as avg_row_length,
|
307
|
+
DATA_LENGTH as data_length,
|
308
|
+
INDEX_LENGTH as index_length,
|
309
|
+
DATA_FREE as data_free
|
310
|
+
FROM information_schema.tables
|
311
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
312
|
+
""", (self.config.database, table_name))
|
313
|
+
stats = cur.fetchone()
|
314
|
+
|
315
|
+
if not stats:
|
316
|
+
return f"No statistics found for table {table_name}"
|
317
|
+
|
318
|
+
# Get column statistics
|
319
|
+
cur.execute("""
|
320
|
+
SELECT
|
321
|
+
COLUMN_NAME as column_name,
|
322
|
+
DATA_TYPE as data_type,
|
323
|
+
COLUMN_TYPE as column_type
|
324
|
+
FROM information_schema.columns
|
325
|
+
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
|
326
|
+
ORDER BY ORDINAL_POSITION
|
327
|
+
""", (self.config.database, table_name))
|
328
|
+
columns = cur.fetchall()
|
329
|
+
|
330
|
+
# Format the output
|
331
|
+
output = [
|
332
|
+
f"Table Statistics for {table_name}:",
|
333
|
+
f" Estimated Row Count: {stats['table_rows']:,}",
|
334
|
+
f" Average Row Length: {stats['avg_row_length']} bytes",
|
335
|
+
f" Data Length: {stats['data_length']:,} bytes",
|
336
|
+
f" Index Length: {stats['index_length']:,} bytes",
|
337
|
+
f" Data Free: {stats['data_free']:,} bytes\n",
|
338
|
+
"Column Information:"
|
339
|
+
]
|
340
|
+
|
341
|
+
for col in columns:
|
342
|
+
col_info = [
|
343
|
+
f" {col['column_name']}:",
|
344
|
+
f" Data Type: {col['data_type']}",
|
345
|
+
f" Column Type: {col['column_type']}"
|
346
|
+
]
|
347
|
+
output.extend(col_info)
|
348
|
+
output.append("") # Empty line between columns
|
349
|
+
|
350
|
+
return "\n".join(output)
|
351
|
+
|
352
|
+
except mysql.connector.Error as e:
|
353
|
+
error_msg = f"Failed to get table statistics: {str(e)}"
|
354
|
+
self.stats.record_error(e.__class__.__name__)
|
355
|
+
raise ConnectionHandlerError(error_msg)
|
356
|
+
finally:
|
357
|
+
if conn:
|
358
|
+
conn.close()
|
359
|
+
|
360
|
+
async def get_table_constraints(self, table_name: str) -> str:
|
361
|
+
"""Get constraint information for table"""
|
362
|
+
conn = None
|
363
|
+
try:
|
364
|
+
conn_params = self.config.get_connection_params()
|
365
|
+
conn = mysql.connector.connect(**conn_params)
|
366
|
+
with conn.cursor(dictionary=True) as cur:
|
367
|
+
# Get constraint information
|
368
|
+
cur.execute("""
|
369
|
+
SELECT
|
370
|
+
k.CONSTRAINT_NAME as constraint_name,
|
371
|
+
t.CONSTRAINT_TYPE as constraint_type,
|
372
|
+
k.COLUMN_NAME as column_name,
|
373
|
+
k.REFERENCED_TABLE_NAME as referenced_table_name,
|
374
|
+
k.REFERENCED_COLUMN_NAME as referenced_column_name
|
375
|
+
FROM information_schema.key_column_usage k
|
376
|
+
JOIN information_schema.table_constraints t
|
377
|
+
ON k.CONSTRAINT_NAME = t.CONSTRAINT_NAME
|
378
|
+
AND k.TABLE_SCHEMA = t.TABLE_SCHEMA
|
379
|
+
AND k.TABLE_NAME = t.TABLE_NAME
|
380
|
+
WHERE k.TABLE_SCHEMA = %s
|
381
|
+
AND k.TABLE_NAME = %s
|
382
|
+
ORDER BY t.CONSTRAINT_TYPE, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
|
383
|
+
""", (self.config.database, table_name))
|
384
|
+
constraints = cur.fetchall()
|
385
|
+
|
386
|
+
if not constraints:
|
387
|
+
return f"No constraints found on table {table_name}"
|
388
|
+
|
389
|
+
# Format constraints by type
|
390
|
+
output = [f"Constraints for {table_name}:"]
|
391
|
+
current_constraint = None
|
392
|
+
constraint_info = []
|
393
|
+
|
394
|
+
for con in constraints:
|
395
|
+
if current_constraint != con['constraint_name']:
|
396
|
+
if constraint_info:
|
397
|
+
output.extend(constraint_info)
|
398
|
+
output.append("")
|
399
|
+
current_constraint = con['constraint_name']
|
400
|
+
constraint_info = [
|
401
|
+
f"\n{con['constraint_type']} Constraint: {con['constraint_name']}",
|
402
|
+
"Columns:"
|
403
|
+
]
|
404
|
+
|
405
|
+
col_info = f" - {con['column_name']}"
|
406
|
+
if con['referenced_table_name']:
|
407
|
+
col_info += f" -> {con['referenced_table_name']}.{con['referenced_column_name']}"
|
408
|
+
constraint_info.append(col_info)
|
409
|
+
|
410
|
+
if constraint_info:
|
411
|
+
output.extend(constraint_info)
|
412
|
+
|
413
|
+
return "\n".join(output)
|
414
|
+
|
415
|
+
except mysql.connector.Error as e:
|
416
|
+
error_msg = f"Failed to get constraint information: {str(e)}"
|
417
|
+
self.stats.record_error(e.__class__.__name__)
|
418
|
+
raise ConnectionHandlerError(error_msg)
|
419
|
+
finally:
|
420
|
+
if conn:
|
421
|
+
conn.close()
|
422
|
+
|
423
|
+
async def explain_query(self, sql: str) -> str:
|
424
|
+
"""Get query execution plan"""
|
425
|
+
conn = None
|
426
|
+
try:
|
427
|
+
conn_params = self.config.get_connection_params()
|
428
|
+
conn = mysql.connector.connect(**conn_params)
|
429
|
+
with conn.cursor(dictionary=True) as cur:
|
430
|
+
# Get EXPLAIN output
|
431
|
+
cur.execute(f"EXPLAIN FORMAT=TREE {sql}")
|
432
|
+
explain_result = cur.fetchall()
|
433
|
+
|
434
|
+
# Get EXPLAIN ANALYZE output
|
435
|
+
cur.execute(f"EXPLAIN ANALYZE {sql}")
|
436
|
+
analyze_result = cur.fetchall()
|
437
|
+
|
438
|
+
output = [
|
439
|
+
"Query Execution Plan:",
|
440
|
+
"==================",
|
441
|
+
"\nEstimated Plan:",
|
442
|
+
"----------------"
|
443
|
+
]
|
444
|
+
for row in explain_result:
|
445
|
+
output.append(str(row['EXPLAIN']))
|
446
|
+
|
447
|
+
output.extend([
|
448
|
+
"\nActual Plan (ANALYZE):",
|
449
|
+
"----------------------"
|
450
|
+
])
|
451
|
+
for row in analyze_result:
|
452
|
+
output.append(str(row['EXPLAIN']))
|
453
|
+
|
454
|
+
return "\n".join(output)
|
455
|
+
|
456
|
+
except mysql.connector.Error as e:
|
457
|
+
error_msg = f"Failed to explain query: {str(e)}"
|
458
|
+
self.stats.record_error(e.__class__.__name__)
|
459
|
+
raise ConnectionHandlerError(error_msg)
|
460
|
+
finally:
|
461
|
+
if conn:
|
462
|
+
conn.close()
|
463
|
+
|
464
|
+
async def cleanup(self):
|
465
|
+
"""Cleanup resources"""
|
466
|
+
# Log final stats before cleanup
|
467
|
+
self.log("info", f"Final MySQL handler stats: {self.stats.to_dict()}")
|
@@ -0,0 +1,216 @@
|
|
1
|
+
"""MySQL MCP server implementation"""
|
2
|
+
import mysql.connector
|
3
|
+
from mysql.connector.pooling import MySQLConnectionPool, PooledMySQLConnection
|
4
|
+
from typing import Optional, List
|
5
|
+
import mcp.types as types
|
6
|
+
from importlib.metadata import metadata
|
7
|
+
from ..base import ConnectionServer
|
8
|
+
from ..log import create_logger
|
9
|
+
from .config import MySQLConfig
|
10
|
+
|
11
|
+
# 获取包信息用于日志命名
|
12
|
+
pkg_meta = metadata("mcp-dbutils")
|
13
|
+
|
14
|
+
class MySQLServer(ConnectionServer):
|
15
|
+
def __init__(self, config: MySQLConfig, config_path: Optional[str] = None):
|
16
|
+
"""初始化MySQL服务器
|
17
|
+
Args:
|
18
|
+
config: 数据库配置
|
19
|
+
config_path: 配置文件路径(可选)
|
20
|
+
"""
|
21
|
+
super().__init__(config_path, config.debug)
|
22
|
+
self.config = config
|
23
|
+
self.config_path = config_path
|
24
|
+
self.log = create_logger(f"{pkg_meta['Name']}.db.mysql", config.debug)
|
25
|
+
# 创建连接池
|
26
|
+
try:
|
27
|
+
conn_params = config.get_connection_params()
|
28
|
+
masked_params = config.get_masked_connection_info()
|
29
|
+
self.log("debug", f"正在连接数据库,参数: {masked_params}")
|
30
|
+
|
31
|
+
# 测试连接
|
32
|
+
test_conn = mysql.connector.connect(**conn_params)
|
33
|
+
test_conn.close()
|
34
|
+
self.log("info", "测试连接成功")
|
35
|
+
|
36
|
+
# 创建连接池配置
|
37
|
+
pool_config = {
|
38
|
+
'pool_name': 'mypool',
|
39
|
+
'pool_size': 5,
|
40
|
+
**conn_params
|
41
|
+
}
|
42
|
+
self.pool = MySQLConnectionPool(**pool_config)
|
43
|
+
self.log("info", "连接池创建成功")
|
44
|
+
except mysql.connector.Error as e:
|
45
|
+
self.log("error", f"连接失败: {str(e)}")
|
46
|
+
raise
|
47
|
+
|
48
|
+
async def list_resources(self) -> list[types.Resource]:
|
49
|
+
"""列出所有表资源"""
|
50
|
+
try:
|
51
|
+
conn = self.pool.get_connection()
|
52
|
+
with conn.cursor(dictionary=True) as cur:
|
53
|
+
cur.execute("""
|
54
|
+
SELECT
|
55
|
+
table_name,
|
56
|
+
table_comment as description
|
57
|
+
FROM information_schema.tables
|
58
|
+
WHERE table_schema = %s
|
59
|
+
""", (self.config.database,))
|
60
|
+
tables = cur.fetchall()
|
61
|
+
return [
|
62
|
+
types.Resource(
|
63
|
+
uri=f"mysql://{self.config.host}/{table['table_name']}/schema",
|
64
|
+
name=f"{table['table_name']} schema",
|
65
|
+
description=table['description'] if table['description'] else None,
|
66
|
+
mimeType="application/json"
|
67
|
+
) for table in tables
|
68
|
+
]
|
69
|
+
except mysql.connector.Error as e:
|
70
|
+
error_msg = f"获取表列表失败: {str(e)}"
|
71
|
+
self.log("error", error_msg)
|
72
|
+
raise
|
73
|
+
finally:
|
74
|
+
conn.close()
|
75
|
+
|
76
|
+
async def read_resource(self, uri: str) -> str:
|
77
|
+
"""读取表结构信息"""
|
78
|
+
try:
|
79
|
+
table_name = uri.split('/')[-2]
|
80
|
+
conn = self.pool.get_connection()
|
81
|
+
with conn.cursor(dictionary=True) as cur:
|
82
|
+
# 获取列信息
|
83
|
+
cur.execute("""
|
84
|
+
SELECT
|
85
|
+
column_name,
|
86
|
+
data_type,
|
87
|
+
is_nullable,
|
88
|
+
column_comment as description
|
89
|
+
FROM information_schema.columns
|
90
|
+
WHERE table_schema = %s AND table_name = %s
|
91
|
+
ORDER BY ordinal_position
|
92
|
+
""", (self.config.database, table_name))
|
93
|
+
columns = cur.fetchall()
|
94
|
+
|
95
|
+
# 获取约束信息
|
96
|
+
cur.execute("""
|
97
|
+
SELECT
|
98
|
+
constraint_name,
|
99
|
+
constraint_type
|
100
|
+
FROM information_schema.table_constraints
|
101
|
+
WHERE table_schema = %s AND table_name = %s
|
102
|
+
""", (self.config.database, table_name))
|
103
|
+
constraints = cur.fetchall()
|
104
|
+
|
105
|
+
return str({
|
106
|
+
'columns': [{
|
107
|
+
'name': col['column_name'],
|
108
|
+
'type': col['data_type'],
|
109
|
+
'nullable': col['is_nullable'] == 'YES',
|
110
|
+
'description': col['description']
|
111
|
+
} for col in columns],
|
112
|
+
'constraints': [{
|
113
|
+
'name': con['constraint_name'],
|
114
|
+
'type': con['constraint_type']
|
115
|
+
} for con in constraints]
|
116
|
+
})
|
117
|
+
except mysql.connector.Error as e:
|
118
|
+
error_msg = f"读取表结构失败: {str(e)}"
|
119
|
+
self.log("error", error_msg)
|
120
|
+
raise
|
121
|
+
finally:
|
122
|
+
conn.close()
|
123
|
+
|
124
|
+
def get_tools(self) -> list[types.Tool]:
|
125
|
+
"""获取可用工具列表"""
|
126
|
+
return [
|
127
|
+
types.Tool(
|
128
|
+
name="query",
|
129
|
+
description="执行只读SQL查询",
|
130
|
+
inputSchema={
|
131
|
+
"type": "object",
|
132
|
+
"properties": {
|
133
|
+
"connection": {
|
134
|
+
"type": "string",
|
135
|
+
"description": "数据库连接名称(可选)"
|
136
|
+
},
|
137
|
+
"sql": {
|
138
|
+
"type": "string",
|
139
|
+
"description": "SQL查询语句(仅支持SELECT)"
|
140
|
+
}
|
141
|
+
},
|
142
|
+
"required": ["sql"]
|
143
|
+
}
|
144
|
+
)
|
145
|
+
]
|
146
|
+
|
147
|
+
async def call_tool(self, name: str, arguments: dict) -> list[types.TextContent]:
|
148
|
+
"""执行工具调用"""
|
149
|
+
if name != "query":
|
150
|
+
raise ValueError(f"未知工具: {name}")
|
151
|
+
sql = arguments.get("sql", "").strip()
|
152
|
+
if not sql:
|
153
|
+
raise ValueError("SQL查询不能为空")
|
154
|
+
# 仅允许SELECT语句
|
155
|
+
if not sql.lower().startswith("select"):
|
156
|
+
raise ValueError("仅支持SELECT查询")
|
157
|
+
|
158
|
+
connection = arguments.get("connection")
|
159
|
+
use_pool = True
|
160
|
+
conn = None
|
161
|
+
try:
|
162
|
+
if connection and self.config_path:
|
163
|
+
# 使用指定的数据库连接
|
164
|
+
config = MySQLConfig.from_yaml(self.config_path, connection)
|
165
|
+
conn_params = config.get_connection_params()
|
166
|
+
masked_params = config.get_masked_connection_info()
|
167
|
+
self.log("info", f"使用配置 {connection} 连接数据库: {masked_params}")
|
168
|
+
conn = mysql.connector.connect(**conn_params)
|
169
|
+
use_pool = False
|
170
|
+
else:
|
171
|
+
# 使用现有连接池
|
172
|
+
conn = self.pool.get_connection()
|
173
|
+
|
174
|
+
self.log("info", f"执行查询: {sql}")
|
175
|
+
with conn.cursor(dictionary=True) as cur:
|
176
|
+
# 设置只读事务
|
177
|
+
cur.execute("SET TRANSACTION READ ONLY")
|
178
|
+
try:
|
179
|
+
cur.execute(sql)
|
180
|
+
results = cur.fetchall()
|
181
|
+
columns = [desc[0] for desc in cur.description]
|
182
|
+
result_text = str({
|
183
|
+
'type': 'mysql',
|
184
|
+
'config_name': connection or 'default',
|
185
|
+
'query_result': {
|
186
|
+
'columns': columns,
|
187
|
+
'rows': results,
|
188
|
+
'row_count': len(results)
|
189
|
+
}
|
190
|
+
})
|
191
|
+
self.log("info", f"查询完成,返回{len(results)}行结果")
|
192
|
+
return [types.TextContent(type="text", text=result_text)]
|
193
|
+
finally:
|
194
|
+
cur.execute("ROLLBACK")
|
195
|
+
except Exception as e:
|
196
|
+
error = f"查询执行失败: {str(e)}"
|
197
|
+
error_msg = str({
|
198
|
+
'type': 'mysql',
|
199
|
+
'config_name': connection or 'default',
|
200
|
+
'error': error
|
201
|
+
})
|
202
|
+
self.log("error", error_msg)
|
203
|
+
return [types.TextContent(type="text", text=error_msg)]
|
204
|
+
finally:
|
205
|
+
if conn:
|
206
|
+
if isinstance(conn, PooledMySQLConnection):
|
207
|
+
conn.close() # 返回到连接池
|
208
|
+
else:
|
209
|
+
conn.close() # 关闭独立连接
|
210
|
+
|
211
|
+
async def cleanup(self):
|
212
|
+
"""清理资源"""
|
213
|
+
if hasattr(self, 'pool'):
|
214
|
+
self.log("info", "关闭连接池")
|
215
|
+
# MySQL连接池没有直接的closeall方法
|
216
|
+
# 当对象被销毁时,连接池会自动关闭
|