mcp-dbutils 0.2.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.
@@ -0,0 +1,62 @@
1
+ """MCP Database Utilities Service"""
2
+
3
+ import asyncio
4
+ import argparse
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+ import yaml
9
+
10
+ from .log import create_logger
11
+ from .base import DatabaseServer
12
+
13
+ # 创建全局logger
14
+ log = create_logger("mcp-dbutils")
15
+
16
+ async def run_server():
17
+ """服务器运行逻辑"""
18
+ parser = argparse.ArgumentParser(description='MCP Database Server')
19
+ parser.add_argument('--config', required=True, help='YAML配置文件路径')
20
+ parser.add_argument('--local-host', help='本地主机地址')
21
+
22
+ args = parser.parse_args()
23
+
24
+ # 检查是否开启debug模式
25
+ debug = os.getenv('MCP_DEBUG', '').lower() in ('1', 'true', 'yes')
26
+
27
+ # 更新logger的debug状态
28
+ global log
29
+ log = create_logger("mcp-dbutils", debug)
30
+
31
+ if debug:
32
+ log("debug", "Debug模式已开启")
33
+
34
+ # 验证配置文件
35
+ try:
36
+ with open(args.config, 'r') as f:
37
+ config = yaml.safe_load(f)
38
+ if not config or 'databases' not in config:
39
+ log("error", "配置文件必须包含 databases 配置")
40
+ sys.exit(1)
41
+ if not config['databases']:
42
+ log("error", "配置文件必须包含至少一个数据库配置")
43
+ sys.exit(1)
44
+ except Exception as e:
45
+ log("error", f"读取配置文件失败: {str(e)}")
46
+ sys.exit(1)
47
+
48
+ # 创建并运行服务器
49
+ try:
50
+ server = DatabaseServer(args.config, debug)
51
+ await server.run()
52
+ except KeyboardInterrupt:
53
+ log("info", "服务器已停止")
54
+ except Exception as e:
55
+ log("error", str(e))
56
+ sys.exit(1)
57
+
58
+ def main():
59
+ """命令行入口函数"""
60
+ asyncio.run(run_server())
61
+
62
+ __all__ = ['main']
mcp_dbutils/base.py ADDED
@@ -0,0 +1,182 @@
1
+ """Database server base class"""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, List, Optional, AsyncContextManager
5
+ from contextlib import asynccontextmanager
6
+ import yaml
7
+ from mcp.server import Server, NotificationOptions
8
+ import mcp.server.stdio
9
+ import mcp.types as types
10
+ from .log import create_logger
11
+
12
+ class DatabaseHandler(ABC):
13
+ """Abstract base class defining common interface for database handlers"""
14
+
15
+ def __init__(self, config_path: str, database: str, debug: bool = False):
16
+ """Initialize database handler
17
+
18
+ Args:
19
+ config_path: Path to configuration file
20
+ database: Database configuration name
21
+ debug: Enable debug mode
22
+ """
23
+ self.config_path = config_path
24
+ self.database = database
25
+ self.debug = debug
26
+ self.log = create_logger(f"db-handler-{database}", debug)
27
+
28
+ @abstractmethod
29
+ async def get_tables(self) -> list[types.Resource]:
30
+ """Get list of table resources from database"""
31
+ pass
32
+
33
+ @abstractmethod
34
+ async def get_schema(self, table_name: str) -> str:
35
+ """Get schema information for specified table"""
36
+ pass
37
+
38
+ @abstractmethod
39
+ async def execute_query(self, sql: str) -> str:
40
+ """Execute SQL query"""
41
+ pass
42
+
43
+ @abstractmethod
44
+ async def cleanup(self):
45
+ """Cleanup resources"""
46
+ pass
47
+
48
+ class DatabaseServer:
49
+ """Unified database server class"""
50
+
51
+ def __init__(self, config_path: str, debug: bool = False):
52
+ """Initialize database server
53
+
54
+ Args:
55
+ config_path: Path to configuration file
56
+ debug: Enable debug mode
57
+ """
58
+ self.config_path = config_path
59
+ self.debug = debug
60
+ self.log = create_logger("db-server", debug)
61
+ self.server = Server("database-server")
62
+ self._setup_handlers()
63
+
64
+ @asynccontextmanager
65
+ async def get_handler(self, database: str) -> AsyncContextManager[DatabaseHandler]:
66
+ """Get database handler
67
+
68
+ Get appropriate database handler based on configuration name
69
+
70
+ Args:
71
+ database: Database configuration name
72
+
73
+ Returns:
74
+ AsyncContextManager[DatabaseHandler]: Context manager for database handler
75
+ """
76
+ # Read configuration file to determine database type
77
+ with open(self.config_path, 'r') as f:
78
+ config = yaml.safe_load(f)
79
+ if not config or 'databases' not in config:
80
+ raise ValueError("Configuration file must contain 'databases' section")
81
+ if database not in config['databases']:
82
+ available_dbs = list(config['databases'].keys())
83
+ raise ValueError(f"Database configuration not found: {database}. Available configurations: {available_dbs}")
84
+
85
+ db_config = config['databases'][database]
86
+
87
+ handler = None
88
+ try:
89
+ # Create appropriate handler based on configuration
90
+ if 'path' in db_config:
91
+ from .sqlite.handler import SqliteHandler
92
+ handler = SqliteHandler(self.config_path, database, self.debug)
93
+ elif 'dbname' in db_config or 'host' in db_config:
94
+ from .postgres.handler import PostgresHandler
95
+ handler = PostgresHandler(self.config_path, database, self.debug)
96
+ else:
97
+ raise ValueError("Cannot determine database type, missing required parameters in configuration")
98
+
99
+ yield handler
100
+ finally:
101
+ if handler:
102
+ await handler.cleanup()
103
+
104
+ def _setup_handlers(self):
105
+ """Setup MCP handlers"""
106
+ @self.server.list_resources()
107
+ async def handle_list_resources(arguments: dict | None = None) -> list[types.Resource]:
108
+ if not arguments or 'database' not in arguments:
109
+ # Return empty list when no database specified
110
+ return []
111
+
112
+ database = arguments['database']
113
+ async with self.get_handler(database) as handler:
114
+ return await handler.get_tables()
115
+
116
+ @self.server.read_resource()
117
+ async def handle_read_resource(uri: str, arguments: dict | None = None) -> str:
118
+ if not arguments or 'database' not in arguments:
119
+ raise ValueError("Database configuration name must be specified")
120
+
121
+ parts = uri.split('/')
122
+ if len(parts) < 3:
123
+ raise ValueError("Invalid resource URI")
124
+
125
+ database = arguments['database']
126
+ table_name = parts[-2] # URI format: xxx/table_name/schema
127
+
128
+ async with self.get_handler(database) as handler:
129
+ return await handler.get_schema(table_name)
130
+
131
+ @self.server.list_tools()
132
+ async def handle_list_tools() -> list[types.Tool]:
133
+ return [
134
+ types.Tool(
135
+ name="query",
136
+ description="Execute read-only SQL query",
137
+ inputSchema={
138
+ "type": "object",
139
+ "properties": {
140
+ "database": {
141
+ "type": "string",
142
+ "description": "Database configuration name"
143
+ },
144
+ "sql": {
145
+ "type": "string",
146
+ "description": "SQL query (SELECT only)"
147
+ }
148
+ },
149
+ "required": ["database", "sql"]
150
+ }
151
+ )
152
+ ]
153
+
154
+ @self.server.call_tool()
155
+ async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
156
+ if name != "query":
157
+ raise ValueError(f"Unknown tool: {name}")
158
+
159
+ if "database" not in arguments:
160
+ raise ValueError("Database configuration name must be specified")
161
+
162
+ sql = arguments.get("sql", "").strip()
163
+ if not sql:
164
+ raise ValueError("SQL query cannot be empty")
165
+
166
+ # Only allow SELECT statements
167
+ if not sql.lower().startswith("select"):
168
+ raise ValueError("Only SELECT queries are supported")
169
+
170
+ database = arguments["database"]
171
+ async with self.get_handler(database) as handler:
172
+ result = await handler.execute_query(sql)
173
+ return [types.TextContent(type="text", text=result)]
174
+
175
+ async def run(self):
176
+ """Run server"""
177
+ async with mcp.server.stdio.stdio_server() as streams:
178
+ await self.server.run(
179
+ streams[0],
180
+ streams[1],
181
+ self.server.create_initialization_options()
182
+ )
mcp_dbutils/config.py ADDED
@@ -0,0 +1,59 @@
1
+ """Common configuration utilities"""
2
+
3
+ import os
4
+ import yaml
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional, Dict, Any, Literal
8
+ from pathlib import Path
9
+
10
+ # Supported database types
11
+ DBType = Literal['sqlite', 'postgres']
12
+
13
+ class DatabaseConfig(ABC):
14
+ """Base class for database configuration"""
15
+
16
+ debug: bool = False
17
+ type: DBType # Database type
18
+
19
+ @abstractmethod
20
+ def get_connection_params(self) -> Dict[str, Any]:
21
+ """Get connection parameters"""
22
+ pass
23
+
24
+ @abstractmethod
25
+ def get_masked_connection_info(self) -> Dict[str, Any]:
26
+ """Get masked connection information for logging"""
27
+ pass
28
+
29
+ @classmethod
30
+ def load_yaml_config(cls, yaml_path: str) -> Dict[str, Any]:
31
+ """Load YAML configuration file
32
+
33
+ Args:
34
+ yaml_path: Path to YAML file
35
+
36
+ Returns:
37
+ Parsed configuration dictionary
38
+ """
39
+ with open(yaml_path, 'r', encoding='utf-8') as f:
40
+ config = yaml.safe_load(f)
41
+
42
+ if not config or 'databases' not in config:
43
+ raise ValueError("Configuration file must contain 'databases' section")
44
+
45
+ # Validate type field in each database configuration
46
+ databases = config['databases']
47
+ for db_name, db_config in databases.items():
48
+ if 'type' not in db_config:
49
+ raise ValueError(f"Database configuration {db_name} missing required 'type' field")
50
+ db_type = db_config['type']
51
+ if db_type not in ('sqlite', 'postgres'):
52
+ raise ValueError(f"Invalid type value in database configuration {db_name}: {db_type}")
53
+
54
+ return databases
55
+
56
+ @classmethod
57
+ def get_debug_mode(cls) -> bool:
58
+ """Get debug mode status"""
59
+ return os.environ.get('MCP_DEBUG', '').lower() in ('1', 'true')
mcp_dbutils/log.py ADDED
@@ -0,0 +1,33 @@
1
+ """日志处理模块"""
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from typing import Callable, Optional
6
+
7
+ def create_logger(name: str, is_debug: bool = False) -> Callable:
8
+ """创建日志函数
9
+ Args:
10
+ name: 服务名称
11
+ is_debug: 是否输出debug级别日志
12
+ """
13
+ def log(level: str, message: str, notify: Optional[Callable] = None):
14
+ """输出日志
15
+ Args:
16
+ level: 日志级别 (debug/info/warning/error)
17
+ message: 日志内容
18
+ notify: MCP通知函数(可选)
19
+ """
20
+ if level == "debug" and not is_debug:
21
+ return
22
+
23
+ timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]
24
+ log_message = f"[{timestamp}] [{name}] [{level}] {message}"
25
+
26
+ # 始终输出到stderr
27
+ print(log_message, file=sys.stderr, flush=True)
28
+
29
+ # 如果提供了notify函数,同时发送MCP通知
30
+ if notify:
31
+ notify(level=level, data=message)
32
+
33
+ return log
@@ -0,0 +1,6 @@
1
+ """PostgreSQL module"""
2
+
3
+ from .handler import PostgresHandler
4
+ from .config import PostgresConfig
5
+
6
+ __all__ = ['PostgresHandler', 'PostgresConfig']
@@ -0,0 +1,66 @@
1
+ """PostgreSQL configuration module"""
2
+ from dataclasses import dataclass
3
+ from typing import Optional, Dict, Any, Literal
4
+ from ..config import DatabaseConfig
5
+
6
+ @dataclass
7
+ class PostgresConfig(DatabaseConfig):
8
+ dbname: str
9
+ user: str
10
+ password: str
11
+ host: str = 'localhost'
12
+ port: str = '5432'
13
+ local_host: Optional[str] = None
14
+ type: Literal['postgres'] = 'postgres'
15
+
16
+ @classmethod
17
+ def from_yaml(cls, yaml_path: str, db_name: str, local_host: Optional[str] = None) -> 'PostgresConfig':
18
+ """Create configuration from YAML file
19
+
20
+ Args:
21
+ yaml_path: Path to YAML configuration file
22
+ db_name: Database configuration name to use
23
+ local_host: Optional local host address
24
+ """
25
+ configs = cls.load_yaml_config(yaml_path)
26
+ if not db_name:
27
+ raise ValueError("Database name must be specified")
28
+ if db_name not in configs:
29
+ available_dbs = list(configs.keys())
30
+ raise ValueError(f"Database configuration not found: {db_name}. Available configurations: {available_dbs}")
31
+
32
+ db_config = configs[db_name]
33
+ if 'type' not in db_config:
34
+ raise ValueError("Database configuration must include 'type' field")
35
+ if db_config['type'] != 'postgres':
36
+ raise ValueError(f"Configuration is not PostgreSQL type: {db_config['type']}")
37
+
38
+ config = cls(
39
+ dbname=db_config.get('dbname', ''),
40
+ user=db_config.get('user', ''),
41
+ password=db_config.get('password', ''),
42
+ host=db_config.get('host', 'localhost'),
43
+ port=str(db_config.get('port', 5432)),
44
+ local_host=local_host,
45
+ )
46
+ config.debug = cls.get_debug_mode()
47
+ return config
48
+
49
+ def get_connection_params(self) -> Dict[str, Any]:
50
+ """Get psycopg2 connection parameters"""
51
+ params = {
52
+ 'dbname': self.dbname,
53
+ 'user': self.user,
54
+ 'password': self.password,
55
+ 'host': self.local_host or self.host,
56
+ 'port': self.port
57
+ }
58
+ return {k: v for k, v in params.items() if v}
59
+
60
+ def get_masked_connection_info(self) -> Dict[str, Any]:
61
+ """Return masked connection information for logging"""
62
+ return {
63
+ 'dbname': self.dbname,
64
+ 'host': self.local_host or self.host,
65
+ 'port': self.port
66
+ }
@@ -0,0 +1,150 @@
1
+ """PostgreSQL database handler implementation"""
2
+
3
+ import psycopg2
4
+ from psycopg2.pool import SimpleConnectionPool
5
+ import mcp.types as types
6
+
7
+ from ..base import DatabaseHandler
8
+ from .config import PostgresConfig
9
+
10
+ class PostgresHandler(DatabaseHandler):
11
+ def __init__(self, config_path: str, database: str, debug: bool = False):
12
+ """Initialize PostgreSQL handler
13
+
14
+ Args:
15
+ config_path: Path to configuration file
16
+ database: Database configuration name
17
+ debug: Enable debug mode
18
+ """
19
+ super().__init__(config_path, database, debug)
20
+ self.config = PostgresConfig.from_yaml(config_path, database)
21
+
22
+ # No connection pool creation during initialization
23
+ masked_params = self.config.get_masked_connection_info()
24
+ self.log("debug", f"Configuring database with parameters: {masked_params}")
25
+ self.pool = None
26
+
27
+ async def get_tables(self) -> list[types.Resource]:
28
+ """Get all table resources"""
29
+ try:
30
+ conn_params = self.config.get_connection_params()
31
+ conn = psycopg2.connect(**conn_params)
32
+ with conn.cursor() as cur:
33
+ cur.execute("""
34
+ SELECT
35
+ table_name,
36
+ obj_description(
37
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
38
+ 'pg_class'
39
+ ) as description
40
+ FROM information_schema.tables
41
+ WHERE table_schema = 'public'
42
+ """)
43
+ tables = cur.fetchall()
44
+ return [
45
+ types.Resource(
46
+ uri=f"postgres://{self.database}/{table[0]}/schema",
47
+ name=f"{table[0]} schema",
48
+ description=table[1] if table[1] else None,
49
+ mimeType="application/json"
50
+ ) for table in tables
51
+ ]
52
+ except psycopg2.Error as e:
53
+ error_msg = f"Failed to get table list: [Code: {e.pgcode}] {e.pgerror or str(e)}"
54
+ self.log("error", error_msg)
55
+ raise
56
+ finally:
57
+ if conn:
58
+ conn.close()
59
+
60
+ async def get_schema(self, table_name: str) -> str:
61
+ """Get table schema information"""
62
+ try:
63
+ conn_params = self.config.get_connection_params()
64
+ conn = psycopg2.connect(**conn_params)
65
+ with conn.cursor() as cur:
66
+ # Get column information
67
+ cur.execute("""
68
+ SELECT
69
+ column_name,
70
+ data_type,
71
+ is_nullable,
72
+ col_description(
73
+ (quote_ident(table_schema) || '.' || quote_ident(table_name))::regclass,
74
+ ordinal_position
75
+ ) as description
76
+ FROM information_schema.columns
77
+ WHERE table_name = %s
78
+ ORDER BY ordinal_position
79
+ """, (table_name,))
80
+ columns = cur.fetchall()
81
+
82
+ # Get constraint information
83
+ cur.execute("""
84
+ SELECT
85
+ conname as constraint_name,
86
+ contype as constraint_type
87
+ FROM pg_constraint c
88
+ JOIN pg_class t ON c.conrelid = t.oid
89
+ WHERE t.relname = %s
90
+ """, (table_name,))
91
+ constraints = cur.fetchall()
92
+
93
+ return str({
94
+ 'columns': [{
95
+ 'name': col[0],
96
+ 'type': col[1],
97
+ 'nullable': col[2] == 'YES',
98
+ 'description': col[3]
99
+ } for col in columns],
100
+ 'constraints': [{
101
+ 'name': con[0],
102
+ 'type': con[1]
103
+ } for con in constraints]
104
+ })
105
+ except psycopg2.Error as e:
106
+ error_msg = f"Failed to read table schema: [Code: {e.pgcode}] {e.pgerror or str(e)}"
107
+ self.log("error", error_msg)
108
+ raise
109
+ finally:
110
+ if conn:
111
+ conn.close()
112
+
113
+ async def execute_query(self, sql: str) -> str:
114
+ """Execute SQL query"""
115
+ try:
116
+ conn_params = self.config.get_connection_params()
117
+ conn = psycopg2.connect(**conn_params)
118
+ self.log("info", f"Executing query: {sql}")
119
+
120
+ with conn.cursor() as cur:
121
+ # Start read-only transaction
122
+ cur.execute("BEGIN TRANSACTION READ ONLY")
123
+ try:
124
+ cur.execute(sql)
125
+ results = cur.fetchall()
126
+ columns = [desc[0] for desc in cur.description]
127
+ formatted_results = [dict(zip(columns, row)) for row in results]
128
+
129
+ result_text = str({
130
+ 'columns': columns,
131
+ 'rows': formatted_results,
132
+ 'row_count': len(results)
133
+ })
134
+
135
+ self.log("info", f"Query completed, returned {len(results)} rows")
136
+ return result_text
137
+ finally:
138
+ cur.execute("ROLLBACK")
139
+ except psycopg2.Error as e:
140
+ error_msg = f"Query execution failed: [Code: {e.pgcode}] {e.pgerror or str(e)}"
141
+ self.log("error", error_msg)
142
+ raise
143
+ finally:
144
+ if conn:
145
+ conn.close()
146
+
147
+ async def cleanup(self):
148
+ """Cleanup resources"""
149
+ # No special cleanup needed since we're not using connection pool
150
+ pass