mcp-dbutils 0.16.1__py3-none-any.whl → 0.18.0__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.
@@ -31,6 +31,35 @@ class MySQLHandler(ConnectionHandler):
31
31
  self.log("debug", f"Configuring connection with parameters: {masked_params}")
32
32
  self.pool = None
33
33
 
34
+ async def _check_table_exists(self, cursor, table_name: str) -> None:
35
+ """检查表是否存在
36
+
37
+ Args:
38
+ cursor: 数据库游标
39
+ table_name: 表名
40
+
41
+ Raises:
42
+ ConnectionHandlerError: 如果表不存在
43
+ """
44
+ cursor.execute("""
45
+ SELECT COUNT(*) as count
46
+ FROM information_schema.tables
47
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
48
+ """, (self.config.database, table_name))
49
+ table_exists = cursor.fetchone()
50
+
51
+ # Handle different formats of cursor results (dict or tuple)
52
+ if not table_exists:
53
+ raise ConnectionHandlerError(f"Table '{self.config.database}.{table_name}' doesn't exist")
54
+
55
+ # If fetchone returns a dictionary (dictionary=True was used)
56
+ if isinstance(table_exists, dict) and 'count' in table_exists and table_exists['count'] == 0:
57
+ raise ConnectionHandlerError(f"Table '{self.config.database}.{table_name}' doesn't exist")
58
+
59
+ # If fetchone returns a tuple
60
+ if isinstance(table_exists, tuple) and table_exists[0] == 0:
61
+ raise ConnectionHandlerError(f"Table '{self.config.database}.{table_name}' doesn't exist")
62
+
34
63
  async def get_tables(self) -> list[types.Resource]:
35
64
  """Get all table resources"""
36
65
  try:
@@ -38,7 +67,7 @@ class MySQLHandler(ConnectionHandler):
38
67
  conn = mysql.connector.connect(**conn_params)
39
68
  with conn.cursor(dictionary=True) as cur: # NOSONAR
