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 c10e9da  fix: make default query row limit configurable (#154)
c10e9da is described below

commit c10e9da9e95e7a78a55124e06fe8b28b7a0f9a09
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 18:33:27 2026 +0800

    fix: make default query row limit configurable (#154)
---
 .env.example                                   |  2 +
 README.md                                      |  2 +
 docker-compose.yml                             |  1 +
 doris_mcp_server/result_limits.py              | 12 ++++++
 doris_mcp_server/tools/tool_catalog.py         |  7 ++--
 doris_mcp_server/tools/tools_manager.py        |  6 ++-
 doris_mcp_server/utils/config.py               | 17 +++++++++
 test/integration/test_real_doris_transports.py | 19 ++++++++++
 test/utils/test_result_limits.py               | 52 +++++++++++++++++++++++++-
 9 files changed, 113 insertions(+), 5 deletions(-)

diff --git a/.env.example b/.env.example
index 4f80861..676e8b9 100644
--- a/.env.example
+++ b/.env.example
@@ -368,6 +368,8 @@ 
BLOCKED_KEYWORDS=DROP,CREATE,ALTER,TRUNCATE,DELETE,INSERT,UPDATE,GRANT,REVOKE,EX
 MAX_QUERY_COMPLEXITY=100
 # Deployment ceiling; absolute hard cap: 100000
 MAX_RESULT_ROWS=10000
+# Per-query default when exec_query omits max_rows; must not exceed 
MAX_RESULT_ROWS
+DEFAULT_RESULT_ROWS=100
 
 # Data masking
 ENABLE_MASKING=true
diff --git a/README.md b/README.md
index 5fbbbd8..5bcd34a 100644
--- a/README.md
+++ b/README.md
@@ -346,6 +346,8 @@ cp .env.example .env
     *   `ENABLE_MASKING`: Enable data masking (default: true)
     *   `MAX_RESULT_ROWS`: Deployment ceiling for returned query rows
         (default: 10000; absolute hard cap: 100000)
+    *   `DEFAULT_RESULT_ROWS`: Default row budget when `exec_query.max_rows`
+        is omitted (default: 100; cannot exceed `MAX_RESULT_ROWS`)
 *   **ADBC Configuration (New in v0.5.0)**:
     *   `ADBC_DEFAULT_MAX_ROWS`: Default maximum rows for ADBC queries
         (default: 10000; cannot exceed `MAX_RESULT_ROWS`)
diff --git a/docker-compose.yml b/docker-compose.yml
index 87a6706..072d2b3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -42,6 +42,7 @@ services:
       - ENABLE_TOKEN_AUTH=true
       - TOKEN_ADMIN_FILE=/run/secrets/mcp_static_token
       - MAX_RESULT_ROWS=10000
+      - DEFAULT_RESULT_ROWS=${DEFAULT_RESULT_ROWS:-100}
       - MAX_RESULT_BYTES=1048576
       - QUERY_TIMEOUT=300
       
diff --git a/doris_mcp_server/result_limits.py 
b/doris_mcp_server/result_limits.py
index 003d17a..8c071fd 100644
--- a/doris_mcp_server/result_limits.py
+++ b/doris_mcp_server/result_limits.py
@@ -34,6 +34,7 @@ ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS = 300
 MIN_RESULT_BYTES = 256
 
 DEFAULT_MAX_RESULT_ROWS = 10_000
+DEFAULT_RESULT_ROWS = 100
 DEFAULT_MAX_RESULT_BYTES = 1024 * 1024
 DEFAULT_QUERY_TIMEOUT_SECONDS = 300
 
@@ -85,6 +86,17 @@ def configured_result_limits(config: object | None) -> 
ResultLimits:
     )
 
 
+def configured_default_result_rows(config: object | None) -> int:
+    """Return the configured default row budget bounded by the deployment 
ceiling."""
+    ceilings = configured_result_limits(config)
+    performance = getattr(config, "performance", None)
+    return _configured_positive_int(
+        getattr(performance, "default_result_rows", None),
+        default=min(DEFAULT_RESULT_ROWS, ceilings.max_rows),
+        hard_maximum=ceilings.max_rows,
+    )
+
+
 def _requested_limit(
     value: object | None,
     *,
diff --git a/doris_mcp_server/tools/tool_catalog.py 
b/doris_mcp_server/tools/tool_catalog.py
index a7e7b07..8c26208 100644
--- a/doris_mcp_server/tools/tool_catalog.py
+++ b/doris_mcp_server/tools/tool_catalog.py
@@ -20,7 +20,7 @@ from typing import Any
 
 from mcp.types import Tool
 
-from ..result_limits import configured_result_limits
+from ..result_limits import configured_default_result_rows, 
configured_result_limits
 from ..utils.config import ADBCConfig
 from .tool_registry import ToolDefinitionRegistry
 
@@ -32,6 +32,7 @@ def build_tool_registry(
     """Build immutable tool metadata and bind it to a handler owner."""
     adbc_config = getattr(config, "adbc", None) or ADBCConfig()
     result_limits = configured_result_limits(config)
+    default_result_rows = configured_default_result_rows(config)
     adbc_default_max_rows = min(
         adbc_config.default_max_rows,
         result_limits.max_rows,
@@ -54,7 +55,7 @@ def build_tool_registry(
 
 - catalog_name (string) [Optional] - Reference catalog name for context, 
defaults to current catalog
 
-- max_rows (integer) [Optional] - Maximum number of rows to return, default 100
+- max_rows (integer) [Optional] - Maximum number of rows to return, defaults 
to the configured DEFAULT_RESULT_ROWS value
 
 - max_bytes (integer) [Optional] - Maximum UTF-8 JSON bytes for returned row 
data
 
@@ -78,7 +79,7 @@ def build_tool_registry(
                     "max_rows": {
                         "type": "integer",
                         "description": "Maximum number of rows to return",
-                        "default": 100,
+                        "default": default_result_rows,
                         "minimum": 1,
                         "maximum": result_limits.max_rows,
                     },
diff --git a/doris_mcp_server/tools/tools_manager.py 
b/doris_mcp_server/tools/tools_manager.py
index 5ca292b..0242197 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_default_result_rows
 from ..utils.adbc_query_tools import DorisADBCQueryTools
 from ..utils.analysis_tools import MemoryTracker, SQLAnalyzer, TableAnalyzer
 from ..utils.data_exploration_tools import DataExplorationTools
@@ -224,7 +225,10 @@ class DorisToolsManager:
         sql = self._required_string(arguments, "sql")
         db_name = arguments.get("db_name")
         catalog_name = arguments.get("catalog_name")
-        max_rows = arguments.get("max_rows", 100)
+        max_rows = arguments.get(
+            "max_rows",
+            configured_default_result_rows(self.connection_manager.config),
+        )
         max_bytes = arguments.get("max_bytes")
         timeout = arguments.get("timeout", 30)
 
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index ba061b7..dcbd7c5 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -40,6 +40,7 @@ from ..result_limits import (
     ABSOLUTE_MAX_RESULT_ROWS,
     DEFAULT_MAX_RESULT_BYTES,
     DEFAULT_MAX_RESULT_ROWS,
+    DEFAULT_RESULT_ROWS,
     MIN_RESULT_BYTES,
 )
 from ..tools.tool_registry import (
@@ -507,6 +508,7 @@ class PerformanceConfig:
     # Concurrency control configuration
     max_concurrent_queries: int = 50
     query_timeout: int = 300
+    default_result_rows: int = DEFAULT_RESULT_ROWS
     max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES
 
     # Connection pool optimization configuration
@@ -1045,6 +1047,12 @@ class DorisConfig:
         config.performance.query_timeout = int(
             os.getenv("QUERY_TIMEOUT", str(config.performance.query_timeout))
         )
+        config.performance.default_result_rows = int(
+            os.getenv(
+                "DEFAULT_RESULT_ROWS",
+                str(config.performance.default_result_rows),
+            )
+        )
         config.performance.max_result_bytes = int(
             os.getenv(
                 "MAX_RESULT_BYTES",
@@ -1363,6 +1371,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,
+                "default_result_rows": self.performance.default_result_rows,
                 "max_result_bytes": self.performance.max_result_bytes,
                 "connection_pool_size": self.performance.connection_pool_size,
                 "idle_timeout": self.performance.idle_timeout,
@@ -1520,6 +1529,14 @@ class DorisConfig:
                 f"{ABSOLUTE_MAX_QUERY_TIMEOUT_SECONDS} seconds"
             )
 
+        if self.performance.default_result_rows <= 0:
+            errors.append("Default result rows must be greater than 0")
+        elif self.performance.default_result_rows > 
self.security.max_result_rows:
+            errors.append(
+                "Default result rows must not exceed the configured "
+                f"maximum result rows ({self.security.max_result_rows})"
+            )
+
         if not (
             MIN_RESULT_BYTES
             <= self.performance.max_result_bytes
diff --git a/test/integration/test_real_doris_transports.py 
b/test/integration/test_real_doris_transports.py
index 3c1fcc9..2a61678 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -407,6 +407,7 @@ async def 
test_real_doris_result_boundaries_and_cancellation(
     environment.update(
         {
             "MAX_RESULT_ROWS": "5",
+            "DEFAULT_RESULT_ROWS": "2",
             "MAX_RESULT_BYTES": "256",
             "QUERY_TIMEOUT": "5",
         }
@@ -431,9 +432,27 @@ async def 
test_real_doris_result_boundaries_and_cancellation(
         }
         query_schema = tools["exec_query"].input_schema["properties"]
         assert query_schema["max_rows"]["maximum"] == 5
+        assert query_schema["max_rows"]["default"] == 2
         assert query_schema["max_bytes"]["maximum"] == 256
         assert query_schema["timeout"]["maximum"] == 5
 
+        default_result = await client.call_tool(
+            "exec_query",
+            {
+                "sql": (
+                    "SELECT id "
+                    f"FROM {doris_sandbox.qualified_table} ORDER BY id"
+                ),
+                "max_bytes": 256,
+                "timeout": 5,
+            },
+        )
+        assert isinstance(default_result.structured_content, dict)
+        default_payload = default_result.structured_content
+        assert default_result.is_error is False
+        assert len(default_payload["data"]) == 2
+        assert default_payload["metadata"]["limits"]["max_rows"] == 2
+
         with pytest.raises(MCPError) as excessive_rows:
             await client.call_tool(
                 "exec_query",
diff --git a/test/utils/test_result_limits.py b/test/utils/test_result_limits.py
index 0475b22..7e95073 100644
--- a/test/utils/test_result_limits.py
+++ b/test/utils/test_result_limits.py
@@ -17,7 +17,7 @@
 """Tests for non-escalating query result and timeout budgets."""
 
 from types import SimpleNamespace
-from unittest.mock import Mock
+from unittest.mock import AsyncMock, Mock
 
 import pytest
 
@@ -37,6 +37,7 @@ from doris_mcp_server.utils.query_executor import 
DorisQueryExecutor
 def _config(
     *,
     rows: int = 50,
+    default_rows: int = 10,
     result_bytes: int = 4096,
     timeout: int = 20,
 ) -> SimpleNamespace:
@@ -46,6 +47,7 @@ def _config(
             enable_security_check=False,
         ),
         performance=SimpleNamespace(
+            default_result_rows=default_rows,
             max_result_bytes=result_bytes,
             query_timeout=timeout,
             max_cache_size=10,
@@ -128,10 +130,58 @@ def 
test_exec_query_schema_advertises_effective_ceilings() -> None:
     properties = tool.input_schema["properties"]
 
     assert properties["max_rows"]["maximum"] == 50
+    assert properties["max_rows"]["default"] == 10
     assert properties["max_bytes"]["maximum"] == 4096
     assert properties["timeout"]["maximum"] == 20
 
 
+def test_config_validation_rejects_default_rows_above_ceiling() -> None:
+    config = DorisConfig()
+    config.security.max_result_rows = 50
+    config.performance.default_result_rows = 51
+
+    assert (
+        "Default result rows must not exceed the configured maximum result 
rows (50)"
+        in config.validate()
+    )
+
+
+def test_environment_configures_default_rows_independently_from_ceiling(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path,
+) -> None:
+    monkeypatch.setenv("MAX_RESULT_ROWS", "500")
+    monkeypatch.setenv("DEFAULT_RESULT_ROWS", "321")
+    monkeypatch.setenv("ADBC_DEFAULT_MAX_ROWS", "500")
+
+    config = DorisConfig.from_env(str(tmp_path / "missing.env"))
+
+    assert config.security.max_result_rows == 500
+    assert config.performance.default_result_rows == 321
+    assert config.validate() == []
+
+
[email protected]
+async def 
test_exec_query_uses_configured_default_rows_when_argument_is_omitted() -> None:
+    connection_manager = Mock()
+    connection_manager.config = _config(default_rows=17)
+    manager = DorisToolsManager(connection_manager)
+    manager.metadata_extractor.exec_query_for_mcp = AsyncMock()
+    manager.metadata_extractor.exec_query_for_mcp.return_value = {"success": 
True}
+
+    result = await manager._exec_query_tool({"sql": "SELECT 1"})
+
+    assert result == {"success": True}
+    manager.metadata_extractor.exec_query_for_mcp.assert_called_once_with(
+        "SELECT 1",
+        None,
+        None,
+        17,
+        30,
+        max_bytes=None,
+    )
+
+
 def test_exec_adbc_query_schema_advertises_effective_ceilings() -> None:
     connection_manager = Mock()
     connection_manager.config = _config()


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to