This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new 45e39a6 feat: enforce bounded query results (#150)
45e39a6 is described below
commit 45e39a6b56af5d7063fea8f16fcd03451d39fc6f
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 14:10:37 2026 +0800
feat: enforce bounded query results (#150)
---
.env.example | 7 +-
CHANGELOG.md | 3 +
README.md | 12 +-
docker-compose.yml | 2 +
docs/tool-registry.md | 4 +-
doris_mcp_server/result_limits.py | 150 +++++++++++
doris_mcp_server/tools/tools_manager.py | 64 ++++-
doris_mcp_server/utils/adbc_query_tools.py | 330 +++++++++++++++++--------
doris_mcp_server/utils/config.py | 51 +++-
doris_mcp_server/utils/db.py | 127 +++++++++-
doris_mcp_server/utils/query_executor.py | 236 +++++++++++++-----
doris_mcp_server/utils/schema_extractor.py | 12 +-
test/integration/test_real_doris_transports.py | 139 ++++++++++-
test/tools/test_tools_operation_guard.py | 25 +-
test/utils/test_adbc_query_tools.py | 188 ++++++++++++++
test/utils/test_db.py | 104 ++++++++
test/utils/test_doris_user_pool_manager.py | 11 +-
test/utils/test_result_limits.py | 168 +++++++++++++
18 files changed, 1431 insertions(+), 202 deletions(-)
diff --git a/.env.example b/.env.example
index b878b74..4f80861 100644
--- a/.env.example
+++ b/.env.example
@@ -366,6 +366,7 @@
BLOCKED_KEYWORDS=DROP,CREATE,ALTER,TRUNCATE,DELETE,INSERT,UPDATE,GRANT,REVOKE,EX
# Query limits
MAX_QUERY_COMPLEXITY=100
+# Deployment ceiling; absolute hard cap: 100000
MAX_RESULT_ROWS=10000
# Data masking
@@ -382,7 +383,10 @@ MAX_CACHE_SIZE=1000
# Concurrency control
MAX_CONCURRENT_QUERIES=50
+# Deployment ceiling; absolute hard cap: 300 seconds
QUERY_TIMEOUT=300
+# UTF-8 JSON row-data budget; range: 256-16777216 bytes
+MAX_RESULT_BYTES=1048576
# Response content size limit (characters)
MAX_RESPONSE_CONTENT_SIZE=4096
@@ -394,7 +398,8 @@ MAX_RESPONSE_CONTENT_SIZE=4096
ADBC_ENABLED=true
# Default ADBC query parameters
-ADBC_DEFAULT_MAX_ROWS=100000
+# Must not exceed MAX_RESULT_ROWS
+ADBC_DEFAULT_MAX_ROWS=10000
ADBC_DEFAULT_TIMEOUT=60
# Format: "arrow", "pandas", "dict"
ADBC_DEFAULT_RETURN_FORMAT=arrow
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8d6721..7026e3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,9 @@ under **Unreleased** until a new version is selected and
published.
- W3C `traceparent`, `tracestate`, and `baggage` propagation from request
`_meta`, with value-safe validation, credential-like baggage redaction,
per-request isolation, and no trace metadata in model-facing results.
+- Bounded query result streaming with deployment and absolute ceilings for
+ rows, serialized bytes, and execution time, plus cancellation-safe database
+ connection disposal.
- Real Doris process tests covering Streamable HTTP and stdio.
### Changed
diff --git a/README.md b/README.md
index 006da86..b83df17 100644
--- a/README.md
+++ b/README.md
@@ -330,9 +330,11 @@ cp .env.example .env
* `ENABLE_SECURITY_CHECK`: Enable/disable SQL security validation
(default: true)
* `BLOCKED_KEYWORDS`: Comma-separated list of blocked SQL keywords
* `ENABLE_MASKING`: Enable data masking (default: true)
- * `MAX_RESULT_ROWS`: Maximum result rows (default: 10000)
+ * `MAX_RESULT_ROWS`: Deployment ceiling for returned query rows
+ (default: 10000; absolute hard cap: 100000)
* **ADBC Configuration (New in v0.5.0)**:
- * `ADBC_DEFAULT_MAX_ROWS`: Default maximum rows for ADBC queries
(default: 100000)
+ * `ADBC_DEFAULT_MAX_ROWS`: Default maximum rows for ADBC queries
+ (default: 10000; cannot exceed `MAX_RESULT_ROWS`)
* `ADBC_DEFAULT_TIMEOUT`: Default ADBC query timeout in seconds
(default: 60)
* `ADBC_DEFAULT_RETURN_FORMAT`: Default return format -
arrow/pandas/dict (default: arrow)
* `ADBC_CONNECTION_TIMEOUT`: ADBC connection timeout in seconds
(default: 30)
@@ -341,6 +343,10 @@ cp .env.example .env
* `ENABLE_QUERY_CACHE`: Enable query caching (default: true)
* `CACHE_TTL`: Cache time-to-live in seconds (default: 300)
* `MAX_CONCURRENT_QUERIES`: Maximum concurrent queries (default: 50)
+ * `QUERY_TIMEOUT`: Deployment ceiling for query execution time
+ (default and absolute hard cap: 300 seconds)
+ * `MAX_RESULT_BYTES`: Deployment ceiling for UTF-8 JSON row data
+ (default: 1048576; allowed range: 256-16777216 bytes)
* `MAX_RESPONSE_CONTENT_SIZE`: Maximum response content size for LLM
compatibility (default: 4096, New in v0.4.0)
* **Enhanced Logging Configuration (Improved in v0.5.0)**:
* `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
@@ -1847,7 +1853,7 @@ the request metadata, HTTP headers, migration steps, and
deployment limits.
3. **Optional ADBC Customization**:
```bash
# Customize ADBC behavior (optional)
- ADBC_DEFAULT_MAX_ROWS=200000
+ ADBC_DEFAULT_MAX_ROWS=10000
ADBC_DEFAULT_TIMEOUT=120
ADBC_DEFAULT_RETURN_FORMAT=pandas # arrow/pandas/dict
```
diff --git a/docker-compose.yml b/docker-compose.yml
index 1fb1f17..87a6706 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -42,6 +42,8 @@ services:
- ENABLE_TOKEN_AUTH=true
- TOKEN_ADMIN_FILE=/run/secrets/mcp_static_token
- MAX_RESULT_ROWS=10000
+ - MAX_RESULT_BYTES=1048576
+ - QUERY_TIMEOUT=300
# Performance configuration
- ENABLE_QUERY_CACHE=true
diff --git a/docs/tool-registry.md b/docs/tool-registry.md
index 2001621..3d3e09a 100644
--- a/docs/tool-registry.md
+++ b/docs/tool-registry.md
@@ -4,7 +4,7 @@
| Tool | Policy | Risk | Handler | Audit event | Parameters |
|---|---|---|---|---|---|
-| `exec_query` | `query` | `query` | `_exec_query_tool` |
`mcp.tool.call.exec_query` | `catalog_name`, `db_name`, `max_rows`, `sql`*,
`timeout` |
+| `exec_query` | `query` | `query` | `_exec_query_tool` |
`mcp.tool.call.exec_query` | `catalog_name`, `db_name`, `max_bytes`,
`max_rows`, `sql`*, `timeout` |
| `get_table_schema` | `metadata` | `metadata` | `_get_table_schema_tool` |
`mcp.tool.call.get_table_schema` | `catalog_name`, `db_name`, `table_name`* |
| `get_db_table_list` | `metadata` | `metadata` | `_get_db_table_list_tool` |
`mcp.tool.call.get_db_table_list` | `catalog_name`, `db_name` |
| `get_db_list` | `metadata` | `metadata` | `_get_db_list_tool` |
`mcp.tool.call.get_db_list` | `catalog_name` |
@@ -27,7 +27,7 @@
| `analyze_data_flow_dependencies` | `restricted` | `high` |
`_analyze_data_flow_dependencies_tool` |
`mcp.tool.call.analyze_data_flow_dependencies` | `analysis_depth`,
`catalog_name`, `db_name`, `include_views`, `target_table` |
| `analyze_slow_queries_topn` | `restricted` | `high` |
`_analyze_slow_queries_topn_tool` | `mcp.tool.call.analyze_slow_queries_topn` |
`days`, `include_patterns`, `min_execution_time_ms`, `top_n` |
| `analyze_resource_growth_curves` | `restricted` | `high` |
`_analyze_resource_growth_curves_tool` |
`mcp.tool.call.analyze_resource_growth_curves` | `days`, `detailed_response`,
`include_predictions`, `resource_types` |
-| `exec_adbc_query` | `restricted` | `high` | `_exec_adbc_query_tool` |
`mcp.tool.call.exec_adbc_query` | `max_rows`, `return_format`, `sql`*,
`timeout` |
+| `exec_adbc_query` | `restricted` | `high` | `_exec_adbc_query_tool` |
`mcp.tool.call.exec_adbc_query` | `max_bytes`, `max_rows`, `return_format`,
`sql`*, `timeout` |
| `get_adbc_connection_info` | `restricted` | `high` |
`_get_adbc_connection_info_tool` | `mcp.tool.call.get_adbc_connection_info` |
None |
Required parameters are marked with `*`. Tool descriptions and JSON Schemas
are exposed directly by MCP `tools/list` from the same registry entries.
diff --git a/doris_mcp_server/result_limits.py
b/doris_mcp_server/result_limits.py
new file mode 100644
index 0000000..003d17a
--- /dev/null
+++ b/doris_mcp_server/result_limits.py
@@ -0,0 +1,150 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Central query-result and execution limits.
+
+Configuration limits are deployment ceilings. Tool arguments may request a
+smaller budget, but cannot raise those ceilings. Absolute constants remain a
+last line of defence if configuration objects are constructed without running
+``DorisConfig.validate()``.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Any
+
+ABSOLUTE_MAX_RESULT_ROWS = 100_000
+ABSOLUTE_MAX_RESULT_BYTES = 16 * 1024 * 1024
+ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS = 300
+MIN_RESULT_BYTES = 256
+
+DEFAULT_MAX_RESULT_ROWS = 10_000
+DEFAULT_MAX_RESULT_BYTES = 1024 * 1024
+DEFAULT_QUERY_TIMEOUT_SECONDS = 300
+
+
+class ResultLimitError(ValueError):
+ """Raised when a caller asks for an invalid or excessive result budget."""
+
+
+@dataclass(frozen=True)
+class ResultLimits:
+ """Effective per-call query result and execution budgets."""
+
+ max_rows: int
+ max_bytes: int
+ timeout_seconds: int
+
+
+def _configured_positive_int(
+ value: object,
+ *,
+ default: int,
+ hard_maximum: int,
+) -> int:
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ return default
+ return min(value, hard_maximum)
+
+
+def configured_result_limits(config: object | None) -> ResultLimits:
+ """Read bounded deployment ceilings from a Doris configuration object."""
+ security = getattr(config, "security", None)
+ performance = getattr(config, "performance", None)
+ return ResultLimits(
+ max_rows=_configured_positive_int(
+ getattr(security, "max_result_rows", None),
+ default=DEFAULT_MAX_RESULT_ROWS,
+ hard_maximum=ABSOLUTE_MAX_RESULT_ROWS,
+ ),
+ max_bytes=_configured_positive_int(
+ getattr(performance, "max_result_bytes", None),
+ default=DEFAULT_MAX_RESULT_BYTES,
+ hard_maximum=ABSOLUTE_MAX_RESULT_BYTES,
+ ),
+ timeout_seconds=_configured_positive_int(
+ getattr(performance, "query_timeout", None),
+ default=DEFAULT_QUERY_TIMEOUT_SECONDS,
+ hard_maximum=ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS,
+ ),
+ )
+
+
+def _requested_limit(
+ value: object | None,
+ *,
+ name: str,
+ configured_maximum: int,
+ minimum: int = 1,
+) -> int:
+ if value is None:
+ return configured_maximum
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise ResultLimitError(f"{name} must be an integer")
+ if value < minimum:
+ raise ResultLimitError(f"{name} must be at least {minimum}")
+ if value > configured_maximum:
+ raise ResultLimitError(
+ f"{name} exceeds the configured maximum of {configured_maximum}"
+ )
+ return value
+
+
+def resolve_result_limits(
+ config: object | None,
+ *,
+ max_rows: object | None,
+ max_bytes: object | None,
+ timeout_seconds: object | None,
+) -> ResultLimits:
+ """Resolve request budgets without allowing configuration escalation."""
+ ceilings = configured_result_limits(config)
+ return ResultLimits(
+ max_rows=_requested_limit(
+ max_rows,
+ name="max_rows",
+ configured_maximum=ceilings.max_rows,
+ ),
+ max_bytes=_requested_limit(
+ max_bytes,
+ name="max_bytes",
+ configured_maximum=ceilings.max_bytes,
+ minimum=MIN_RESULT_BYTES,
+ ),
+ timeout_seconds=_requested_limit(
+ timeout_seconds,
+ name="timeout",
+ configured_maximum=ceilings.timeout_seconds,
+ ),
+ )
+
+
+def json_array_row_size(row: dict[str, Any], *, first: bool) -> int:
+ """Return a conservative UTF-8 JSON contribution for one result row.
+
+ ``ensure_ascii=True`` intentionally counts escaped non-ASCII data, which is
+ never smaller than the UTF-8 representation emitted by the MCP stack.
+ Array brackets are accounted for separately by the caller.
+ """
+ encoded = json.dumps(
+ row,
+ ensure_ascii=True,
+ separators=(",", ":"),
+ default=str,
+ ).encode("utf-8")
+ return len(encoded) + (0 if first else 1)
diff --git a/doris_mcp_server/tools/tools_manager.py
b/doris_mcp_server/tools/tools_manager.py
index b103fe2..ab556fd 100644
--- a/doris_mcp_server/tools/tools_manager.py
+++ b/doris_mcp_server/tools/tools_manager.py
@@ -31,6 +31,7 @@ from ..auth.operation_policy import (
authorize_operation,
filter_tools_for_auth_context,
)
+from ..result_limits import configured_result_limits
from ..utils.adbc_query_tools import DorisADBCQueryTools
from ..utils.analysis_tools import MemoryTracker, SQLAnalyzer, TableAnalyzer
from ..utils.config import ADBCConfig
@@ -120,6 +121,15 @@ class DorisToolsManager:
connection_manager = getattr(self, "connection_manager", None)
config = getattr(connection_manager, "config", None)
adbc_config = getattr(config, "adbc", None) or ADBCConfig()
+ result_limits = configured_result_limits(config)
+ adbc_default_max_rows = min(
+ adbc_config.default_max_rows,
+ result_limits.max_rows,
+ )
+ adbc_default_timeout = min(
+ adbc_config.default_timeout,
+ result_limits.timeout_seconds,
+ )
tools = [
Tool(
@@ -136,6 +146,8 @@ class DorisToolsManager:
- max_rows (integer) [Optional] - Maximum number of rows to return, default 100
+- max_bytes (integer) [Optional] - Maximum UTF-8 JSON bytes for returned row
data
+
- timeout (integer) [Optional] - Query timeout in seconds, default 30
""",
input_schema={
@@ -157,11 +169,22 @@ class DorisToolsManager:
"type": "integer",
"description": "Maximum number of rows to return",
"default": 100,
+ "minimum": 1,
+ "maximum": result_limits.max_rows,
+ },
+ "max_bytes": {
+ "type": "integer",
+ "description": "Maximum UTF-8 JSON bytes for
returned row data",
+ "default": result_limits.max_bytes,
+ "minimum": 256,
+ "maximum": result_limits.max_bytes,
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds",
"default": 30,
+ "minimum": 1,
+ "maximum": result_limits.timeout_seconds,
},
},
"required": ["sql"],
@@ -936,8 +959,9 @@ class DorisToolsManager:
[Parameter Content]:
- sql (string) [Required] - SQL statement to execute
-- max_rows (integer) [Optional] - Maximum number of rows to return, default is
{adbc_config.default_max_rows}
-- timeout (integer) [Optional] - Query timeout in seconds, default is
{adbc_config.default_timeout}
+- max_rows (integer) [Optional] - Maximum number of rows to return, default is
{adbc_default_max_rows}
+- max_bytes (integer) [Optional] - Maximum UTF-8 JSON bytes for returned row
data, default is {result_limits.max_bytes}
+- timeout (integer) [Optional] - Query timeout in seconds, default is
{adbc_default_timeout}
- return_format (string) [Optional] - Format for returned data, default is
"{adbc_config.default_return_format}"
* "arrow": Return Arrow format with metadata
* "pandas": Return Pandas DataFrame format
@@ -958,12 +982,23 @@ class DorisToolsManager:
"max_rows": {
"type": "integer",
"description": "Maximum number of rows to return",
- "default": adbc_config.default_max_rows,
+ "minimum": 1,
+ "maximum": result_limits.max_rows,
+ "default": adbc_default_max_rows,
+ },
+ "max_bytes": {
+ "type": "integer",
+ "description": "Maximum UTF-8 JSON result bytes",
+ "minimum": 256,
+ "maximum": result_limits.max_bytes,
+ "default": result_limits.max_bytes,
},
"timeout": {
"type": "integer",
"description": "Query timeout in seconds",
- "default": adbc_config.default_timeout,
+ "minimum": 1,
+ "maximum": result_limits.timeout_seconds,
+ "default": adbc_default_timeout,
},
"return_format": {
"type": "string",
@@ -1092,11 +1127,17 @@ No parameters required. Returns connection status,
configuration, and diagnostic
db_name = arguments.get("db_name")
catalog_name = arguments.get("catalog_name")
max_rows = arguments.get("max_rows", 100)
+ max_bytes = arguments.get("max_bytes")
timeout = arguments.get("timeout", 30)
# Delegate to metadata extractor for processing
return await self.metadata_extractor.exec_query_for_mcp(
- sql, db_name, catalog_name, max_rows, timeout
+ sql,
+ db_name,
+ catalog_name,
+ max_rows,
+ timeout,
+ max_bytes=max_bytes,
)
async def _get_table_schema_tool(self, arguments: dict[str, Any]) ->
dict[str, Any]:
@@ -1588,13 +1629,18 @@ No parameters required. Returns connection status,
configuration, and diagnostic
async def _exec_adbc_query_tool(self, arguments: dict[str, Any]) ->
dict[str, Any]:
"""ADBC query execution tool routing"""
sql = self._required_string(arguments, "sql")
- max_rows = arguments.get("max_rows", 100000)
- timeout = arguments.get("timeout", 60)
- return_format = arguments.get("return_format", "arrow")
+ max_rows = arguments.get("max_rows")
+ max_bytes = arguments.get("max_bytes")
+ timeout = arguments.get("timeout")
+ return_format = arguments.get("return_format")
# Delegate to ADBC query tools for processing
return await self.adbc_query_tools.exec_adbc_query(
- sql, max_rows, timeout, return_format
+ sql,
+ max_rows=max_rows,
+ timeout=timeout,
+ return_format=return_format,
+ max_bytes=max_bytes,
)
async def _get_adbc_connection_info_tool(
diff --git a/doris_mcp_server/utils/adbc_query_tools.py
b/doris_mcp_server/utils/adbc_query_tools.py
index 478532b..7de8b44 100644
--- a/doris_mcp_server/utils/adbc_query_tools.py
+++ b/doris_mcp_server/utils/adbc_query_tools.py
@@ -20,13 +20,19 @@ Apache Doris ADBC Query Tools
High-performance data querying using Apache Arrow Flight SQL protocol
"""
+import asyncio
import os
import socket
import time
from datetime import datetime
-from importlib.util import find_spec
-from typing import Any, cast
-
+from typing import Any
+
+from ..result_limits import (
+ ResultLimitError,
+ ResultLimits,
+ json_array_row_size,
+ resolve_result_limits,
+)
from ..utils.db import DorisConnectionManager
from ..utils.logger import get_logger
from ..utils.security import AuthContext
@@ -61,28 +67,6 @@ def _convert_numpy_types(obj: Any) -> Any:
return obj
-def _convert_dataframe_to_json_serializable(
- df: Any,
-) -> list[dict[str, Any]]:
- """Convert DataFrame to JSON serializable format"""
- if find_spec("numpy") is None or find_spec("pandas") is None:
- # Fallback to basic dict conversion
- return cast(list[dict[str, Any]], df.to_dict("records"))
-
- # Convert DataFrame to records
- records = df.to_dict('records')
-
- # Convert each record's values
- converted_records = []
- for record in records:
- converted_record = {}
- for key, value in record.items():
- converted_record[key] = _convert_numpy_types(value)
- converted_records.append(converted_record)
-
- return converted_records
-
-
class DorisADBCQueryTools:
"""ADBC Query Tools for high-performance data transfer using Arrow Flight
SQL"""
@@ -91,13 +75,16 @@ class DorisADBCQueryTools:
self.adbc_client: Any | None = None
self.flight_sql_module: Any | None = None
self.adbc_manager_module: Any | None = None
+ # ADBC connections and cursors are not safe to replace concurrently.
+ self._query_lock = asyncio.Lock()
async def exec_adbc_query(
self,
sql: str,
max_rows: int | None = None,
timeout: int | None = None,
- return_format: str | None = None
+ return_format: str | None = None,
+ max_bytes: int | None = None,
) -> dict[str, Any]:
"""
Execute SQL query using ADBC (Arrow Flight SQL) protocol
@@ -107,18 +94,42 @@ class DorisADBCQueryTools:
max_rows: Maximum number of rows to return (uses config default if
None)
timeout: Query timeout in seconds (uses config default if None)
return_format: Format for returned data ("arrow", "pandas",
"dict", uses config default if None)
+ max_bytes: Maximum UTF-8 JSON bytes for returned row data
Returns:
Query results in specified format with metadata
"""
try:
- start_time = time.time()
-
# Use configuration defaults if parameters not specified
adbc_config = self.connection_manager.config.adbc
- max_rows = max_rows if max_rows is not None else
adbc_config.default_max_rows
- timeout = timeout if timeout is not None else
adbc_config.default_timeout
- return_format = return_format if return_format is not None else
adbc_config.default_return_format
+ try:
+ limits = resolve_result_limits(
+ self.connection_manager.config,
+ max_rows=(
+ max_rows
+ if max_rows is not None
+ else adbc_config.default_max_rows
+ ),
+ max_bytes=max_bytes,
+ timeout_seconds=(
+ timeout
+ if timeout is not None
+ else adbc_config.default_timeout
+ ),
+ )
+ except ResultLimitError as exc:
+ return {
+ "success": False,
+ "error": str(exc),
+ "error_type": "invalid_result_limits",
+ "timestamp": datetime.now().isoformat(),
+ }
+ return_format = (
+ return_format
+ if return_format is not None
+ else adbc_config.default_return_format
+ )
+ start_time = time.time()
# Step 1: Check environment variables and port availability
port_check_result = await self._check_arrow_flight_ports()
@@ -130,15 +141,21 @@ class DorisADBCQueryTools:
if not import_result["success"]:
return import_result
- # Step 3: Create ADBC connection
- connection_result = await self._create_adbc_connection()
- if not connection_result["success"]:
- return connection_result
-
- # Step 4: Execute query using ADBC
- query_result = await self._execute_query_with_adbc(
- sql, max_rows, timeout, return_format
- )
+ async with self._query_lock:
+ # Step 3: Create a per-call ADBC connection.
+ connection_result = await self._create_adbc_connection()
+ if not connection_result["success"]:
+ return connection_result
+
+ try:
+ # Step 4: Execute query using ADBC.
+ query_result = await self._execute_query_with_adbc(
+ sql,
+ limits,
+ return_format,
+ )
+ finally:
+ await self._close_adbc_client()
execution_time = time.time() - start_time
@@ -158,6 +175,16 @@ class DorisADBCQueryTools:
"timestamp": datetime.now().isoformat()
}
+ async def _close_adbc_client(self) -> None:
+ """Close and clear the current ADBC connection without blocking MCP."""
+ client, self.adbc_client = self.adbc_client, None
+ if client is None:
+ return
+ try:
+ await asyncio.to_thread(client.close)
+ except Exception as exc:
+ logger.debug(f"Failed to close ADBC client: {exc}")
+
async def _check_arrow_flight_ports(self) -> dict[str, Any]:
"""Check Arrow Flight SQL port configuration and availability"""
try:
@@ -397,11 +424,10 @@ class DorisADBCQueryTools:
async def _execute_query_with_adbc(
self,
sql: str,
- max_rows: int,
- timeout: int,
- return_format: str
+ limits: ResultLimits,
+ return_format: str,
) -> dict[str, Any]:
- """Execute query using ADBC"""
+ """Execute an ADBC query with bounded output and real cancellation."""
try:
if not self.adbc_client:
return {
@@ -425,88 +451,174 @@ class DorisADBCQueryTools:
}
cursor = self.adbc_client.cursor()
- start_time = time.time()
+ worker = asyncio.create_task(
+ asyncio.to_thread(
+ self._execute_query_with_adbc_sync,
+ cursor,
+ sql,
+ limits,
+ return_format,
+ )
+ )
+ try:
+ return await asyncio.wait_for(
+ asyncio.shield(worker),
+ timeout=limits.timeout_seconds,
+ )
+ except TimeoutError:
+ await self._cancel_adbc_worker(cursor, worker)
+ return {
+ "success": False,
+ "error": (
+ "ADBC query execution timed out after "
+ f"{limits.timeout_seconds} seconds"
+ ),
+ "error_type": "timeout",
+ "sql": sql,
+ }
+ except asyncio.CancelledError:
+ await self._cancel_adbc_worker(cursor, worker)
+ raise
- # Execute query
- cursor.execute(sql)
+ except Exception as e:
+ logger.error(f"ADBC query execution failed: {str(e)}")
+ return {
+ "success": False,
+ "error": f"ADBC query execution failed: {str(e)}",
+ "error_type": "query_execution_error",
+ "sql": sql
+ }
- # Get results based on return format
- if return_format == "arrow":
- # Return Arrow format
- arrow_data = cursor.fetchallarrow()
+ async def _cancel_adbc_worker(
+ self,
+ cursor: Any,
+ worker: asyncio.Task[dict[str, Any]],
+ ) -> None:
+ """Request driver cancellation and reap the worker thread."""
+ try:
+ await asyncio.wait_for(
+ asyncio.to_thread(cursor.adbc_cancel),
+ timeout=2,
+ )
+ except Exception as exc:
+ logger.warning(f"Failed to cancel ADBC query: {exc}")
- # Limit rows
- if len(arrow_data) > max_rows:
- arrow_data = arrow_data.slice(0, max_rows)
+ try:
+ await asyncio.wait_for(asyncio.shield(worker), timeout=5)
+ except (asyncio.CancelledError, Exception) as exc:
+ # The driver owns the worker thread. Keep the result consumed even
+ # if a non-cooperative implementation takes longer to unwind.
+ logger.debug(f"ADBC query worker did not stop promptly: {exc}")
+ worker.add_done_callback(self._consume_adbc_worker_result)
+
+ @staticmethod
+ def _consume_adbc_worker_result(worker: asyncio.Task[dict[str, Any]]) ->
None:
+ try:
+ worker.result()
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ @staticmethod
+ def _execute_query_with_adbc_sync(
+ cursor: Any,
+ sql: str,
+ limits: ResultLimits,
+ return_format: str,
+ ) -> dict[str, Any]:
+ """Run blocking ADBC calls in a worker and stream bounded batches."""
+ start_time = time.time()
+ rows: list[dict[str, Any]] = []
+ result_bytes = 2 # JSON array brackets
+ truncated = False
+ truncation_reason: str | None = None
- # Convert Arrow data to serializable format
- preview_df = arrow_data.to_pandas().head(10) if
len(arrow_data) > 0 else None
+ try:
+ cursor.execute(sql)
+ description = cursor.description or []
+ column_names = [str(column[0]) for column in description]
+ column_types = [str(column[1]) for column in description]
+
+ while not truncated:
+ fetch_size = min(256, limits.max_rows + 1 - len(rows))
+ batch = cursor.fetchmany(max(fetch_size, 1))
+ if not batch:
+ break
+
+ for raw_row in batch:
+ if len(rows) >= limits.max_rows:
+ truncated = True
+ truncation_reason = "max_rows"
+ break
+
+ if isinstance(raw_row, dict):
+ row = {
+ str(key): _convert_numpy_types(value)
+ for key, value in raw_row.items()
+ }
+ else:
+ row = {
+ name: _convert_numpy_types(value)
+ for name, value in zip(
+ column_names,
+ raw_row,
+ strict=False,
+ )
+ }
+
+ contribution = json_array_row_size(
+ row,
+ first=not rows,
+ )
+ if result_bytes + contribution > limits.max_bytes:
+ truncated = True
+ truncation_reason = "max_bytes"
+ break
+
+ rows.append(row)
+ result_bytes += contribution
+
+ common_result = {
+ "format": return_format,
+ "num_rows": len(rows),
+ "num_columns": len(column_names),
+ "column_names": column_names,
+ "column_types": column_types,
+ }
+ if return_format == "arrow":
result_data = {
- "format": "arrow",
- "num_rows": len(arrow_data),
- "num_columns": len(arrow_data.schema),
- "column_names": arrow_data.schema.names,
- "column_types": [str(field.type) for field in
arrow_data.schema],
- "data_preview":
_convert_dataframe_to_json_serializable(preview_df) if preview_df is not None
else [],
- "total_bytes": arrow_data.nbytes if hasattr(arrow_data,
'nbytes') else 0
+ **common_result,
+ "data_preview": rows[:10],
+ "total_bytes": result_bytes,
}
-
elif return_format == "pandas":
- # Return Pandas DataFrame
- df = cursor.fetch_df()
-
- # Limit rows
- if len(df) > max_rows:
- df = df.head(max_rows)
-
result_data = {
- "format": "pandas",
- "num_rows": len(df),
- "num_columns": len(df.columns),
- "column_names": df.columns.tolist(),
- "column_types": df.dtypes.astype(str).tolist(),
- "data": _convert_dataframe_to_json_serializable(df),
- "memory_usage": int(df.memory_usage(deep=True).sum())
+ **common_result,
+ "data": rows,
+ "memory_usage": result_bytes,
}
-
- else: # return_format == "dict"
- # Return dictionary format
- arrow_data = cursor.fetchallarrow()
- df = arrow_data.to_pandas()
-
- # Limit rows
- if len(df) > max_rows:
- df = df.head(max_rows)
-
+ else:
result_data = {
- "format": "dict",
- "num_rows": len(df),
- "num_columns": len(df.columns),
- "column_names": df.columns.tolist(),
- "column_types": df.dtypes.astype(str).tolist(),
- "data": _convert_dataframe_to_json_serializable(df)
+ **common_result,
+ "data": rows,
}
- execution_time = time.time() - start_time
-
- cursor.close()
-
return {
"success": True,
"result": result_data,
- "execution_time": round(execution_time, 3),
+ "execution_time": round(time.time() - start_time, 3),
"sql": sql,
- "max_rows_applied": len(result_data.get("data", [])) >=
max_rows
- }
-
- except Exception as e:
- logger.error(f"ADBC query execution failed: {str(e)}")
- return {
- "success": False,
- "error": f"ADBC query execution failed: {str(e)}",
- "error_type": "query_execution_error",
- "sql": sql
+ "max_rows_applied": truncation_reason == "max_rows",
+ "result_bytes": result_bytes,
+ "truncated": truncated,
+ "truncation_reason": truncation_reason,
+ "limits": {
+ "max_rows": limits.max_rows,
+ "max_bytes": limits.max_bytes,
+ "timeout_seconds": limits.timeout_seconds,
+ },
}
+ finally:
+ cursor.close()
async def get_adbc_connection_info(self) -> dict[str, Any]:
"""Get ADBC connection information and status"""
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index d41359d..ba061b7 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -34,6 +34,14 @@ from urllib.parse import urlparse
from dotenv import load_dotenv
from .._version import __version__
+from ..result_limits import (
+ ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS,
+ ABSOLUTE_MAX_RESULT_BYTES,
+ ABSOLUTE_MAX_RESULT_ROWS,
+ DEFAULT_MAX_RESULT_BYTES,
+ DEFAULT_MAX_RESULT_ROWS,
+ MIN_RESULT_BYTES,
+)
from ..tools.tool_registry import (
DORIS_OAUTH_EXPLAIN_TOOL_SET,
DORIS_OAUTH_METADATA_TOOL_NAMES,
@@ -499,6 +507,7 @@ class PerformanceConfig:
# Concurrency control configuration
max_concurrent_queries: int = 50
query_timeout: int = 300
+ max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES
# Connection pool optimization configuration
connection_pool_size: int = 20
@@ -540,7 +549,7 @@ class ADBCConfig:
"""ADBC (Arrow Flight SQL) configuration"""
# Default query parameters
- default_max_rows: int = 100000
+ default_max_rows: int = DEFAULT_MAX_RESULT_ROWS
default_timeout: int = 60
default_return_format: str = "arrow" # "arrow", "pandas", "dict"
@@ -1036,6 +1045,12 @@ class DorisConfig:
config.performance.query_timeout = int(
os.getenv("QUERY_TIMEOUT", str(config.performance.query_timeout))
)
+ config.performance.max_result_bytes = int(
+ os.getenv(
+ "MAX_RESULT_BYTES",
+ str(config.performance.max_result_bytes),
+ )
+ )
config.performance.max_response_content_size = int(
os.getenv("MAX_RESPONSE_CONTENT_SIZE",
str(config.performance.max_response_content_size))
)
@@ -1348,6 +1363,7 @@ class DorisConfig:
"max_cache_size": self.performance.max_cache_size,
"max_concurrent_queries":
self.performance.max_concurrent_queries,
"query_timeout": self.performance.query_timeout,
+ "max_result_bytes": self.performance.max_result_bytes,
"connection_pool_size": self.performance.connection_pool_size,
"idle_timeout": self.performance.idle_timeout,
"max_response_content_size":
self.performance.max_response_content_size,
@@ -1480,6 +1496,11 @@ class DorisConfig:
if self.security.max_result_rows <= 0:
errors.append("Maximum result rows must be greater than 0")
+ elif self.security.max_result_rows > ABSOLUTE_MAX_RESULT_ROWS:
+ errors.append(
+ "Maximum result rows must not exceed "
+ f"{ABSOLUTE_MAX_RESULT_ROWS}"
+ )
# Validate performance configuration
if self.performance.cache_ttl <= 0:
@@ -1490,6 +1511,24 @@ class DorisConfig:
if self.performance.query_timeout <= 0:
errors.append("Query timeout must be greater than 0")
+ elif (
+ self.performance.query_timeout
+ > ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS
+ ):
+ errors.append(
+ "Query timeout must not exceed "
+ f"{ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS} seconds"
+ )
+
+ if not (
+ MIN_RESULT_BYTES
+ <= self.performance.max_result_bytes
+ <= ABSOLUTE_MAX_RESULT_BYTES
+ ):
+ errors.append(
+ "Maximum result bytes must be in the range "
+ f"{MIN_RESULT_BYTES}-{ABSOLUTE_MAX_RESULT_BYTES}"
+ )
# Validate data quality configuration
if self.data_quality.max_columns_per_batch <= 0:
@@ -1542,9 +1581,19 @@ class DorisConfig:
# Validate ADBC configuration
if self.adbc.default_max_rows <= 0:
errors.append("ADBC default max rows must be greater than 0")
+ elif self.adbc.default_max_rows > self.security.max_result_rows:
+ errors.append(
+ "ADBC default max rows must not exceed the configured "
+ f"maximum result rows ({self.security.max_result_rows})"
+ )
if self.adbc.default_timeout <= 0:
errors.append("ADBC default timeout must be greater than 0")
+ elif self.adbc.default_timeout > self.performance.query_timeout:
+ errors.append(
+ "ADBC default timeout must not exceed the configured query "
+ f"timeout ({self.performance.query_timeout} seconds)"
+ )
if self.adbc.default_return_format not in ["arrow", "pandas", "dict"]:
errors.append("ADBC default return format must be one of arrow,
pandas, or dict")
diff --git a/doris_mcp_server/utils/db.py b/doris_mcp_server/utils/db.py
index 0285d4d..71760c6 100644
--- a/doris_mcp_server/utils/db.py
+++ b/doris_mcp_server/utils/db.py
@@ -41,6 +41,7 @@ from typing import TYPE_CHECKING, Any, TypedDict
import aiomysql
from aiomysql import Connection, Pool
+from ..result_limits import json_array_row_size
from .config import DorisConfig
from .datetime_utils import utc_now
from .logger import get_logger
@@ -174,6 +175,8 @@ class DorisConnection:
auth_context: AuthContext | None = None,
*,
mask_result: bool = True,
+ max_rows: int | None = None,
+ max_bytes: int | None = None,
) -> QueryResult:
"""Execute SQL after validation, with optional result masking.
@@ -199,14 +202,37 @@ class DorisConnection:
"blocked_operations": validation_result.blocked_operations,
}
- async with self.connection.cursor(aiomysql.DictCursor) as cursor:
+ bounded_result = max_rows is not None or max_bytes is not None
+ cursor_type = (
+ aiomysql.SSDictCursor if bounded_result else
aiomysql.DictCursor
+ )
+ async with self.connection.cursor(cursor_type) as cursor:
await cursor.execute(sql, params)
# cursor.description is set by the DB driver for any statement
that returns rows,
# avoiding a brittle hardcoded keyword list (e.g. missing
WITH/CTE, comments before keywords).
+ result_bytes: int | None = None
+ truncated = False
+ truncation_reason: str | None = None
if cursor.description:
- data = await cursor.fetchall()
- row_count = len(data)
+ if bounded_result:
+ (
+ final_data,
+ result_bytes,
+ truncated,
+ truncation_reason,
+ ) = await self._fetch_bounded_rows(
+ cursor,
+ auth_context=auth_context,
+ mask_result=mask_result,
+ max_rows=max_rows,
+ max_bytes=max_bytes,
+ )
+ data = final_data
+ row_count = len(final_data)
+ else:
+ data = await cursor.fetchall()
+ row_count = len(data)
else:
data = []
row_count = cursor.rowcount
@@ -223,6 +249,8 @@ class DorisConnection:
# If security manager exists and has auth context, apply data
masking
final_data = list(data) if data else []
if (
+ not bounded_result
+ and
self.security_manager
and auth_context
and final_data
@@ -240,6 +268,21 @@ class DorisConnection:
}
if security_result:
metadata["security_check"] = security_result
+ if bounded_result:
+ metadata.update(
+ {
+ "result_bytes": result_bytes or 2,
+ "truncated": truncated,
+ "truncation_reason": truncation_reason,
+ }
+ )
+
+ if truncated:
+ # An unbuffered cursor still has unread rows. Closing the
+ # physical connection avoids draining an attacker-sized
+ # result and prevents it from returning to the pool.
+ self.is_healthy = False
+ await self.connection.ensure_closed()
return QueryResult(
data=final_data,
@@ -249,11 +292,78 @@ class DorisConnection:
sql=sql,
)
+ except asyncio.CancelledError:
+ # MCP cancellation and asyncio timeouts both arrive here as task
+ # cancellation. A running MySQL command cannot safely be returned
+ # to the pool, so terminate its physical connection first.
+ self.is_healthy = False
+ try:
+ await self.connection.ensure_closed()
+ finally:
+ raise
except Exception as e:
self.is_healthy = False
logging.error(f"Query execution failed: {e}")
raise
+ async def _fetch_bounded_rows(
+ self,
+ cursor: Any,
+ *,
+ auth_context: AuthContext | None,
+ mask_result: bool,
+ max_rows: int | None,
+ max_bytes: int | None,
+ ) -> tuple[list[dict[str, Any]], int, bool, str | None]:
+ """Fetch a result incrementally under row and serialized-byte
budgets."""
+ row_budget = max_rows if max_rows is not None else 100_000
+ byte_budget = max_bytes if max_bytes is not None else 16 * 1024 * 1024
+ final_data: list[dict[str, Any]] = []
+ result_bytes = 2 # JSON array brackets.
+ truncation_reason: str | None = None
+
+ while truncation_reason is None:
+ remaining_rows = row_budget - len(final_data)
+ fetch_size = min(128, remaining_rows + 1)
+ rows = list(await cursor.fetchmany(fetch_size))
+ if not rows:
+ break
+
+ processed_rows = [dict(row) for row in rows]
+ if (
+ self.security_manager
+ and auth_context
+ and processed_rows
+ and mask_result
+ ):
+ processed_rows = list(
+ await self.security_manager.apply_data_masking(
+ processed_rows,
+ auth_context,
+ )
+ )
+
+ for row in processed_rows:
+ if len(final_data) >= row_budget:
+ truncation_reason = "row_limit"
+ break
+ row_size = json_array_row_size(row, first=not final_data)
+ if result_bytes + row_size > byte_budget:
+ truncation_reason = "byte_limit"
+ break
+ final_data.append(row)
+ result_bytes += row_size
+
+ if len(rows) < fetch_size:
+ break
+
+ return (
+ final_data,
+ result_bytes,
+ truncation_reason is not None,
+ truncation_reason,
+ )
+
async def ping(self) -> bool:
"""Check connection health status with enhanced at_eof error
detection"""
try:
@@ -2460,6 +2570,9 @@ class DorisConnectionManager:
sql: str,
params: Mapping[str, Any] | tuple[Any, ...] | None = None,
auth_context: AuthContext | None = None,
+ *,
+ max_rows: int | None = None,
+ max_bytes: int | None = None,
) -> QueryResult:
"""Execute query using the same routed acquire/release contract as
get_connection()."""
connection = None
@@ -2471,7 +2584,13 @@ class DorisConnectionManager:
)
# Execute query
- result = await connection.execute(sql, params,
effective_auth_context)
+ result = await connection.execute(
+ sql,
+ params,
+ effective_auth_context,
+ max_rows=max_rows,
+ max_bytes=max_bytes,
+ )
return result
diff --git a/doris_mcp_server/utils/query_executor.py
b/doris_mcp_server/utils/query_executor.py
index 77ead62..e30c7b6 100644
--- a/doris_mcp_server/utils/query_executor.py
+++ b/doris_mcp_server/utils/query_executor.py
@@ -33,6 +33,12 @@ from typing import TYPE_CHECKING, Any, cast
import sqlparse
+from ..result_limits import (
+ ResultLimitError,
+ ResultLimits,
+ configured_result_limits,
+ resolve_result_limits,
+)
from .auth_credentials import EMPTY_CREDENTIAL
from .datetime_utils import utc_now
from .db import (
@@ -62,6 +68,8 @@ class QueryRequest:
user_id: str
parameters: dict[str, Any] | None = None
timeout: int | None = None
+ max_rows: int | None = None
+ max_bytes: int | None = None
cache_enabled: bool = True
@@ -338,7 +346,11 @@ class DorisQueryExecutor:
config: Any | None = None,
) -> None:
self.connection_manager = connection_manager
- self.config = config or self._create_default_config()
+ self.config = (
+ config
+ or getattr(connection_manager, "config", None)
+ or self._create_default_config()
+ )
self.logger = get_logger(__name__)
# Initialize components
@@ -359,6 +371,7 @@ class DorisQueryExecutor:
self.max_concurrent_queries = getattr(
getattr(self.config, 'performance', None),
'max_concurrent_queries', 50
) if hasattr(self.config, 'performance') else 50
+ self.configured_result_limits = configured_result_limits(self.config)
# Background tasks
self._background_tasks: list[asyncio.Task[None]] = []
@@ -368,12 +381,19 @@ class DorisQueryExecutor:
class DefaultConfig:
def __init__(self) -> None:
self.performance = DefaultPerformanceConfig()
+ self.security = DefaultSecurityConfig()
class DefaultPerformanceConfig:
def __init__(self) -> None:
self.max_cache_size = 1000
self.cache_ttl = 300
self.max_concurrent_queries = 50
+ self.query_timeout = 300
+ self.max_result_bytes = 1024 * 1024
+
+ class DefaultSecurityConfig:
+ def __init__(self) -> None:
+ self.max_result_rows = 10_000
return DefaultConfig()
@@ -475,13 +495,27 @@ class DorisQueryExecutor:
if query_request.timeout:
try:
result = await asyncio.wait_for(
-
self.connection_manager.execute_query(query_request.session_id, optimized_sql,
query_request.parameters, auth_context),
+ self.connection_manager.execute_query(
+ query_request.session_id,
+ optimized_sql,
+ query_request.parameters,
+ auth_context,
+ max_rows=query_request.max_rows,
+ max_bytes=query_request.max_bytes,
+ ),
timeout=query_request.timeout
)
except TimeoutError:
raise Exception(f"Query timeout after {query_request.timeout}
seconds")
else:
- result = await
self.connection_manager.execute_query(query_request.session_id, optimized_sql,
query_request.parameters, auth_context)
+ result = await self.connection_manager.execute_query(
+ query_request.session_id,
+ optimized_sql,
+ query_request.parameters,
+ auth_context,
+ max_rows=query_request.max_rows,
+ max_bytes=query_request.max_bytes,
+ )
return result
@@ -505,6 +539,8 @@ class DorisQueryExecutor:
async def execute_batch_sqls_for_mcp(
self,
sqls: list[str],
+ max_rows: int = 1000,
+ max_bytes: int | None = None,
timeout: int = 30,
session_id: str = "mcp_session",
user_id: str = "mcp_user",
@@ -517,35 +553,53 @@ class DorisQueryExecutor:
"error": "SQL query is required",
"data": None
}
- query_requests = [
- QueryRequest(
- sql=sql,
- session_id=session_id,
- user_id=user_id,
- timeout=timeout,
- cache_enabled=False
- )
- for sql in sqls
- ]
- query_results = await self.execute_batch_queries(query_requests,
auth_context)
+ limits = resolve_result_limits(
+ self.config,
+ max_rows=max_rows,
+ max_bytes=max_bytes,
+ timeout_seconds=timeout,
+ )
+ query_results: list[QueryResult] = []
+ remaining_rows = limits.max_rows
+ remaining_bytes = limits.max_bytes
+ async with asyncio.timeout(limits.timeout_seconds):
+ for sql in sqls:
+ if remaining_rows <= 0 or remaining_bytes < 256:
+ break
+ result = await self.execute_query(
+ QueryRequest(
+ sql=sql,
+ session_id=session_id,
+ user_id=user_id,
+ timeout=None,
+ max_rows=remaining_rows,
+ max_bytes=remaining_bytes,
+ cache_enabled=False,
+ ),
+ auth_context,
+ )
+ query_results.append(result)
+ remaining_rows -= result.row_count
+ remaining_bytes -= int(result.metadata.get("result_bytes", 2))
+
# Serialize data for JSON response
results = [
- {
- "data": [self._serialize_row_data(data) for data in
result.data],
- "row_count": result.row_count,
- "execution_time": result.execution_time,
- "metadata": {
- "columns": result.metadata.get("columns", []),
- "query": result.sql
- }
- }
+ self._query_result_payload(result, limits)
for result in query_results
]
return {
"success": True,
"multiple_results": True,
- "results": results
+ "results": results,
+ "metadata": {
+ "truncated": len(query_results) < len(sqls),
+ "limits": {
+ "max_rows": limits.max_rows,
+ "max_bytes": limits.max_bytes,
+ "timeout_seconds": limits.timeout_seconds,
+ },
+ },
}
async def execute_batch_queries(
@@ -630,7 +684,16 @@ class DorisQueryExecutor:
if callable(release_connection):
await release_connection(session_id, connection)
- def _query_result_payload(self, result: QueryResult) -> dict[str, Any]:
+ def _query_result_payload(
+ self,
+ result: QueryResult,
+ limits: ResultLimits,
+ ) -> dict[str, Any]:
+ boundary_metadata = {
+ key: result.metadata.get(key)
+ for key in ("result_bytes", "truncated", "truncation_reason")
+ if key in result.metadata
+ }
return {
"data": [self._serialize_row_data(data) for data in result.data],
"row_count": result.row_count,
@@ -638,6 +701,12 @@ class DorisQueryExecutor:
"metadata": {
"columns": result.metadata.get("columns", []),
"query": result.sql,
+ **boundary_metadata,
+ "limits": {
+ "max_rows": limits.max_rows,
+ "max_bytes": limits.max_bytes,
+ "timeout_seconds": limits.timeout_seconds,
+ },
},
}
@@ -648,12 +717,19 @@ class DorisQueryExecutor:
db_name: str | None = None,
catalog_name: str | None = None,
limit: int = 1000,
+ max_bytes: int | None = None,
timeout: int = 30,
session_id: str = "mcp_session",
user_id: str = "mcp_user",
auth_context: AuthContext | None = None,
) -> dict[str, Any]:
"""Execute optional catalog/db context and target SQL on one routed
connection."""
+ limits = resolve_result_limits(
+ self.config,
+ max_rows=limit,
+ max_bytes=max_bytes,
+ timeout_seconds=timeout,
+ )
try:
context_statements = self._build_context_statements(db_name,
catalog_name)
except SQLSecurityError as exc:
@@ -681,29 +757,42 @@ class DorisQueryExecutor:
connection = None
try:
- connection = await self._acquire_routed_connection(session_id,
auth_context)
- for context_sql in context_statements:
- if timeout:
- await asyncio.wait_for(
- connection.execute(context_sql,
auth_context=auth_context),
- timeout=timeout,
+ async with asyncio.timeout(limits.timeout_seconds):
+ connection = await self._acquire_routed_connection(
+ session_id,
+ auth_context,
+ )
+ query_results: list[QueryResult] = []
+ for context_sql in context_statements:
+ await connection.execute(
+ context_sql,
+ auth_context=auth_context,
)
- else:
- await connection.execute(context_sql,
auth_context=auth_context)
-
- query_results: list[QueryResult] = []
- for statement in target_statements:
- if timeout:
- result = await asyncio.wait_for(
- connection.execute(statement,
auth_context=auth_context),
- timeout=timeout,
+
+ remaining_rows = limits.max_rows
+ remaining_bytes = limits.max_bytes
+ for statement in target_statements:
+ statement_limits = ResultLimits(
+ max_rows=max(1, remaining_rows),
+ max_bytes=max(256, remaining_bytes),
+ timeout_seconds=limits.timeout_seconds,
)
- else:
- result = await connection.execute(statement,
auth_context=auth_context)
- query_results.append(result)
+ result = await connection.execute(
+ statement,
+ auth_context=auth_context,
+ max_rows=statement_limits.max_rows,
+ max_bytes=statement_limits.max_bytes,
+ )
+ query_results.append(result)
+ remaining_rows -= result.row_count
+ remaining_bytes -= int(
+ result.metadata.get("result_bytes", 2)
+ )
+ if remaining_rows <= 0 or remaining_bytes < 256:
+ break
if len(query_results) == 1:
- payload = self._query_result_payload(query_results[0])
+ payload = self._query_result_payload(query_results[0], limits)
return {
"success": True,
**payload,
@@ -712,7 +801,10 @@ class DorisQueryExecutor:
return {
"success": True,
"multiple_results": True,
- "results": [self._query_result_payload(result) for result in
query_results],
+ "results": [
+ self._query_result_payload(result, limits)
+ for result in query_results
+ ],
}
finally:
if connection is not None:
@@ -776,6 +868,7 @@ class DorisQueryExecutor:
self,
sql: str,
limit: int = 1000,
+ max_bytes: int | None = None,
timeout: int = 30,
session_id: str = "mcp_session",
user_id: str = "mcp_user",
@@ -787,6 +880,21 @@ class DorisQueryExecutor:
FIX for Issue #62 Bug 1: Now accepts auth_context parameter to support
token-bound database configuration
"""
+ try:
+ limits = resolve_result_limits(
+ self.config,
+ max_rows=limit,
+ max_bytes=max_bytes,
+ timeout_seconds=timeout,
+ )
+ except ResultLimitError as exc:
+ return {
+ "success": False,
+ "error": str(exc),
+ "error_type": "invalid_result_limits",
+ "data": None,
+ }
+
max_retries = 2
retry_count = 0
@@ -876,8 +984,9 @@ class DorisQueryExecutor:
sql,
db_name=db_name,
catalog_name=catalog_name,
- limit=limit,
- timeout=timeout,
+ limit=limits.max_rows,
+ max_bytes=limits.max_bytes,
+ timeout=limits.timeout_seconds,
session_id=session_id,
user_id=user_id,
auth_context=auth_context,
@@ -889,9 +998,15 @@ class DorisQueryExecutor:
if s.strip()
]
if len(all_statements) > 1:
- return await
self.execute_batch_sqls_for_mcp(sqls=all_statements, timeout=timeout,
-
session_id=session_id, user_id=user_id,
-
auth_context=auth_context)
+ return await self.execute_batch_sqls_for_mcp(
+ sqls=all_statements,
+ max_rows=limits.max_rows,
+ max_bytes=limits.max_bytes,
+ timeout=limits.timeout_seconds,
+ session_id=session_id,
+ user_id=user_id,
+ auth_context=auth_context,
+ )
# Add LIMIT if not present and it's a single SELECT query.
# Split first so a multi-statement SQL block is not turned into
@@ -900,34 +1015,25 @@ class DorisQueryExecutor:
if get_first_sql_keyword(sql) == "SELECT" and "LIMIT" not in
sql_upper:
if sql.endswith(";"):
sql = sql[:-1]
- sql = f"{sql} LIMIT {limit}"
+ sql = f"{sql} LIMIT {limits.max_rows}"
# Create query request
query_request = QueryRequest(
sql=sql,
session_id=session_id,
user_id=user_id,
- timeout=timeout,
+ timeout=limits.timeout_seconds,
+ max_rows=limits.max_rows,
+ max_bytes=limits.max_bytes,
cache_enabled=False # Disable cache for MCP calls to
ensure fresh data
)
# Execute query with retry logic
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))
-
return {
"success": True,
- "data": serialized_data,
- "row_count": result.row_count,
- "execution_time": result.execution_time,
- "metadata": {
- "columns": result.metadata.get("columns", []),
- "query": sql
- }
+ **self._query_result_payload(result, limits),
}
except Exception as e:
@@ -1170,6 +1276,7 @@ async def execute_sql_query(
# Extract parameters from kwargs or use defaults
limit = kwargs.get("limit", 1000)
+ max_bytes = kwargs.get("max_bytes")
timeout = kwargs.get("timeout", 30)
session_id = kwargs.get("session_id", "mcp_session")
user_id = kwargs.get("user_id", "mcp_user")
@@ -1181,6 +1288,7 @@ async def execute_sql_query(
result = await executor.execute_sql_for_mcp(
sql=sql,
limit=limit,
+ max_bytes=max_bytes,
timeout=timeout,
session_id=session_id,
user_id=user_id,
diff --git a/doris_mcp_server/utils/schema_extractor.py
b/doris_mcp_server/utils/schema_extractor.py
index 0d99b0c..b43f735 100644
--- a/doris_mcp_server/utils/schema_extractor.py
+++ b/doris_mcp_server/utils/schema_extractor.py
@@ -2102,6 +2102,8 @@ class MetadataExtractor:
catalog_name: str | None = None,
max_rows: int = 100,
timeout: int = 30,
+ *,
+ max_bytes: int | None = None,
) -> dict[str, Any]:
"""
Execute SQL query and return results, supports catalog federation
queries
@@ -2193,6 +2195,7 @@ class MetadataExtractor:
sql=sql,
connection_manager=self.connection_manager,
limit=max_rows,
+ max_bytes=max_bytes,
timeout=timeout,
db_name=db_name,
catalog_name=catalog_name,
@@ -2598,10 +2601,17 @@ class MetadataManager:
catalog_name: str | None = None,
max_rows: int = 100,
timeout: int = 30,
+ *,
+ max_bytes: int | None = None,
) -> dict[str, Any]:
"""Execute SQL query and return results, supports catalog federation
queries"""
return await self.extractor.exec_query_for_mcp(
- sql, db_name, catalog_name, max_rows, timeout
+ sql,
+ db_name,
+ catalog_name,
+ max_rows,
+ timeout,
+ max_bytes=max_bytes,
)
async def get_table_schema(
diff --git a/test/integration/test_real_doris_transports.py
b/test/integration/test_real_doris_transports.py
index b59c1a9..3c1fcc9 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -376,19 +376,148 @@ async def _exec_query(
sql: str,
*,
timeout: int = 5,
+ max_rows: int = 20,
+ max_bytes: int | None = None,
) -> tuple[Any, dict[str, Any]]:
+ arguments: dict[str, Any] = {
+ "sql": sql,
+ "max_rows": max_rows,
+ "timeout": timeout,
+ }
+ if max_bytes is not None:
+ arguments["max_bytes"] = max_bytes
result = await client.call_tool(
"exec_query",
- {
- "sql": sql,
- "max_rows": 20,
- "timeout": timeout,
- },
+ arguments,
)
assert isinstance(result.structured_content, dict)
return result, result.structured_content
[email protected]("transport", ["http", "stdio"])
+async def test_real_doris_result_boundaries_and_cancellation(
+ transport: str,
+ doris_sandbox: DorisSandbox,
+) -> None:
+ environment = _server_environment(
+ doris_sandbox.settings,
+ user=doris_sandbox.settings.user,
+ password=doris_sandbox.settings.password,
+ )
+ environment.update(
+ {
+ "MAX_RESULT_ROWS": "5",
+ "MAX_RESULT_BYTES": "256",
+ "QUERY_TIMEOUT": "5",
+ }
+ )
+ with doris_sandbox.admin_connection.cursor() as cursor:
+ cursor.executemany(
+ f"INSERT INTO {doris_sandbox.qualified_table} VALUES (%s, %s)",
+ [
+ (index, f"{doris_sandbox.marker}-{index}-" + ("x" * 32))
+ for index in range(1, 7)
+ ],
+ )
+
+ async with _transport_client(
+ transport,
+ environment,
+ read_timeout_seconds=10,
+ ) as client:
+ tools = {
+ tool.name: tool
+ for tool in (await client.list_tools(cache_mode="bypass")).tools
+ }
+ query_schema = tools["exec_query"].input_schema["properties"]
+ assert query_schema["max_rows"]["maximum"] == 5
+ assert query_schema["max_bytes"]["maximum"] == 256
+ assert query_schema["timeout"]["maximum"] == 5
+
+ with pytest.raises(MCPError) as excessive_rows:
+ await client.call_tool(
+ "exec_query",
+ {
+ "sql": f"SELECT * FROM {doris_sandbox.qualified_table}",
+ "max_rows": 6,
+ "max_bytes": 256,
+ "timeout": 5,
+ },
+ )
+ assert excessive_rows.value.code == -32602
+
+ row_result, row_payload = await _exec_query(
+ client,
+ f"SELECT id FROM {doris_sandbox.qualified_table} ORDER BY id",
+ max_rows=2,
+ max_bytes=256,
+ timeout=5,
+ )
+ assert row_result.is_error is False
+ assert len(row_payload["data"]) == 2
+ assert row_payload["metadata"]["result_bytes"] <= 256
+ assert row_payload["metadata"]["limits"] == {
+ "max_rows": 2,
+ "max_bytes": 256,
+ "timeout_seconds": 5,
+ }
+
+ byte_result, byte_payload = await _exec_query(
+ client,
+ (
+ "SELECT id, marker "
+ f"FROM {doris_sandbox.qualified_table} ORDER BY id"
+ ),
+ max_rows=5,
+ max_bytes=256,
+ timeout=5,
+ )
+ assert byte_result.is_error is False
+ assert byte_payload["metadata"]["result_bytes"] <= 256
+ assert byte_payload["metadata"]["truncated"] is True
+ assert byte_payload["metadata"]["truncation_reason"] == "byte_limit"
+ assert 0 < len(byte_payload["data"]) < 5
+
+ if transport == "stdio":
+ cancelled_query = asyncio.create_task(
+ client.call_tool(
+ "exec_query",
+ {
+ "sql": "SELECT SLEEP(5) AS slept",
+ "max_rows": 1,
+ "max_bytes": 256,
+ "timeout": 5,
+ },
+ )
+ )
+ await asyncio.sleep(0.2)
+ cancelled_query.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await cancelled_query
+ else:
+ timed_out, timed_out_payload = await _exec_query(
+ client,
+ "SELECT SLEEP(5) AS slept",
+ max_rows=1,
+ max_bytes=256,
+ timeout=1,
+ )
+ assert timed_out.is_error is True
+ assert timed_out_payload["error_type"] == "timeout"
+
+ recovery_started = time.monotonic()
+ recovered_result, recovered_payload = await _exec_query(
+ client,
+ "SELECT 1 AS recovered",
+ max_rows=1,
+ max_bytes=256,
+ timeout=5,
+ )
+ assert time.monotonic() - recovery_started < 3
+ assert recovered_result.is_error is False
+ assert recovered_payload["data"] == [{"recovered": 1}]
+
+
async def _collect_list_pages(
list_method: Any,
*,
diff --git a/test/tools/test_tools_operation_guard.py
b/test/tools/test_tools_operation_guard.py
index ceda039..8a679c9 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -32,12 +32,22 @@ class FakeRoutedConnection:
self.session_id = session_id
self.auth_context = auth_context
- async def execute(self, sql, params=None, auth_context=None):
+ async def execute(
+ self,
+ sql,
+ params=None,
+ auth_context=None,
+ *,
+ max_rows=None,
+ max_bytes=None,
+ ):
return await self.manager.execute_query(
self.session_id,
sql,
params,
auth_context or self.auth_context,
+ max_rows=max_rows,
+ max_bytes=max_bytes,
)
@@ -81,7 +91,16 @@ class FakeRoutedConnectionManager:
async def release_connection(self, session_id, connection):
self.connection_releases += 1
- async def execute_query(self, session_id, sql, params=None,
auth_context=None):
+ async def execute_query(
+ self,
+ session_id,
+ sql,
+ params=None,
+ auth_context=None,
+ *,
+ max_rows=None,
+ max_bytes=None,
+ ):
if getattr(auth_context, "auth_method", "") != "doris_oauth":
raise AssertionError("Doris OAuth tool path did not pass
AuthContext")
if getattr(auth_context, "doris_user", "") != "alice":
@@ -92,6 +111,8 @@ class FakeRoutedConnectionManager:
"sql": sql,
"params": params,
"doris_user": auth_context.doris_user,
+ "max_rows": max_rows,
+ "max_bytes": max_bytes,
}
)
if sql.strip().upper().startswith("EXPLAIN"):
diff --git a/test/utils/test_adbc_query_tools.py
b/test/utils/test_adbc_query_tools.py
new file mode 100644
index 0000000..e7f2bf0
--- /dev/null
+++ b/test/utils/test_adbc_query_tools.py
@@ -0,0 +1,188 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Bounded streaming and cancellation tests for Arrow Flight SQL queries."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from doris_mcp_server.result_limits import ResultLimits
+from doris_mcp_server.utils.adbc_query_tools import DorisADBCQueryTools
+
+
+def _manager() -> SimpleNamespace:
+ return SimpleNamespace(
+ config=SimpleNamespace(
+ security=SimpleNamespace(max_result_rows=50),
+ performance=SimpleNamespace(
+ max_result_bytes=4096,
+ query_timeout=20,
+ ),
+ adbc=SimpleNamespace(
+ default_max_rows=20,
+ default_timeout=10,
+ default_return_format="dict",
+ ),
+ ),
+ security_manager=None,
+ )
+
+
+class _StreamingCursor:
+ description = (("id", "BIGINT"), ("payload", "VARCHAR"))
+
+ def __init__(self, rows: list[tuple[Any, ...]]) -> None:
+ self._rows = rows
+ self._offset = 0
+ self.executed_sql: str | None = None
+ self.fetch_sizes: list[int] = []
+ self.closed = False
+
+ def execute(self, sql: str) -> None:
+ self.executed_sql = sql
+
+ def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
+ self.fetch_sizes.append(size)
+ batch = self._rows[self._offset : self._offset + size]
+ self._offset += len(batch)
+ return batch
+
+ def close(self) -> None:
+ self.closed = True
+
+ def adbc_cancel(self) -> None:
+ raise AssertionError("completed queries must not be cancelled")
+
+
+class _BlockingCursor:
+ description: tuple[()] = ()
+
+ def __init__(self) -> None:
+ self.started = threading.Event()
+ self.cancelled = threading.Event()
+ self.closed = False
+ self.cancel_calls = 0
+
+ def execute(self, sql: str) -> None:
+ del sql
+ self.started.set()
+ self.cancelled.wait(timeout=5)
+
+ def fetchmany(self, size: int) -> list[tuple[Any, ...]]:
+ del size
+ return []
+
+ def adbc_cancel(self) -> None:
+ self.cancel_calls += 1
+ self.cancelled.set()
+
+ def close(self) -> None:
+ self.closed = True
+
+
[email protected]
+async def test_adbc_streams_to_row_limit_without_fetchall() -> None:
+ cursor = _StreamingCursor(
+ [(1, "one"), (2, "two"), (3, "three")]
+ )
+ tools = DorisADBCQueryTools(_manager())
+ tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
+
+ result = await tools._execute_query_with_adbc(
+ "SELECT id, payload FROM bounded",
+ ResultLimits(max_rows=2, max_bytes=4096, timeout_seconds=5),
+ "dict",
+ )
+
+ assert result["success"] is True
+ assert result["result"]["data"] == [
+ {"id": 1, "payload": "one"},
+ {"id": 2, "payload": "two"},
+ ]
+ assert result["truncated"] is True
+ assert result["truncation_reason"] == "max_rows"
+ assert result["result_bytes"] <= 4096
+ assert cursor.closed is True
+ assert cursor.fetch_sizes == [3]
+
+
[email protected]
+async def test_adbc_streams_to_byte_limit() -> None:
+ cursor = _StreamingCursor(
+ [(1, "x" * 180), (2, "y" * 180)]
+ )
+ tools = DorisADBCQueryTools(_manager())
+ tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
+
+ result = await tools._execute_query_with_adbc(
+ "SELECT id, payload FROM bounded",
+ ResultLimits(max_rows=10, max_bytes=256, timeout_seconds=5),
+ "pandas",
+ )
+
+ assert result["success"] is True
+ assert result["result"]["num_rows"] == 1
+ assert result["truncated"] is True
+ assert result["truncation_reason"] == "max_bytes"
+ assert result["result_bytes"] <= 256
+ assert cursor.closed is True
+
+
[email protected]
+async def test_adbc_cancellation_calls_driver_and_reaps_worker() -> None:
+ cursor = _BlockingCursor()
+ tools = DorisADBCQueryTools(_manager())
+ tools.adbc_client = SimpleNamespace(cursor=lambda: cursor)
+
+ task = asyncio.create_task(
+ tools._execute_query_with_adbc(
+ "SELECT SLEEP(5)",
+ ResultLimits(max_rows=1, max_bytes=256, timeout_seconds=10),
+ "dict",
+ )
+ )
+ assert await asyncio.to_thread(cursor.started.wait, 1)
+
+ task.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ assert cursor.cancel_calls == 1
+ assert cursor.closed is True
+
+
[email protected]
+async def test_adbc_rejects_limit_escalation_before_network_checks() -> None:
+ tools = DorisADBCQueryTools(_manager())
+
+ result = await tools.exec_adbc_query(
+ "SELECT 1",
+ max_rows=51,
+ max_bytes=1024,
+ timeout=5,
+ )
+
+ assert result["success"] is False
+ assert result["error_type"] == "invalid_result_limits"
+ assert result["error"] == (
+ "max_rows exceeds the configured maximum of 50"
+ )
diff --git a/test/utils/test_db.py b/test/utils/test_db.py
index 158e4e9..36d9e9d 100644
--- a/test/utils/test_db.py
+++ b/test/utils/test_db.py
@@ -1,3 +1,4 @@
+import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -153,6 +154,28 @@ def _make_doris_connection(cursor_description,
fetchall_rows, rowcount=0):
return DorisConnection(connection=raw_connection, session_id="test")
+def _make_bounded_doris_connection(cursor_description, fetchmany_rows):
+ cursor = MagicMock()
+ cursor.execute = AsyncMock(return_value=None)
+ cursor.fetchmany = AsyncMock(side_effect=fetchmany_rows)
+ cursor.description = cursor_description
+ cursor.rowcount = 0
+
+ cursor_ctx = MagicMock()
+ cursor_ctx.__aenter__ = AsyncMock(return_value=cursor)
+ cursor_ctx.__aexit__ = AsyncMock(return_value=None)
+
+ raw_connection = MagicMock()
+ raw_connection.cursor = MagicMock(return_value=cursor_ctx)
+ raw_connection.ensure_closed = AsyncMock()
+
+ return (
+ DorisConnection(connection=raw_connection, session_id="bounded"),
+ cursor,
+ raw_connection,
+ )
+
+
class TestExecuteResultSetDetection:
"""Behavior contract for DorisConnection.execute().
@@ -270,3 +293,84 @@ class TestExecuteResultSetDetection:
assert result.data == []
assert result.row_count == affected
+
+
+class TestBoundedResultFetch:
+ async def
test_row_limit_stops_streaming_and_closes_physical_connection(self):
+ conn, cursor, raw_connection = _make_bounded_doris_connection(
+ [("id", None, None, None, None, None, None)],
+ [[{"id": 1}, {"id": 2}, {"id": 3}]],
+ )
+
+ result = await conn.execute(
+ "WITH data AS (...) SELECT * FROM data",
+ max_rows=2,
+ max_bytes=4096,
+ )
+
+ assert result.data == [{"id": 1}, {"id": 2}]
+ assert result.row_count == 2
+ assert result.metadata["truncated"] is True
+ assert result.metadata["truncation_reason"] == "row_limit"
+ assert result.metadata["result_bytes"] <= 4096
+ cursor.fetchall.assert_not_called()
+ raw_connection.ensure_closed.assert_awaited_once()
+ assert conn.is_healthy is False
+
+ async def test_byte_limit_is_measured_after_masking(self):
+ conn, _, raw_connection = _make_bounded_doris_connection(
+ [("secret", None, None, None, None, None, None)],
+ [[{"secret": "x"}], []],
+ )
+ security_manager = MagicMock()
+ security_manager.validate_sql_security = AsyncMock(
+ return_value=MagicMock(
+ is_valid=True,
+ risk_level="low",
+ blocked_operations=[],
+ )
+ )
+ security_manager.apply_data_masking = AsyncMock(
+ return_value=[{"secret": "masked-value-that-is-too-large"}]
+ )
+ conn.security_manager = security_manager
+
+ result = await conn.execute(
+ "SELECT secret FROM t",
+ auth_context=object(),
+ max_rows=10,
+ max_bytes=24,
+ )
+
+ assert result.data == []
+ assert result.metadata["truncated"] is True
+ assert result.metadata["truncation_reason"] == "byte_limit"
+ assert result.metadata["result_bytes"] == 2
+ security_manager.apply_data_masking.assert_awaited_once()
+ raw_connection.ensure_closed.assert_awaited_once()
+
+ async def test_task_cancellation_closes_connection_and_propagates(self):
+ conn, cursor, raw_connection = _make_bounded_doris_connection(
+ [("id", None, None, None, None, None, None)],
+ [],
+ )
+ async def never_returns(*args, **kwargs):
+ del args, kwargs
+ await asyncio.Event().wait()
+
+ cursor.execute.side_effect = never_returns
+ task = asyncio.create_task(
+ conn.execute(
+ "SELECT SLEEP(30)",
+ max_rows=1,
+ max_bytes=4096,
+ )
+ )
+ await asyncio.sleep(0)
+ task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+ raw_connection.ensure_closed.assert_awaited_once()
+ assert conn.is_healthy is False
diff --git a/test/utils/test_doris_user_pool_manager.py
b/test/utils/test_doris_user_pool_manager.py
index 9d64b42..e8285f0 100644
--- a/test/utils/test_doris_user_pool_manager.py
+++ b/test/utils/test_doris_user_pool_manager.py
@@ -271,7 +271,16 @@ async def
test_execute_query_releases_to_captured_doris_user_owner(manager, monk
await manager.create_or_replace_doris_user_pool("alice", "pw1")
- async def fake_execute(self, sql, params=None, auth_context=None):
+ async def fake_execute(
+ self,
+ sql,
+ params=None,
+ auth_context=None,
+ *,
+ max_rows=None,
+ max_bytes=None,
+ ):
+ del max_rows, max_bytes
return QueryResult(data=[{"ok": 1}], metadata={}, execution_time=0.0,
row_count=1, sql=sql)
monkeypatch.setattr(DorisConnection, "execute", fake_execute)
diff --git a/test/utils/test_result_limits.py b/test/utils/test_result_limits.py
new file mode 100644
index 0000000..0475b22
--- /dev/null
+++ b/test/utils/test_result_limits.py
@@ -0,0 +1,168 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for non-escalating query result and timeout budgets."""
+
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+
+from doris_mcp_server.result_limits import (
+ ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS,
+ ABSOLUTE_MAX_RESULT_BYTES,
+ ABSOLUTE_MAX_RESULT_ROWS,
+ ResultLimitError,
+ configured_result_limits,
+ resolve_result_limits,
+)
+from doris_mcp_server.tools.tools_manager import DorisToolsManager
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.query_executor import DorisQueryExecutor
+
+
+def _config(
+ *,
+ rows: int = 50,
+ result_bytes: int = 4096,
+ timeout: int = 20,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ security=SimpleNamespace(
+ max_result_rows=rows,
+ enable_security_check=False,
+ ),
+ performance=SimpleNamespace(
+ max_result_bytes=result_bytes,
+ query_timeout=timeout,
+ max_cache_size=10,
+ cache_ttl=10,
+ max_concurrent_queries=2,
+ ),
+ adbc=SimpleNamespace(
+ default_max_rows=rows,
+ default_timeout=timeout,
+ default_return_format="dict",
+ ),
+ )
+
+
+def test_configured_limits_cannot_exceed_absolute_hard_caps() -> None:
+ limits = configured_result_limits(
+ _config(
+ rows=ABSOLUTE_MAX_RESULT_ROWS + 1,
+ result_bytes=ABSOLUTE_MAX_RESULT_BYTES + 1,
+ timeout=ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS + 1,
+ )
+ )
+
+ assert limits.max_rows == ABSOLUTE_MAX_RESULT_ROWS
+ assert limits.max_bytes == ABSOLUTE_MAX_RESULT_BYTES
+ assert limits.timeout_seconds == ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS
+
+
[email protected](
+ ("field", "value", "message"),
+ [
+ ("max_rows", True, "max_rows must be an integer"),
+ ("max_rows", 51, "max_rows exceeds the configured maximum of 50"),
+ ("max_bytes", 255, "max_bytes must be at least 256"),
+ ("max_bytes", 4097, "max_bytes exceeds the configured maximum of
4096"),
+ ("timeout_seconds", 21, "timeout exceeds the configured maximum of
20"),
+ ],
+)
+def test_request_cannot_raise_deployment_ceiling(
+ field: str,
+ value: object,
+ message: str,
+) -> None:
+ kwargs = {
+ "max_rows": 10,
+ "max_bytes": 1024,
+ "timeout_seconds": 10,
+ }
+ kwargs[field] = value
+
+ with pytest.raises(ResultLimitError, match=message):
+ resolve_result_limits(_config(), **kwargs)
+
+
+def test_doris_config_validation_rejects_values_above_hard_caps() -> None:
+ config = DorisConfig()
+ config.security.max_result_rows = ABSOLUTE_MAX_RESULT_ROWS + 1
+ config.performance.max_result_bytes = ABSOLUTE_MAX_RESULT_BYTES + 1
+ config.performance.query_timeout = ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS + 1
+
+ errors = config.validate()
+
+ assert f"Maximum result rows must not exceed {ABSOLUTE_MAX_RESULT_ROWS}"
in errors
+ assert (
+ "Maximum result bytes must be in the range "
+ f"256-{ABSOLUTE_MAX_RESULT_BYTES}"
+ ) in errors
+ assert (
+ "Query timeout must not exceed "
+ f"{ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS} seconds"
+ ) in errors
+
+
+def test_exec_query_schema_advertises_effective_ceilings() -> None:
+ connection_manager = Mock()
+ connection_manager.config = _config()
+ manager = DorisToolsManager(connection_manager)
+ tool = manager.tool_registry.resolve("exec_query").tool
+ assert tool is not None
+ properties = tool.input_schema["properties"]
+
+ assert properties["max_rows"]["maximum"] == 50
+ assert properties["max_bytes"]["maximum"] == 4096
+ assert properties["timeout"]["maximum"] == 20
+
+
+def test_exec_adbc_query_schema_advertises_effective_ceilings() -> None:
+ connection_manager = Mock()
+ connection_manager.config = _config()
+ manager = DorisToolsManager(connection_manager)
+ tool = manager.tool_registry.resolve("exec_adbc_query").tool
+ assert tool is not None
+ properties = tool.input_schema["properties"]
+
+ assert properties["max_rows"]["maximum"] == 50
+ assert properties["max_bytes"]["maximum"] == 4096
+ assert properties["timeout"]["maximum"] == 20
+
+
[email protected]
+async def test_executor_returns_typed_error_before_database_dispatch() -> None:
+ connection_manager = Mock()
+ connection_manager.config = _config()
+ connection_manager.execute_query = Mock()
+ executor = DorisQueryExecutor(connection_manager)
+
+ result = await executor.execute_sql_for_mcp(
+ "SELECT 1",
+ limit=51,
+ max_bytes=1024,
+ timeout=10,
+ )
+
+ assert result == {
+ "success": False,
+ "error": "max_rows exceeds the configured maximum of 50",
+ "error_type": "invalid_result_limits",
+ "data": None,
+ }
+ connection_manager.execute_query.assert_not_called()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]