v0.4.1 preview
This commit is contained in:
@@ -43,6 +43,8 @@ MAX_RESPONSE_CONTENT_SIZE=4096
|
||||
# Security Configuration
|
||||
# =============================================================================
|
||||
|
||||
ENABLE_SECURITY_CHECK=true
|
||||
BLOCKED_KEYWORDS="DROP,TRUNCATE,DELETE,SHUTDOWN,INSERT,UPDATE,CREATE,ALTER,GRANT,REVOKE,KILL"
|
||||
AUTH_TYPE=token
|
||||
TOKEN_SECRET=your_secret_key_here
|
||||
TOKEN_EXPIRY=3600
|
||||
@@ -84,5 +86,5 @@ ALERT_WEBHOOK_URL=
|
||||
# =============================================================================
|
||||
|
||||
SERVER_NAME=doris-mcp-server
|
||||
SERVER_VERSION=0.3.0
|
||||
SERVER_VERSION=0.4.1
|
||||
SERVER_PORT=3000
|
||||
|
||||
@@ -70,17 +70,26 @@ class SecurityConfig:
|
||||
token_expiry: int = 3600
|
||||
|
||||
# SQL security configuration
|
||||
enable_security_check: bool = True # Main switch: whether to enable SQL security check
|
||||
blocked_keywords: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
# DDL Operations (Data Definition Language)
|
||||
"DROP",
|
||||
"DELETE",
|
||||
"TRUNCATE",
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"TRUNCATE",
|
||||
# DML Operations (Data Manipulation Language)
|
||||
"DELETE",
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
# DCL Operations (Data Control Language)
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
# System Operations
|
||||
"EXEC",
|
||||
"EXECUTE",
|
||||
"SHUTDOWN",
|
||||
"KILL",
|
||||
]
|
||||
)
|
||||
max_query_complexity: int = 100
|
||||
@@ -154,7 +163,7 @@ class DorisConfig:
|
||||
|
||||
# Basic configuration
|
||||
server_name: str = "doris-mcp-server"
|
||||
server_version: str = "0.4.0"
|
||||
server_version: str = "0.4.1"
|
||||
server_port: int = 3000
|
||||
transport: str = "stdio"
|
||||
|
||||
@@ -267,6 +276,22 @@ class DorisConfig:
|
||||
config.security.max_query_complexity = int(
|
||||
os.getenv("MAX_QUERY_COMPLEXITY", str(config.security.max_query_complexity))
|
||||
)
|
||||
config.security.enable_security_check = (
|
||||
os.getenv("ENABLE_SECURITY_CHECK", str(config.security.enable_security_check).lower()).lower() == "true"
|
||||
)
|
||||
|
||||
# Handle blocked keywords environment variable configuration
|
||||
# Format: BLOCKED_KEYWORDS="DROP,DELETE,TRUNCATE,ALTER,CREATE,INSERT,UPDATE,GRANT,REVOKE"
|
||||
blocked_keywords_env = os.getenv("BLOCKED_KEYWORDS", "")
|
||||
if blocked_keywords_env:
|
||||
# If environment variable is provided, use keywords list from environment variable
|
||||
config.security.blocked_keywords = [
|
||||
keyword.strip().upper()
|
||||
for keyword in blocked_keywords_env.split(",")
|
||||
if keyword.strip()
|
||||
]
|
||||
# If environment variable is empty, keep default configuration unchanged
|
||||
|
||||
config.security.enable_masking = (
|
||||
os.getenv("ENABLE_MASKING", str(config.security.enable_masking).lower()).lower() == "true"
|
||||
)
|
||||
@@ -399,6 +424,7 @@ class DorisConfig:
|
||||
"auth_type": self.security.auth_type,
|
||||
"token_secret": "***", # Hide secret key
|
||||
"token_expiry": self.security.token_expiry,
|
||||
"enable_security_check": self.security.enable_security_check,
|
||||
"blocked_keywords": self.security.blocked_keywords,
|
||||
"max_query_complexity": self.security.max_query_complexity,
|
||||
"max_result_rows": self.security.max_result_rows,
|
||||
|
||||
@@ -142,11 +142,22 @@ class DorisConnection:
|
||||
self.is_healthy = False
|
||||
return False
|
||||
|
||||
# Check if connection has _reader (aiomysql internal state)
|
||||
# This prevents the 'NoneType' object has no attribute 'at_eof' error
|
||||
if not hasattr(self.connection, '_reader') or self.connection._reader is None:
|
||||
self.is_healthy = False
|
||||
return False
|
||||
|
||||
# Additional check for reader's state
|
||||
if hasattr(self.connection._reader, '_transport') and self.connection._reader._transport is None:
|
||||
self.is_healthy = False
|
||||
return False
|
||||
|
||||
# Try to ping the connection
|
||||
await self.connection.ping()
|
||||
self.is_healthy = True
|
||||
return True
|
||||
except Exception as e:
|
||||
except (AttributeError, OSError, ConnectionError, Exception) as e:
|
||||
# Log the specific error for debugging
|
||||
logging.debug(f"Connection ping failed for session {self.session_id}: {e}")
|
||||
self.is_healthy = False
|
||||
@@ -309,15 +320,34 @@ class DorisConnectionManager:
|
||||
if session_id in self.session_connections:
|
||||
conn = self.session_connections[session_id]
|
||||
try:
|
||||
# Return connection to pool
|
||||
if self.pool and conn.connection and not conn.connection.closed:
|
||||
self.pool.release(conn.connection)
|
||||
# Return connection to pool only if it's valid and not closed
|
||||
if (self.pool and
|
||||
conn.connection and
|
||||
not conn.connection.closed and
|
||||
hasattr(conn.connection, '_reader') and
|
||||
conn.connection._reader is not None):
|
||||
try:
|
||||
# Try to gracefully return to pool
|
||||
self.pool.release(conn.connection)
|
||||
except Exception as pool_error:
|
||||
self.logger.debug(f"Failed to return connection to pool for session {session_id}: {pool_error}")
|
||||
# If pool release fails, try to close the connection directly
|
||||
try:
|
||||
await conn.connection.ensure_closed()
|
||||
except Exception:
|
||||
pass # Ignore errors during forced close
|
||||
|
||||
# Close connection wrapper
|
||||
await conn.close()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error cleaning up connection for session {session_id}: {e}")
|
||||
# Force close if normal cleanup fails
|
||||
try:
|
||||
if conn.connection and not conn.connection.closed:
|
||||
await conn.connection.ensure_closed()
|
||||
except Exception:
|
||||
pass # Ignore errors during forced close
|
||||
finally:
|
||||
# Remove from session connections
|
||||
del self.session_connections[session_id]
|
||||
@@ -339,12 +369,26 @@ class DorisConnectionManager:
|
||||
try:
|
||||
unhealthy_sessions = []
|
||||
|
||||
# First pass: check basic connectivity
|
||||
for session_id, conn in self.session_connections.items():
|
||||
if not await conn.ping():
|
||||
unhealthy_sessions.append(session_id)
|
||||
|
||||
# Clean up unhealthy connections
|
||||
for session_id in unhealthy_sessions:
|
||||
# Second pass: check for stale connections (over 30 minutes old)
|
||||
current_time = datetime.utcnow()
|
||||
stale_sessions = []
|
||||
for session_id, conn in self.session_connections.items():
|
||||
if session_id not in unhealthy_sessions: # Don't double-check
|
||||
last_used_delta = (current_time - conn.last_used).total_seconds()
|
||||
if last_used_delta > 1800: # 30 minutes
|
||||
# Force a ping check for stale connections
|
||||
if not await conn.ping():
|
||||
stale_sessions.append(session_id)
|
||||
|
||||
all_problematic_sessions = list(set(unhealthy_sessions + stale_sessions))
|
||||
|
||||
# Clean up problematic connections
|
||||
for session_id in all_problematic_sessions:
|
||||
await self._cleanup_session_connection(session_id)
|
||||
self.metrics.failed_connections += 1
|
||||
|
||||
@@ -352,11 +396,19 @@ class DorisConnectionManager:
|
||||
await self._update_connection_metrics()
|
||||
self.metrics.last_health_check = datetime.utcnow()
|
||||
|
||||
if unhealthy_sessions:
|
||||
self.logger.warning(f"Cleaned up {len(unhealthy_sessions)} unhealthy connections")
|
||||
if all_problematic_sessions:
|
||||
self.logger.warning(f"Health check: cleaned up {len(unhealthy_sessions)} unhealthy and {len(stale_sessions)} stale connections")
|
||||
else:
|
||||
self.logger.debug(f"Health check: all {len(self.session_connections)} connections healthy")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Health check failed: {e}")
|
||||
# If health check fails, try to diagnose the issue
|
||||
try:
|
||||
diagnosis = await self.diagnose_connection_health()
|
||||
self.logger.error(f"Connection diagnosis: {diagnosis}")
|
||||
except Exception:
|
||||
pass # Don't let diagnosis failure crash health check
|
||||
|
||||
async def _cleanup_loop(self):
|
||||
"""Background cleanup loop"""
|
||||
@@ -463,6 +515,93 @@ class DorisConnectionManager:
|
||||
self.logger.error(f"Connection test failed: {e}")
|
||||
return False
|
||||
|
||||
async def diagnose_connection_health(self) -> Dict[str, Any]:
|
||||
"""Diagnose connection pool and session health"""
|
||||
diagnosis = {
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"pool_status": "unknown",
|
||||
"session_connections": {},
|
||||
"problematic_connections": [],
|
||||
"recommendations": []
|
||||
}
|
||||
|
||||
try:
|
||||
# Check pool status
|
||||
if not self.pool:
|
||||
diagnosis["pool_status"] = "not_initialized"
|
||||
diagnosis["recommendations"].append("Initialize connection pool")
|
||||
return diagnosis
|
||||
|
||||
if self.pool.closed:
|
||||
diagnosis["pool_status"] = "closed"
|
||||
diagnosis["recommendations"].append("Recreate connection pool")
|
||||
return diagnosis
|
||||
|
||||
diagnosis["pool_status"] = "healthy"
|
||||
diagnosis["pool_info"] = {
|
||||
"size": self.pool.size,
|
||||
"free_size": self.pool.freesize,
|
||||
"min_size": self.pool.minsize,
|
||||
"max_size": self.pool.maxsize
|
||||
}
|
||||
|
||||
# Check session connections
|
||||
problematic_sessions = []
|
||||
for session_id, conn in self.session_connections.items():
|
||||
conn_status = {
|
||||
"session_id": session_id,
|
||||
"created_at": conn.created_at.isoformat(),
|
||||
"last_used": conn.last_used.isoformat(),
|
||||
"query_count": conn.query_count,
|
||||
"is_healthy": conn.is_healthy
|
||||
}
|
||||
|
||||
# Detailed connection checks
|
||||
if conn.connection:
|
||||
conn_status["connection_closed"] = conn.connection.closed
|
||||
conn_status["has_reader"] = hasattr(conn.connection, '_reader') and conn.connection._reader is not None
|
||||
|
||||
if hasattr(conn.connection, '_reader') and conn.connection._reader:
|
||||
conn_status["reader_transport"] = conn.connection._reader._transport is not None
|
||||
else:
|
||||
conn_status["reader_transport"] = False
|
||||
else:
|
||||
conn_status["connection_closed"] = True
|
||||
conn_status["has_reader"] = False
|
||||
conn_status["reader_transport"] = False
|
||||
|
||||
# Check if connection is problematic
|
||||
if (not conn.is_healthy or
|
||||
conn_status["connection_closed"] or
|
||||
not conn_status["has_reader"] or
|
||||
not conn_status["reader_transport"]):
|
||||
problematic_sessions.append(session_id)
|
||||
diagnosis["problematic_connections"].append(conn_status)
|
||||
|
||||
diagnosis["session_connections"][session_id] = conn_status
|
||||
|
||||
# Generate recommendations
|
||||
if problematic_sessions:
|
||||
diagnosis["recommendations"].append(f"Clean up {len(problematic_sessions)} problematic connections")
|
||||
|
||||
if self.pool.freesize == 0 and self.pool.size >= self.pool.maxsize:
|
||||
diagnosis["recommendations"].append("Connection pool exhausted - consider increasing max_connections")
|
||||
|
||||
# Auto-cleanup problematic connections
|
||||
for session_id in problematic_sessions:
|
||||
try:
|
||||
await self._cleanup_session_connection(session_id)
|
||||
self.logger.info(f"Auto-cleaned problematic connection for session: {session_id}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to auto-clean session {session_id}: {e}")
|
||||
|
||||
return diagnosis
|
||||
|
||||
except Exception as e:
|
||||
diagnosis["error"] = str(e)
|
||||
diagnosis["recommendations"].append("Manual intervention required")
|
||||
return diagnosis
|
||||
|
||||
|
||||
class ConnectionPoolMonitor:
|
||||
"""Connection pool monitor
|
||||
|
||||
@@ -548,79 +548,127 @@ class DorisQueryExecutor:
|
||||
user_id: str = "mcp_user"
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute SQL query for MCP interface - unified method"""
|
||||
try:
|
||||
if not sql:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "SQL query is required",
|
||||
"data": None
|
||||
}
|
||||
max_retries = 2
|
||||
retry_count = 0
|
||||
|
||||
while retry_count <= max_retries:
|
||||
try:
|
||||
if not sql:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "SQL query is required",
|
||||
"data": None
|
||||
}
|
||||
|
||||
# Add LIMIT if not present and it's a SELECT query
|
||||
if sql.upper().startswith("SELECT") and "LIMIT" not in sql.upper():
|
||||
if sql.endswith(";"):
|
||||
sql = sql[:-1]
|
||||
sql = f"{sql} LIMIT {limit}"
|
||||
# Add LIMIT if not present and it's a SELECT query
|
||||
if sql.upper().startswith("SELECT") and "LIMIT" not in sql.upper():
|
||||
if sql.endswith(";"):
|
||||
sql = sql[:-1]
|
||||
sql = f"{sql} LIMIT {limit}"
|
||||
|
||||
# Create auth context for MCP calls
|
||||
class MockAuthContext:
|
||||
def __init__(self):
|
||||
self.user_id = user_id
|
||||
self.roles = ["data_analyst"]
|
||||
self.permissions = ["read_data", "execute_query"]
|
||||
self.session_id = session_id
|
||||
self.security_level = "internal"
|
||||
# Create auth context for MCP calls
|
||||
class MockAuthContext:
|
||||
def __init__(self):
|
||||
self.user_id = user_id
|
||||
self.roles = ["data_analyst"]
|
||||
self.permissions = ["read_data", "execute_query"]
|
||||
self.session_id = session_id
|
||||
self.security_level = "internal"
|
||||
|
||||
auth_context = MockAuthContext()
|
||||
|
||||
# Create query request
|
||||
query_request = QueryRequest(
|
||||
sql=sql,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
timeout=timeout,
|
||||
cache_enabled=True
|
||||
)
|
||||
|
||||
# Execute query
|
||||
result = await self.execute_query(query_request, auth_context)
|
||||
|
||||
# Process results
|
||||
processed_data = []
|
||||
if result.data:
|
||||
for row in result.data:
|
||||
processed_row = self._serialize_row_data(row)
|
||||
processed_data.append(processed_row)
|
||||
auth_context = MockAuthContext()
|
||||
|
||||
# Create query request
|
||||
query_request = QueryRequest(
|
||||
sql=sql,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
timeout=timeout,
|
||||
cache_enabled=False # Disable cache for MCP calls to ensure fresh data
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": processed_data,
|
||||
"metadata": {
|
||||
"row_count": result.row_count,
|
||||
"execution_time": result.execution_time,
|
||||
"columns": result.metadata.get("columns", []),
|
||||
"query": sql
|
||||
},
|
||||
"error": None
|
||||
}
|
||||
# Execute query with retry logic
|
||||
try:
|
||||
result = await self.execute_query(query_request, auth_context)
|
||||
|
||||
# Serialize data for JSON response
|
||||
serialized_data = []
|
||||
for row in result.data:
|
||||
serialized_data.append(self._serialize_row_data(row))
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
self.logger.error(f"SQL execution error: {error_msg}")
|
||||
|
||||
# Analyze error for better user feedback
|
||||
error_analysis = self._analyze_error(error_msg)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_analysis.get("user_message", error_msg),
|
||||
"error_type": error_analysis.get("error_type", "execution_error"),
|
||||
"data": None,
|
||||
"metadata": {
|
||||
"query": sql,
|
||||
"error_details": error_msg
|
||||
}
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"data": serialized_data,
|
||||
"row_count": result.row_count,
|
||||
"execution_time": result.execution_time,
|
||||
"metadata": {
|
||||
"columns": result.metadata.get("columns", []),
|
||||
"query": sql
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as query_error:
|
||||
# Check if it's a connection-related error that we should retry
|
||||
error_str = str(query_error).lower()
|
||||
connection_errors = [
|
||||
"at_eof", "connection", "closed", "nonetype",
|
||||
"transport", "reader", "broken pipe", "connection reset"
|
||||
]
|
||||
|
||||
is_connection_error = any(err in error_str for err in connection_errors)
|
||||
|
||||
if is_connection_error and retry_count < max_retries:
|
||||
retry_count += 1
|
||||
self.logger.warning(f"Connection error detected, retrying ({retry_count}/{max_retries}): {query_error}")
|
||||
|
||||
# Release the problematic connection
|
||||
try:
|
||||
await self.connection_manager.release_connection(session_id)
|
||||
except Exception:
|
||||
pass # Ignore cleanup errors
|
||||
|
||||
# Wait a bit before retry
|
||||
await asyncio.sleep(0.5 * retry_count)
|
||||
continue
|
||||
else:
|
||||
# Re-raise if not a connection error or max retries exceeded
|
||||
raise query_error
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
|
||||
# If we've exhausted retries or it's not a connection error, return error
|
||||
if retry_count >= max_retries or "at_eof" not in error_msg.lower():
|
||||
error_analysis = self._analyze_error(error_msg)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": error_analysis.get("user_message", error_msg),
|
||||
"error_type": error_analysis.get("error_type", "general_error"),
|
||||
"data": None,
|
||||
"metadata": {
|
||||
"query": sql,
|
||||
"error_details": error_msg,
|
||||
"retry_count": retry_count
|
||||
}
|
||||
}
|
||||
else:
|
||||
# Try one more time for connection errors
|
||||
retry_count += 1
|
||||
if retry_count <= max_retries:
|
||||
self.logger.warning(f"Retrying query due to connection error ({retry_count}/{max_retries}): {e}")
|
||||
await asyncio.sleep(0.5 * retry_count)
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Query failed after {max_retries} retries: {error_msg}",
|
||||
"data": None,
|
||||
"metadata": {
|
||||
"query": sql,
|
||||
"error_details": error_msg,
|
||||
"retry_count": retry_count
|
||||
}
|
||||
}
|
||||
|
||||
def _serialize_row_data(self, row_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Serialize row data for JSON response"""
|
||||
@@ -649,7 +697,12 @@ class DorisQueryExecutor:
|
||||
"""Analyze error message and provide user-friendly feedback"""
|
||||
error_msg_lower = error_message.lower()
|
||||
|
||||
if "table" in error_msg_lower and "doesn't exist" in error_msg_lower:
|
||||
if "at_eof" in error_msg_lower or "nonetype" in error_msg_lower and "at_eof" in error_msg_lower:
|
||||
return {
|
||||
"error_type": "connection_lost",
|
||||
"user_message": "Database connection was lost. The query has been automatically retried. If this persists, please restart the server."
|
||||
}
|
||||
elif "table" in error_msg_lower and "doesn't exist" in error_msg_lower:
|
||||
return {
|
||||
"error_type": "table_not_found",
|
||||
"user_message": "The specified table does not exist. Please check the table name and database."
|
||||
@@ -674,6 +727,11 @@ class DorisQueryExecutor:
|
||||
"error_type": "timeout",
|
||||
"user_message": "Query execution timed out. Try simplifying your query or adding more specific filters."
|
||||
}
|
||||
elif "connection" in error_msg_lower and ("closed" in error_msg_lower or "reset" in error_msg_lower):
|
||||
return {
|
||||
"error_type": "connection_error",
|
||||
"user_message": "Database connection was interrupted. The query has been automatically retried."
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"error_type": "general_error",
|
||||
|
||||
@@ -20,7 +20,6 @@ Doris Security Management Module
|
||||
Implements enterprise-level authentication, authorization, SQL security validation and data masking functionality
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -101,30 +100,24 @@ class DorisSecurityManager:
|
||||
self.masking_rules = self._load_masking_rules()
|
||||
|
||||
def _load_blocked_keywords(self) -> set[str]:
|
||||
"""Load blocked SQL keywords"""
|
||||
default_blocked = {
|
||||
"DROP",
|
||||
"DELETE",
|
||||
"TRUNCATE",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"EXEC",
|
||||
"EXECUTE",
|
||||
"SHUTDOWN",
|
||||
"KILL",
|
||||
}
|
||||
|
||||
# Load custom rules from configuration file
|
||||
"""Load blocked SQL keywords from configuration"""
|
||||
# Load keywords from configuration, unified source of truth
|
||||
if hasattr(self.config, 'get'):
|
||||
custom_blocked = set(self.config.get("blocked_keywords", []))
|
||||
# Dictionary-style configuration
|
||||
blocked_keywords = self.config.get("blocked_keywords", [])
|
||||
elif hasattr(self.config, 'security') and hasattr(self.config.security, 'blocked_keywords'):
|
||||
# DorisConfig object, get through security.blocked_keywords
|
||||
blocked_keywords = self.config.security.blocked_keywords
|
||||
else:
|
||||
custom_blocked = set()
|
||||
# Fallback to default if no configuration available
|
||||
blocked_keywords = [
|
||||
"DROP", "CREATE", "ALTER", "TRUNCATE",
|
||||
"DELETE", "INSERT", "UPDATE",
|
||||
"GRANT", "REVOKE",
|
||||
"EXEC", "EXECUTE", "SHUTDOWN", "KILL"
|
||||
]
|
||||
|
||||
return default_blocked.union(custom_blocked)
|
||||
return set(blocked_keywords)
|
||||
|
||||
def _load_sensitive_tables(self) -> dict[str, SecurityLevel]:
|
||||
"""Load sensitive table configuration"""
|
||||
@@ -478,13 +471,30 @@ class SQLSecurityValidator:
|
||||
# Dictionary configuration
|
||||
self.blocked_keywords = set(config.get("blocked_keywords", []))
|
||||
self.max_query_complexity = config.get("max_query_complexity", 100)
|
||||
self.enable_security_check = config.get("enable_security_check", True)
|
||||
elif hasattr(config, 'security'):
|
||||
# DorisConfig object with security attribute - unified source from config
|
||||
self.blocked_keywords = set(config.security.blocked_keywords)
|
||||
self.max_query_complexity = config.security.max_query_complexity
|
||||
self.enable_security_check = getattr(config.security, 'enable_security_check', True)
|
||||
else:
|
||||
# DorisConfig object, use default values
|
||||
self.blocked_keywords = set(["DROP", "DELETE", "TRUNCATE", "ALTER", "CREATE", "INSERT", "UPDATE"])
|
||||
# Fallback to default if no configuration available
|
||||
self.blocked_keywords = set([
|
||||
"DROP", "CREATE", "ALTER", "TRUNCATE",
|
||||
"DELETE", "INSERT", "UPDATE",
|
||||
"GRANT", "REVOKE",
|
||||
"EXEC", "EXECUTE", "SHUTDOWN", "KILL"
|
||||
])
|
||||
self.max_query_complexity = 100
|
||||
self.enable_security_check = True
|
||||
|
||||
async def validate(self, sql: str, auth_context: AuthContext) -> ValidationResult:
|
||||
"""Validate SQL query security"""
|
||||
# If security check is disabled, always return valid
|
||||
if not self.enable_security_check:
|
||||
self.logger.debug("SQL security check is disabled, allowing all queries")
|
||||
return ValidationResult(is_valid=True)
|
||||
|
||||
try:
|
||||
# Parse SQL statement
|
||||
parsed = sqlparse.parse(sql)[0]
|
||||
|
||||
328
uv.lock
generated
328
uv.lock
generated
@@ -518,170 +518,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "doris-mcp-server"
|
||||
version = "0.3.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiomysql" },
|
||||
{ name = "aioredis" },
|
||||
{ name = "asyncio-mqtt" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "numpy" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pandas" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "toml" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "bandit" },
|
||||
{ name = "black" },
|
||||
{ name = "flake8" },
|
||||
{ name = "isort" },
|
||||
{ name = "mypy" },
|
||||
{ name = "myst-parser" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "ruff" },
|
||||
{ name = "safety" },
|
||||
{ name = "sphinx" },
|
||||
{ name = "sphinx-rtd-theme" },
|
||||
{ name = "tox" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "myst-parser" },
|
||||
{ name = "sphinx" },
|
||||
{ name = "sphinx-autoapi" },
|
||||
{ name = "sphinx-rtd-theme" },
|
||||
]
|
||||
monitoring = [
|
||||
{ name = "grafana-client" },
|
||||
{ name = "jaeger-client" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
performance = [
|
||||
{ name = "cchardet" },
|
||||
{ name = "orjson" },
|
||||
{ name = "uvloop" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=23.0.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.9.0" },
|
||||
{ name = "aiomysql", specifier = ">=0.2.0" },
|
||||
{ name = "aioredis", specifier = ">=2.0.0" },
|
||||
{ name = "asyncio-mqtt", specifier = ">=0.16.0" },
|
||||
{ name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.1.0" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.12.0" },
|
||||
{ name = "cchardet", marker = "extra == 'performance'", specifier = ">=2.1.0" },
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.108.0" },
|
||||
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "grafana-client", marker = "extra == 'monitoring'", specifier = ">=3.5.0" },
|
||||
{ name = "httpx", specifier = ">=0.26.0" },
|
||||
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.0" },
|
||||
{ name = "jaeger-client", marker = "extra == 'monitoring'", specifier = ">=4.8.0" },
|
||||
{ name = "mcp", specifier = ">=1.8.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
||||
{ name = "myst-parser", marker = "extra == 'dev'", specifier = ">=2.0.0" },
|
||||
{ name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "opentelemetry-api", marker = "extra == 'monitoring'", specifier = ">=1.21.0" },
|
||||
{ name = "opentelemetry-sdk", marker = "extra == 'monitoring'", specifier = ">=1.21.0" },
|
||||
{ name = "orjson", specifier = ">=3.9.0" },
|
||||
{ name = "orjson", marker = "extra == 'performance'", specifier = ">=3.9.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.19.0" },
|
||||
{ name = "prometheus-client", marker = "extra == 'monitoring'", specifier = ">=0.19.0" },
|
||||
{ name = "pydantic", specifier = ">=2.5.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "pymysql", specifier = ">=1.1.0" },
|
||||
{ name = "pytest", specifier = ">=8.4.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.8.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "rich", specifier = ">=13.7.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "safety", marker = "extra == 'dev'", specifier = ">=2.3.0" },
|
||||
{ name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.2.0" },
|
||||
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.2.0" },
|
||||
{ name = "sphinx-autoapi", marker = "extra == 'docs'", specifier = ">=3.0.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = ">=2.0.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=2.0.0" },
|
||||
{ name = "sqlparse", specifier = ">=0.4.4" },
|
||||
{ name = "starlette", specifier = ">=0.27.0" },
|
||||
{ name = "structlog", specifier = ">=23.2.0" },
|
||||
{ name = "toml", specifier = ">=0.10.0" },
|
||||
{ name = "tox", marker = "extra == 'dev'", specifier = ">=4.11.0" },
|
||||
{ name = "tqdm", specifier = ">=4.66.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.25.0" },
|
||||
{ name = "uvloop", marker = "extra == 'performance'", specifier = ">=0.19.0" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["dev", "docs", "performance", "monitoring"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.11.13" }]
|
||||
|
||||
[[package]]
|
||||
name = "dparse"
|
||||
version = "0.6.4"
|
||||
@@ -1110,6 +946,170 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/45/823ad05504bea55cb0feb7470387f151252127ad5c72f8882e8fe6cf5c0e/mcp-1.9.3-py3-none-any.whl", hash = "sha256:69b0136d1ac9927402ed4cf221d4b8ff875e7132b0b06edd446448766f34f9b9", size = 131063 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp-doris-server"
|
||||
version = "0.4.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiomysql" },
|
||||
{ name = "aioredis" },
|
||||
{ name = "asyncio-mqtt" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "click" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "numpy" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pandas" },
|
||||
{ name = "passlib", extra = ["bcrypt"] },
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-jose", extra = ["cryptography"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "rich" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "starlette" },
|
||||
{ name = "structlog" },
|
||||
{ name = "toml" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "bandit" },
|
||||
{ name = "black" },
|
||||
{ name = "flake8" },
|
||||
{ name = "isort" },
|
||||
{ name = "mypy" },
|
||||
{ name = "myst-parser" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-xdist" },
|
||||
{ name = "ruff" },
|
||||
{ name = "safety" },
|
||||
{ name = "sphinx" },
|
||||
{ name = "sphinx-rtd-theme" },
|
||||
{ name = "tox" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "myst-parser" },
|
||||
{ name = "sphinx" },
|
||||
{ name = "sphinx-autoapi" },
|
||||
{ name = "sphinx-rtd-theme" },
|
||||
]
|
||||
monitoring = [
|
||||
{ name = "grafana-client" },
|
||||
{ name = "jaeger-client" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "prometheus-client" },
|
||||
]
|
||||
performance = [
|
||||
{ name = "cchardet" },
|
||||
{ name = "orjson" },
|
||||
{ name = "uvloop" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=23.0.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.9.0" },
|
||||
{ name = "aiomysql", specifier = ">=0.2.0" },
|
||||
{ name = "aioredis", specifier = ">=2.0.0" },
|
||||
{ name = "asyncio-mqtt", specifier = ">=0.16.0" },
|
||||
{ name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" },
|
||||
{ name = "bcrypt", specifier = ">=4.1.0" },
|
||||
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.12.0" },
|
||||
{ name = "cchardet", marker = "extra == 'performance'", specifier = ">=2.1.0" },
|
||||
{ name = "click", specifier = ">=8.1.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.108.0" },
|
||||
{ name = "flake8", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "grafana-client", marker = "extra == 'monitoring'", specifier = ">=3.5.0" },
|
||||
{ name = "httpx", specifier = ">=0.26.0" },
|
||||
{ name = "isort", marker = "extra == 'dev'", specifier = ">=5.13.0" },
|
||||
{ name = "jaeger-client", marker = "extra == 'monitoring'", specifier = ">=4.8.0" },
|
||||
{ name = "mcp", specifier = ">=1.8.0" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" },
|
||||
{ name = "myst-parser", marker = "extra == 'dev'", specifier = ">=2.0.0" },
|
||||
{ name = "myst-parser", marker = "extra == 'docs'", specifier = ">=2.0.0" },
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "opentelemetry-api", marker = "extra == 'monitoring'", specifier = ">=1.21.0" },
|
||||
{ name = "opentelemetry-sdk", marker = "extra == 'monitoring'", specifier = ">=1.21.0" },
|
||||
{ name = "orjson", specifier = ">=3.9.0" },
|
||||
{ name = "orjson", marker = "extra == 'performance'", specifier = ">=3.9.0" },
|
||||
{ name = "pandas", specifier = ">=2.0.0" },
|
||||
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.0" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6.0" },
|
||||
{ name = "prometheus-client", specifier = ">=0.19.0" },
|
||||
{ name = "prometheus-client", marker = "extra == 'monitoring'", specifier = ">=0.19.0" },
|
||||
{ name = "pydantic", specifier = ">=2.5.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.1.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "pymysql", specifier = ">=1.1.0" },
|
||||
{ name = "pytest", specifier = ">=8.4.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" },
|
||||
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" },
|
||||
{ name = "pytest-cov", specifier = ">=6.1.1" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" },
|
||||
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.8.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.6" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
{ name = "requests", specifier = ">=2.31.0" },
|
||||
{ name = "rich", specifier = ">=13.7.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
|
||||
{ name = "safety", marker = "extra == 'dev'", specifier = ">=2.3.0" },
|
||||
{ name = "sphinx", marker = "extra == 'dev'", specifier = ">=7.2.0" },
|
||||
{ name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.2.0" },
|
||||
{ name = "sphinx-autoapi", marker = "extra == 'docs'", specifier = ">=3.0.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = ">=2.0.0" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=2.0.0" },
|
||||
{ name = "sqlparse", specifier = ">=0.4.4" },
|
||||
{ name = "starlette", specifier = ">=0.27.0" },
|
||||
{ name = "structlog", specifier = ">=23.2.0" },
|
||||
{ name = "toml", specifier = ">=0.10.0" },
|
||||
{ name = "tox", marker = "extra == 'dev'", specifier = ">=4.11.0" },
|
||||
{ name = "tqdm", specifier = ">=4.66.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.25.0" },
|
||||
{ name = "uvloop", marker = "extra == 'performance'", specifier = ">=0.19.0" },
|
||||
{ name = "websockets", specifier = ">=12.0" },
|
||||
]
|
||||
provides-extras = ["dev", "docs", "performance", "monitoring"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "ruff", specifier = ">=0.11.13" }]
|
||||
|
||||
[[package]]
|
||||
name = "mdit-py-plugins"
|
||||
version = "0.4.2"
|
||||
|
||||
Reference in New Issue
Block a user