db-connect-mcp 0.1.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.

Potentially problematic release.


This version of db-connect-mcp might be problematic. Click here for more details.

@@ -0,0 +1,496 @@
1
+ """Multi-database MCP Server
2
+
3
+ A Model Context Protocol (MCP) server providing database analysis and querying
4
+ capabilities for PostgreSQL, MySQL, and ClickHouse databases.
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import logging
10
+ import os
11
+ from typing import Any, Optional
12
+
13
+ from dotenv import load_dotenv
14
+ from mcp.server import Server
15
+ from mcp.types import TextContent, Tool
16
+
17
+ from db_connect_mcp.adapters import create_adapter
18
+ from db_connect_mcp.core import (
19
+ DatabaseConnection,
20
+ MetadataInspector,
21
+ QueryExecutor,
22
+ StatisticsAnalyzer,
23
+ )
24
+ from db_connect_mcp.models.config import DatabaseConfig
25
+
26
+ # Load environment variables
27
+ load_dotenv()
28
+
29
+ # Configure logging
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class DatabaseMCPServer:
35
+ """MCP server for multi-database operations."""
36
+
37
+ def __init__(self, config: DatabaseConfig):
38
+ """
39
+ Initialize database MCP server.
40
+
41
+ Args:
42
+ config: Database configuration
43
+ """
44
+ self.config = config
45
+ self.connection = DatabaseConnection(config)
46
+ self.adapter = create_adapter(config)
47
+ self.inspector: Optional[MetadataInspector] = None
48
+ self.executor: Optional[QueryExecutor] = None
49
+ self.analyzer: Optional[StatisticsAnalyzer] = None
50
+ self.server = Server("db-mcp")
51
+
52
+ async def initialize(self) -> None:
53
+ """Initialize all components."""
54
+ await self.connection.initialize()
55
+
56
+ self.inspector = MetadataInspector(self.connection, self.adapter)
57
+ self.executor = QueryExecutor(self.connection, self.adapter)
58
+ self.analyzer = StatisticsAnalyzer(self.connection, self.adapter)
59
+
60
+ # Register MCP tool handlers
61
+ await self._register_tools()
62
+
63
+ logger.info(
64
+ f"Initialized {self.config.dialect} MCP server "
65
+ f"({len(self.adapter.capabilities.get_supported_features())} features)"
66
+ )
67
+
68
+ async def _register_tools(self) -> None:
69
+ """Register MCP tools based on database capabilities."""
70
+ # Note: Tools are registered via the list_tools decorator, not add_tool
71
+ # This method is kept for initializing any tool-related state
72
+ pass
73
+
74
+ def _create_get_database_info_tool(self) -> Tool:
75
+ """Create get_database_info tool."""
76
+ return Tool(
77
+ name="get_database_info",
78
+ description="Get database information including version, size, and capabilities",
79
+ inputSchema={
80
+ "type": "object",
81
+ "properties": {},
82
+ "required": [],
83
+ },
84
+ )
85
+
86
+ def _create_list_schemas_tool(self) -> Tool:
87
+ """Create list_schemas tool."""
88
+ return Tool(
89
+ name="list_schemas",
90
+ description="List all schemas/databases in the database instance",
91
+ inputSchema={
92
+ "type": "object",
93
+ "properties": {},
94
+ "required": [],
95
+ },
96
+ )
97
+
98
+ def _create_list_tables_tool(self) -> Tool:
99
+ """Create list_tables tool."""
100
+ return Tool(
101
+ name="list_tables",
102
+ description="List all tables and views in a schema",
103
+ inputSchema={
104
+ "type": "object",
105
+ "properties": {
106
+ "schema": {
107
+ "type": "string",
108
+ "description": "Schema name (optional, uses default if not specified)",
109
+ },
110
+ "include_views": {
111
+ "type": "boolean",
112
+ "description": "Whether to include views (default: true)",
113
+ "default": True,
114
+ },
115
+ },
116
+ "required": [],
117
+ },
118
+ )
119
+
120
+ def _create_describe_table_tool(self) -> Tool:
121
+ """Create describe_table tool."""
122
+ return Tool(
123
+ name="describe_table",
124
+ description="Get comprehensive table information including columns, indexes, and constraints",
125
+ inputSchema={
126
+ "type": "object",
127
+ "properties": {
128
+ "table": {"type": "string", "description": "Table name"},
129
+ "schema": {
130
+ "type": "string",
131
+ "description": "Schema name (optional)",
132
+ },
133
+ },
134
+ "required": ["table"],
135
+ },
136
+ )
137
+
138
+ def _create_execute_query_tool(self) -> Tool:
139
+ """Create execute_query tool."""
140
+ return Tool(
141
+ name="execute_query",
142
+ description="Execute a read-only SQL query (SELECT, WITH, EXPLAIN)",
143
+ inputSchema={
144
+ "type": "object",
145
+ "properties": {
146
+ "query": {"type": "string", "description": "SQL query to execute"},
147
+ "limit": {
148
+ "type": "integer",
149
+ "description": "Maximum number of rows to return (default: 1000)",
150
+ "default": 1000,
151
+ },
152
+ },
153
+ "required": ["query"],
154
+ },
155
+ )
156
+
157
+ def _create_sample_data_tool(self) -> Tool:
158
+ """Create sample_data tool."""
159
+ return Tool(
160
+ name="sample_data",
161
+ description="Sample data from a table efficiently",
162
+ inputSchema={
163
+ "type": "object",
164
+ "properties": {
165
+ "table": {"type": "string", "description": "Table name"},
166
+ "schema": {
167
+ "type": "string",
168
+ "description": "Schema name (optional)",
169
+ },
170
+ "limit": {
171
+ "type": "integer",
172
+ "description": "Number of rows to sample (default: 100)",
173
+ "default": 100,
174
+ },
175
+ },
176
+ "required": ["table"],
177
+ },
178
+ )
179
+
180
+ def _create_get_relationships_tool(self) -> Tool:
181
+ """Create get_table_relationships tool."""
182
+ return Tool(
183
+ name="get_table_relationships",
184
+ description="Get foreign key relationships for a table",
185
+ inputSchema={
186
+ "type": "object",
187
+ "properties": {
188
+ "table": {"type": "string", "description": "Table name"},
189
+ "schema": {
190
+ "type": "string",
191
+ "description": "Schema name (optional)",
192
+ },
193
+ },
194
+ "required": ["table"],
195
+ },
196
+ )
197
+
198
+ def _create_analyze_column_tool(self) -> Tool:
199
+ """Create analyze_column tool."""
200
+ return Tool(
201
+ name="analyze_column",
202
+ description="Get comprehensive column statistics including percentiles and distributions",
203
+ inputSchema={
204
+ "type": "object",
205
+ "properties": {
206
+ "table": {"type": "string", "description": "Table name"},
207
+ "column": {"type": "string", "description": "Column name"},
208
+ "schema": {
209
+ "type": "string",
210
+ "description": "Schema name (optional)",
211
+ },
212
+ },
213
+ "required": ["table", "column"],
214
+ },
215
+ )
216
+
217
+ def _create_explain_query_tool(self) -> Tool:
218
+ """Create explain_query tool."""
219
+ return Tool(
220
+ name="explain_query",
221
+ description="Get query execution plan to analyze performance",
222
+ inputSchema={
223
+ "type": "object",
224
+ "properties": {
225
+ "query": {"type": "string", "description": "SQL query to explain"},
226
+ "analyze": {
227
+ "type": "boolean",
228
+ "description": "Whether to execute the query (EXPLAIN ANALYZE)",
229
+ "default": False,
230
+ },
231
+ },
232
+ "required": ["query"],
233
+ },
234
+ )
235
+
236
+ def _create_profile_database_tool(self) -> Tool:
237
+ """Create profile_database tool."""
238
+ return Tool(
239
+ name="profile_database",
240
+ description="Get database-wide profiling information (size, table counts, etc.)",
241
+ inputSchema={
242
+ "type": "object",
243
+ "properties": {},
244
+ "required": [],
245
+ },
246
+ )
247
+
248
+ # Tool handlers
249
+ async def handle_get_database_info(
250
+ self, arguments: dict[str, Any]
251
+ ) -> list[TextContent]:
252
+ """Handle get_database_info request."""
253
+ assert self.inspector is not None
254
+
255
+ version = await self.connection.get_version()
256
+
257
+ # Sanitize connection URL (remove password)
258
+ url_parts = self.config.url.split("@")
259
+ if len(url_parts) > 1:
260
+ sanitized_url = f"<credentials>@{url_parts[-1]}"
261
+ else:
262
+ sanitized_url = self.config.url
263
+
264
+ from db_connect_mcp.models.database import DatabaseInfo
265
+
266
+ db_info = DatabaseInfo(
267
+ name=self.config.url.split("/")[-1], # Extract DB name from URL
268
+ dialect=self.config.dialect,
269
+ version=version,
270
+ size_bytes=None,
271
+ schema_count=None,
272
+ table_count=None,
273
+ capabilities=self.adapter.capabilities,
274
+ server_encoding=None,
275
+ collation=None,
276
+ connection_url=sanitized_url,
277
+ read_only=self.config.read_only,
278
+ )
279
+
280
+ return [
281
+ TextContent(type="text", text=json.dumps(db_info.model_dump(), indent=2))
282
+ ]
283
+
284
+ async def handle_list_schemas(self, arguments: dict[str, Any]) -> list[TextContent]:
285
+ """Handle list_schemas request."""
286
+ assert self.inspector is not None
287
+
288
+ schemas = await self.inspector.get_schemas()
289
+ schemas_data = [s.model_dump() for s in schemas]
290
+
291
+ return [TextContent(type="text", text=json.dumps(schemas_data, indent=2))]
292
+
293
+ async def handle_list_tables(self, arguments: dict[str, Any]) -> list[TextContent]:
294
+ """Handle list_tables request."""
295
+ assert self.inspector is not None
296
+
297
+ schema = arguments.get("schema")
298
+ include_views = arguments.get("include_views", True)
299
+
300
+ tables = await self.inspector.get_tables(schema, include_views)
301
+ tables_data = [t.model_dump() for t in tables]
302
+
303
+ return [TextContent(type="text", text=json.dumps(tables_data, indent=2))]
304
+
305
+ async def handle_describe_table(
306
+ self, arguments: dict[str, Any]
307
+ ) -> list[TextContent]:
308
+ """Handle describe_table request."""
309
+ assert self.inspector is not None
310
+
311
+ table = arguments["table"]
312
+ schema = arguments.get("schema")
313
+
314
+ table_info = await self.inspector.describe_table(table, schema)
315
+
316
+ return [
317
+ TextContent(type="text", text=json.dumps(table_info.model_dump(), indent=2))
318
+ ]
319
+
320
+ async def handle_execute_query(
321
+ self, arguments: dict[str, Any]
322
+ ) -> list[TextContent]:
323
+ """Handle execute_query request."""
324
+ assert self.executor is not None
325
+
326
+ query = arguments["query"]
327
+ limit = arguments.get("limit", 1000)
328
+
329
+ result = await self.executor.execute_query(query, limit=limit)
330
+
331
+ return [
332
+ TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))
333
+ ]
334
+
335
+ async def handle_sample_data(self, arguments: dict[str, Any]) -> list[TextContent]:
336
+ """Handle sample_data request."""
337
+ assert self.executor is not None
338
+
339
+ table = arguments["table"]
340
+ schema = arguments.get("schema")
341
+ limit = arguments.get("limit", 100)
342
+
343
+ result = await self.executor.sample_data(table, schema, limit)
344
+
345
+ return [
346
+ TextContent(type="text", text=json.dumps(result.model_dump(), indent=2))
347
+ ]
348
+
349
+ async def handle_get_relationships(
350
+ self, arguments: dict[str, Any]
351
+ ) -> list[TextContent]:
352
+ """Handle get_table_relationships request."""
353
+ assert self.inspector is not None
354
+
355
+ table = arguments["table"]
356
+ schema = arguments.get("schema")
357
+
358
+ relationships = await self.inspector.get_relationships(table, schema)
359
+ relationships_data = [r.model_dump() for r in relationships]
360
+
361
+ return [TextContent(type="text", text=json.dumps(relationships_data, indent=2))]
362
+
363
+ async def handle_analyze_column(
364
+ self, arguments: dict[str, Any]
365
+ ) -> list[TextContent]:
366
+ """Handle analyze_column request."""
367
+ assert self.analyzer is not None
368
+
369
+ table = arguments["table"]
370
+ column = arguments["column"]
371
+ schema = arguments.get("schema")
372
+
373
+ stats = await self.analyzer.analyze_column(table, column, schema)
374
+
375
+ return [TextContent(type="text", text=json.dumps(stats.model_dump(), indent=2))]
376
+
377
+ async def handle_explain_query(
378
+ self, arguments: dict[str, Any]
379
+ ) -> list[TextContent]:
380
+ """Handle explain_query request."""
381
+ assert self.executor is not None
382
+
383
+ query = arguments["query"]
384
+ analyze = arguments.get("analyze", False)
385
+
386
+ plan = await self.executor.explain_query(query, analyze)
387
+
388
+ return [TextContent(type="text", text=json.dumps(plan.model_dump(), indent=2))]
389
+
390
+ async def handle_profile_database(
391
+ self, arguments: dict[str, Any]
392
+ ) -> list[TextContent]:
393
+ """Handle profile_database request."""
394
+ # This would be implemented with adapter-specific profiling queries
395
+ return [
396
+ TextContent(
397
+ type="text",
398
+ text=json.dumps(
399
+ {"message": "Database profiling not yet implemented"}, indent=2
400
+ ),
401
+ )
402
+ ]
403
+
404
+ async def cleanup(self) -> None:
405
+ """Cleanup resources."""
406
+ await self.connection.dispose()
407
+ logger.info("Database MCP server cleaned up")
408
+
409
+
410
+ async def main() -> None:
411
+ """Main entry point for the MCP server."""
412
+ # Get database URL from environment
413
+ database_url = os.getenv("DATABASE_URL")
414
+ if not database_url:
415
+ raise ValueError("DATABASE_URL environment variable must be set")
416
+
417
+ # Create configuration
418
+ config = DatabaseConfig(url=database_url)
419
+
420
+ # Create and initialize server
421
+ mcp_server = DatabaseMCPServer(config)
422
+
423
+ try:
424
+ await mcp_server.initialize()
425
+
426
+ # Register list_tools handler
427
+ @mcp_server.server.list_tools()
428
+ async def list_tools() -> list[Tool]:
429
+ """List available tools based on database capabilities."""
430
+ tools = [
431
+ mcp_server._create_get_database_info_tool(),
432
+ mcp_server._create_list_schemas_tool(),
433
+ mcp_server._create_list_tables_tool(),
434
+ mcp_server._create_describe_table_tool(),
435
+ mcp_server._create_execute_query_tool(),
436
+ mcp_server._create_sample_data_tool(),
437
+ ]
438
+
439
+ # Add conditional tools
440
+ if mcp_server.adapter.capabilities.foreign_keys:
441
+ tools.append(mcp_server._create_get_relationships_tool())
442
+
443
+ if mcp_server.adapter.capabilities.advanced_stats:
444
+ tools.append(mcp_server._create_analyze_column_tool())
445
+
446
+ if mcp_server.adapter.capabilities.explain_plans:
447
+ tools.append(mcp_server._create_explain_query_tool())
448
+
449
+ if mcp_server.adapter.capabilities.profiling:
450
+ tools.append(mcp_server._create_profile_database_tool())
451
+
452
+ return tools
453
+
454
+ # Register tool call handlers
455
+ @mcp_server.server.call_tool()
456
+ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
457
+ """Handle tool calls."""
458
+ handlers = {
459
+ "get_database_info": mcp_server.handle_get_database_info,
460
+ "list_schemas": mcp_server.handle_list_schemas,
461
+ "list_tables": mcp_server.handle_list_tables,
462
+ "describe_table": mcp_server.handle_describe_table,
463
+ "execute_query": mcp_server.handle_execute_query,
464
+ "sample_data": mcp_server.handle_sample_data,
465
+ "get_table_relationships": mcp_server.handle_get_relationships,
466
+ "analyze_column": mcp_server.handle_analyze_column,
467
+ "explain_query": mcp_server.handle_explain_query,
468
+ "profile_database": mcp_server.handle_profile_database,
469
+ }
470
+
471
+ handler = handlers.get(name)
472
+ if handler is None:
473
+ raise ValueError(f"Unknown tool: {name}")
474
+
475
+ return await handler(arguments)
476
+
477
+ # Run the server
478
+ from mcp.server.stdio import stdio_server
479
+
480
+ async with stdio_server() as (read_stream, write_stream):
481
+ await mcp_server.server.run(
482
+ read_stream,
483
+ write_stream,
484
+ mcp_server.server.create_initialization_options(),
485
+ )
486
+
487
+ finally:
488
+ await mcp_server.cleanup()
489
+
490
+
491
+ if __name__ == "__main__":
492
+ # Windows-specific event loop policy
493
+ if os.name == "nt":
494
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) # type: ignore[attr-defined]
495
+
496
+ asyncio.run(main())