40
69
  cur.execute("""
41
- SELECT
70
+ SELECT
42
71
  TABLE_NAME as table_name,
43
72
  TABLE_COMMENT as description
44
73
  FROM information_schema.tables
@@ -69,7 +98,7 @@ class MySQLHandler(ConnectionHandler):
69
98
  with conn.cursor(dictionary=True) as cur: # NOSONAR
70
99
  # Get column information
71
100
  cur.execute("""
72
- SELECT
101
+ SELECT
73
102
  COLUMN_NAME as column_name,
74
103
  DATA_TYPE as data_type,
75
104
  IS_NULLABLE as is_nullable,
@@ -119,24 +148,30 @@ class MySQLHandler(ConnectionHandler):
119
148
  self.log("debug", f"Executing query: {sql}")
120
149
 
121
150
  with conn.cursor(dictionary=True) as cur: # NOSONAR
122
- # Start read-only transaction
123
- cur.execute("SET TRANSACTION READ ONLY")
151
+ # Check if the query is a SELECT statement
152
+ sql_upper = sql.strip().upper()
153
+ is_select = sql_upper.startswith("SELECT")
154
+
155
+ # Only set read-only transaction for SELECT statements
156
+ if is_select:
157
+ cur.execute("SET TRANSACTION READ ONLY")
124
158
  try:
125
159
  cur.execute(sql)
126
- results = cur.fetchall()
160
+ if not is_select:
161
+ conn.commit()
162
+ results = cur.fetchall() if is_select else []
163
+ if cur.description is None: # DDL statements
164
+ return "Query executed successfully"
127
165
  columns = [desc[0] for desc in cur.description]
128
-
129
- result_text = str({
130
- 'type': self.db_type,
131
- 'columns': columns,
132
- 'rows': results,
133
- 'row_count': len(results)
166
+ return str({
167
+ "columns": columns,
168
+ "rows": results
134
169
  })
135
-
136
- self.log("debug", f"Query completed, returned {len(results)} rows")
137
- return result_text
170
+ except mysql.connector.Error as e:
171
+ self.log("error", f"Query error: {str(e)}")
172
+ raise ConnectionHandlerError(str(e))
138
173
  finally:
139
- cur.execute("ROLLBACK")
174
+ cur.close()
140
175
  except mysql.connector.Error as e:
141
176
  error_msg = f"[{self.db_type}] Query execution failed: {str(e)}"
142
177
  raise ConnectionHandlerError(error_msg)
@@ -151,11 +186,14 @@ class MySQLHandler(ConnectionHandler):
151
186
  conn_params = self.config.get_connection_params()
152
187
  conn = mysql.connector.connect(**conn_params)
153
188
  with conn.cursor(dictionary=True) as cur: # NOSONAR
189
+ # Check if table exists
190
+ await self._check_table_exists(cur, table_name)
191
+
154
192
  # Get table information and comment
155
193
  cur.execute("""
156
- SELECT
194
+ SELECT
157
195
  TABLE_COMMENT as table_comment
158
- FROM information_schema.tables
196
+ FROM information_schema.tables
159
197
  WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
160
198
  """, (self.config.database, table_name))
161
199
  table_info = cur.fetchone()
@@ -163,7 +201,7 @@ class MySQLHandler(ConnectionHandler):
163
201
 
164
202
  # Get column information
165
203
  cur.execute("""
166
- SELECT
204
+ SELECT
167
205
  COLUMN_NAME as column_name,
168
206
  DATA_TYPE as data_type,
169
207
  COLUMN_DEFAULT as column_default,
@@ -184,14 +222,14 @@ class MySQLHandler(ConnectionHandler):
184
222
  f"Comment: {table_comment or 'No comment'}\n",
185
223
  COLUMNS_HEADER
186
224
  ]
187
-
225
+
188
226
  for col in columns:
189
227
  col_info = [
190
228
  f" {col['column_name']} ({col['data_type']})",
191
229
  f" Nullable: {col['is_nullable']}",
192
230
  f" Default: {col['column_default'] or 'None'}"
193
231
  ]
194
-
232
+
195
233
  if col['character_maximum_length']:
196
234
  col_info.append(f" Max Length: {col['character_maximum_length']}")
197
235
  if col['numeric_precision']:
@@ -200,12 +238,12 @@ class MySQLHandler(ConnectionHandler):
200
238
  col_info.append(f" Scale: {col['numeric_scale']}")
201
239
  if col['column_comment']:
202
240
  col_info.append(f" Comment: {col['column_comment']}")
203
-
241
+
204
242
  description.extend(col_info)
205
243
  description.append("") # Empty line between columns
206
-
244
+
207
245
  return "\n".join(description)
208
-
246
+
209
247
  except mysql.connector.Error as e:
210
248
  error_msg = f"Failed to get table description: {str(e)}"
211
249
  self.stats.record_error(e.__class__.__name__)
@@ -227,7 +265,7 @@ class MySQLHandler(ConnectionHandler):
227
265
  if result:
228
266
  return result['Create Table']
229
267
  return f"Failed to get DDL for table {table_name}"
230
-
268
+
231
269
  except mysql.connector.Error as e:
232
270
  error_msg = f"Failed to get table DDL: {str(e)}"
233
271
  self.stats.record_error(e.__class__.__name__)
@@ -243,9 +281,12 @@ class MySQLHandler(ConnectionHandler):
243
281
  conn_params = self.config.get_connection_params()
244
282
  conn = mysql.connector.connect(**conn_params)
245
283
  with conn.cursor(dictionary=True) as cur: # NOSONAR
284
+ # Check if table exists
285
+ await self._check_table_exists(cur, table_name)
286
+
246
287
  # Get index information
247
288
  cur.execute("""
248
- SELECT
289
+ SELECT
249
290
  INDEX_NAME as index_name,
250
291
  COLUMN_NAME as column_name,
251
292
  NON_UNIQUE as non_unique,
@@ -264,7 +305,7 @@ class MySQLHandler(ConnectionHandler):
264
305
  current_index = None
265
306
  formatted_indexes = []
266
307
  index_info = []
267
-
308
+
268
309
  for idx in indexes:
269
310
  if current_index != idx['index_name']:
270
311
  if index_info:
@@ -279,14 +320,14 @@ class MySQLHandler(ConnectionHandler):
279
320
  ]
280
321
  if idx['index_comment']:
281
322
  index_info.insert(1, f"Comment: {idx['index_comment']}")
282
-
323
+
283
324
  index_info.append(f" - {idx['column_name']}")
284
325
 
285
326
  if index_info:
286
327
  formatted_indexes.extend(index_info)
287
328
 
288
329
  return "\n".join(formatted_indexes)
289
-
330
+
290
331
  except mysql.connector.Error as e:
291
332
  error_msg = f"Failed to get index information: {str(e)}"
292
333
  self.stats.record_error(e.__class__.__name__)
@@ -302,9 +343,12 @@ class MySQLHandler(ConnectionHandler):
302
343
  conn_params = self.config.get_connection_params()
303
344
  conn = mysql.connector.connect(**conn_params)
304
345
  with conn.cursor(dictionary=True) as cur: # NOSONAR
346
+ # Check if table exists
347
+ await self._check_table_exists(cur, table_name)
348
+
305
349
  # Get table statistics
306
350
  cur.execute("""
307
- SELECT
351
+ SELECT
308
352
  TABLE_ROWS as table_rows,
309
353
  AVG_ROW_LENGTH as avg_row_length,
310
354
  DATA_LENGTH as data_length,
@@ -320,7 +364,7 @@ class MySQLHandler(ConnectionHandler):
320
364
 
321
365
  # Get column statistics
322
366
  cur.execute("""
323
- SELECT
367
+ SELECT
324
368
  COLUMN_NAME as column_name,
325
369
  DATA_TYPE as data_type,
326
370
  COLUMN_TYPE as column_type
@@ -367,20 +411,23 @@ class MySQLHandler(ConnectionHandler):
367
411
  conn_params = self.config.get_connection_params()
368
412
  conn = mysql.connector.connect(**conn_params)
369
413
  with conn.cursor(dictionary=True) as cur: # NOSONAR
414
+ # Check if table exists
415
+ await self._check_table_exists(cur, table_name)
416
+
370
417
  # Get constraint information
371
418
  cur.execute("""
372
- SELECT
419
+ SELECT
373
420
  k.CONSTRAINT_NAME as constraint_name,
374
421
  t.CONSTRAINT_TYPE as constraint_type,
375
422
  k.COLUMN_NAME as column_name,
376
423
  k.REFERENCED_TABLE_NAME as referenced_table_name,
377
424
  k.REFERENCED_COLUMN_NAME as referenced_column_name
378
425
  FROM information_schema.key_column_usage k
379
- JOIN information_schema.table_constraints t
426
+ JOIN information_schema.table_constraints t
380
427
  ON k.CONSTRAINT_NAME = t.CONSTRAINT_NAME
381
428
  AND k.TABLE_SCHEMA = t.TABLE_SCHEMA
382
429
  AND k.TABLE_NAME = t.TABLE_NAME
383
- WHERE k.TABLE_SCHEMA = %s
430
+ WHERE k.TABLE_SCHEMA = %s
384
431
  AND k.TABLE_NAME = %s
385
432
  ORDER BY t.CONSTRAINT_TYPE, k.CONSTRAINT_NAME, k.ORDINAL_POSITION
386
433
  """, (self.config.database, table_name))
@@ -404,7 +451,7 @@ class MySQLHandler(ConnectionHandler):
404
451
  f"\n{con['constraint_type']} Constraint: {con['constraint_name']}",
405
452
  COLUMNS_HEADER
406
453
  ]
407
-
454
+
408
455
  col_info = f" - {con['column_name']}"
409
456
  if con['referenced_table_name']:
410
457
  col_info += f" -> {con['referenced_table_name']}.{con['referenced_column_name']}"
@@ -446,7 +493,7 @@ class MySQLHandler(ConnectionHandler):
446
493
  ]
447
494
  for row in explain_result:
448
495
  output.append(str(row['EXPLAIN']))
449
-
496
+
450
497
  output.extend([
451
498
  "\nActual Plan (ANALYZE):",
452
499
  "----------------------"
@@ -464,6 +511,26 @@ class MySQLHandler(ConnectionHandler):
464
511
  if conn:
465
512
  conn.close()
466
513
 
514
+ async def test_connection(self) -> bool:
515
+ """Test database connection
516
+
517
+ Returns:
518
+ bool: True if connection is successful, False otherwise
519
+ """
520
+ conn = None
521
+ try:
522
+ conn_params = self.config.get_connection_params()
523
+ conn = mysql.connector.connect(**conn_params)
524
+ with conn.cursor() as cur:
525
+ cur.execute("SELECT 1")
526
+ return True
527
+ except mysql.connector.Error as e:
528
+ self.log("error", f"Connection test failed: {str(e)}")
529
+ return False
530
+ finally:
531
+ if conn:
532
+ conn.close()
533
+
467
534
  async def cleanup(self):
468
535
  """Cleanup resources"""
469
536
  # Log final stats before cleanup
@@ -165,7 +165,7 @@ class PostgreSQLHandler(ConnectionHandler):
165
165
  (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
166
166
  'pg_class'
167
167
  ) as table_comment
168
- FROM information_schema.tables
168
+ FROM information_schema.tables
169
169
  WHERE table_name = %s
170
170
  """, (table_name,))
171
171
  table_info = cur.fetchone()
@@ -173,7 +173,7 @@ class PostgreSQLHandler(ConnectionHandler):
173
173
 
174
174
  # 获取列信息
175
175
  cur.execute("""
176
- SELECT
176
+ SELECT
177
177
  column_name,
178
178
  data_type,
179
179
  column_default,
@@ -197,14 +197,14 @@ class PostgreSQLHandler(ConnectionHandler):
197
197
  f"Comment: {table_comment or 'No comment'}\n",
198
198
  COLUMNS_HEADER
199
199
  ]
200
-
200
+
201
201
  for col in columns:
202
202
  col_info = [
203
203
  f" {col[0]} ({col[1]})",
204
204
  f" Nullable: {col[3]}",
205
205
  f" Default: {col[2] or 'None'}"
206
206
  ]
207
-
207
+
208
208
  if col[4]: # character_maximum_length
209
209
  col_info.append(f" Max Length: {col[4]}")
210
210
  if col[5]: # numeric_precision
@@ -213,12 +213,12 @@ class PostgreSQLHandler(ConnectionHandler):
213
213
  col_info.append(f" Scale: {col[6]}")
214
214
  if col[7]: # column_comment
215
215
  col_info.append(f" Comment: {col[7]}")
216
-
216
+
217
217
  description.extend(col_info)
218
218
  description.append("") # Empty line between columns
219
-
219
+
220
220
  return "\n".join(description)
221
-
221
+
222
222
  except psycopg2.Error as e:
223
223
  error_msg = f"Failed to get index information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
224
224
  self.stats.record_error(e.__class__.__name__)
@@ -236,7 +236,7 @@ class PostgreSQLHandler(ConnectionHandler):
236
236
  with conn.cursor() as cur:
237
237
  # 获取列定义
238
238
  cur.execute("""
239
- SELECT
239
+ SELECT
240
240
  column_name,
241
241
  data_type,
242
242
  column_default,
@@ -252,7 +252,7 @@ class PostgreSQLHandler(ConnectionHandler):
252
252
 
253
253
  # 获取约束
254
254
  cur.execute("""
255
- SELECT
255
+ SELECT
256
256
  conname as constraint_name,
257
257
  pg_get_constraintdef(c.oid) as constraint_def
258
258
  FROM pg_constraint c
@@ -263,12 +263,12 @@ class PostgreSQLHandler(ConnectionHandler):
263
263
 
264
264
  # 构建CREATE TABLE语句
265
265
  ddl = [f"CREATE TABLE {table_name} ("]
266
-
266
+
267
267
  # 添加列定义
268
268
  column_defs = []
269
269
  for col in columns:
270
270
  col_def = [f" {col[0]} {col[1]}"]
271
-
271
+
272
272
  if col[4]: # character_maximum_length
273
273
  col_def[0] = f"{col_def[0]}({col[4]})"
274
274
  elif col[5]: # numeric_precision
@@ -276,24 +276,24 @@ class PostgreSQLHandler(ConnectionHandler):
276
276
  col_def[0] = f"{col_def[0]}({col[5]},{col[6]})"
277
277
  else:
278
278
  col_def[0] = f"{col_def[0]}({col[5]})"
279
-
279
+
280
280
  if col[2]: # default
281
281
  col_def.append(f"DEFAULT {col[2]}")
282
282
  if col[3] == 'NO': # not null
283
283
  col_def.append("NOT NULL")
284
-
284
+
285
285
  column_defs.append(" ".join(col_def))
286
-
286
+
287
287
  # 添加约束定义
288
288
  for con in constraints:
289
289
  column_defs.append(f" CONSTRAINT {con[0]} {con[1]}")
290
-
290
+
291
291
  ddl.append(",\n".join(column_defs))
292
292
  ddl.append(");")
293
-
293
+
294
294
  # 添加注释
295
295
  cur.execute("""
296
- SELECT
296
+ SELECT
297
297
  c.column_name,
298
298
  col_description(
299
299
  (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
@@ -307,15 +307,15 @@ class PostgreSQLHandler(ConnectionHandler):
307
307
  WHERE c.table_name = %s
308
308
  """, (table_name,))
309
309
  comments = cur.fetchall()
310
-
310
+
311
311
  for comment in comments:
312
312
  if comment[2]: # table comment
313
313
  ddl.append(f"\nCOMMENT ON TABLE {table_name} IS '{comment[2]}';")
314
314
  if comment[1]: # column comment
315
315
  ddl.append(f"COMMENT ON COLUMN {table_name}.{comment[0]} IS '{comment[1]}';")
316
-
316
+
317
317
  return "\n".join(ddl)
318
-
318
+
319
319
  except psycopg2.Error as e:
320
320
  error_msg = f"Failed to get table DDL: [Code: {e.pgcode}] {e.pgerror or str(e)}"
321
321
  self.stats.record_error(e.__class__.__name__)
@@ -336,7 +336,7 @@ class PostgreSQLHandler(ConnectionHandler):
336
336
  SELECT
337
337
  i.relname as index_name,
338
338
  a.attname as column_name,
339
- CASE
339
+ CASE
340
340
  WHEN ix.indisprimary THEN 'PRIMARY KEY'
341
341
  WHEN ix.indisunique THEN 'UNIQUE'
342
342
  ELSE 'INDEX'
@@ -362,7 +362,7 @@ class PostgreSQLHandler(ConnectionHandler):
362
362
  current_index = None
363
363
  formatted_indexes = []
364
364
  index_info = []
365
-
365
+
366
366
  for idx in indexes:
367
367
  if current_index != idx[0]:
368
368
  if index_info:
@@ -377,14 +377,14 @@ class PostgreSQLHandler(ConnectionHandler):
377
377
  ]
378
378
  if idx[5]: # index comment
379
379
  index_info.insert(1, f"Comment: {idx[5]}")
380
-
380
+
381
381
  index_info.append(f" - {idx[1]}")
382
382
 
383
383
  if index_info:
384
384
  formatted_indexes.extend(index_info)
385
385
 
386
386
  return "\n".join(formatted_indexes)
387
-
387
+
388
388
  except psycopg2.Error as e:
389
389
  error_msg = f"Failed to get index information: [Code: {e.pgcode}] {e.pgerror or str(e)}"
390
390
  self.stats.record_error(e.__class__.__name__)
@@ -402,7 +402,7 @@ class PostgreSQLHandler(ConnectionHandler):
402
402
  with conn.cursor() as cur:
403
403
  # Get table statistics
404
404
  cur.execute("""
405
- SELECT
405
+ SELECT
406
406
  c.reltuples::bigint as row_estimate,
407
407
  pg_size_pretty(pg_total_relation_size(c.oid)) as total_size,
408
408
  pg_size_pretty(pg_table_size(c.oid)) as table_size,
@@ -428,8 +428,8 @@ class PostgreSQLHandler(ConnectionHandler):
428
428
  s.n_distinct as distinct_values,
429
429
  pg_column_size(a.attname::text) as approx_width
430
430
  FROM pg_stats s
431
- JOIN pg_attribute a ON a.attrelid = %s::regclass
432
- AND a.attnum > 0
431
+ JOIN pg_attribute a ON a.attrelid = %s::regclass
432
+ AND a.attnum > 0
433
433
  AND a.attname = s.attname
434
434
  WHERE s.schemaname = 'public'
435
435
  AND s.tablename = %s
@@ -522,10 +522,10 @@ class PostgreSQLHandler(ConnectionHandler):
522
522
 
523
523
  if con[4]: # is_deferrable
524
524
  output.append(f" Deferrable: {'Deferred' if con[5] else 'Immediate'}")
525
-
525
+
526
526
  if con[6]: # comment
527
527
  output.append(f" Comment: {con[6]}")
528
-
528
+
529
529
  output.append("") # Empty line between constraints
530
530
 
531
531
  return "\n".join(output)
@@ -567,7 +567,7 @@ class PostgreSQLHandler(ConnectionHandler):
567
567
  "----------------"
568
568
  ]
569
569
  output.extend(line[0] for line in regular_plan)
570
-
570
+
571
571
  output.extend([
572
572
  "\nActual Plan (ANALYZE):",
573
573
  "----------------------"
@@ -584,6 +584,26 @@ class PostgreSQLHandler(ConnectionHandler):
584
584
  if conn:
585
585
  conn.close()
586
586
 
587
+ async def test_connection(self) -> bool:
588
+ """Test database connection
589
+
590
+ Returns:
591
+ bool: True if connection is successful, False otherwise
592
+ """
593
+ conn = None
594
+ try:
595
+ conn_params = self.config.get_connection_params()
596
+ conn = psycopg2.connect(**conn_params)
597
+ with conn.cursor() as cur:
598
+ cur.execute("SELECT 1")
599
+ return True
600
+ except psycopg2.Error as e:
601
+ self.log("error", f"Connection test failed: [Code: {e.pgcode}] {e.pgerror or str(e)}")
602
+ return False
603
+ finally:
604
+ if conn:
605
+ conn.close()
606
+
587
607
  async def cleanup(self):
588
608
  """Cleanup resources"""
589
609
  # Log final stats before cleanup