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 f6f0a57  fix: separate Doris SQL and HTTP endpoints (#130)
f6f0a57 is described below

commit f6f0a57835ea80413146bec607b4c02134d6eb99
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 01:29:23 2026 +0800

    fix: separate Doris SQL and HTTP endpoints (#130)
---
 .env.example                                   |  4 ++-
 README.md                                      | 29 ++++++++++-----
 doris_mcp_server/utils/analysis_tools.py       | 15 ++++++--
 doris_mcp_server/utils/config.py               | 21 ++++++++++-
 doris_mcp_server/utils/doris_http_client.py    |  6 ++--
 doris_mcp_server/utils/monitoring_tools.py     |  5 ++-
 test/integration/test_real_doris_transports.py | 24 ++++++++-----
 test/protocol/stdio_capability_server.py       |  3 +-
 test/protocol/test_mcp_v2_protocol.py          |  2 ++
 test/security/test_doris_http_ssrf.py          | 50 ++++++++++++++++++++++++--
 10 files changed, 130 insertions(+), 29 deletions(-)

diff --git a/.env.example b/.env.example
index 6c0b1de..0a4d410 100644
--- a/.env.example
+++ b/.env.example
@@ -30,7 +30,9 @@ DORIS_USER=root
 DORIS_PASSWORD=
 DORIS_DATABASE=information_schema
 
-# Doris FE HTTP API port (for Profile and other HTTP APIs)
+# Doris FE HTTP API endpoint (for Profile and other HTTP APIs).
+# Leave the host empty to reuse DORIS_HOST for backward compatibility.
+DORIS_FE_HTTP_HOST=
 DORIS_FE_HTTP_PORT=8030
 
 # Doris BE (Backend) HTTP allowlist for monitoring metrics.
diff --git a/README.md b/README.md
index a57c57c..ac44bbc 100644
--- a/README.md
+++ b/README.md
@@ -264,7 +264,8 @@ cp .env.example .env
     *   `DORIS_DATABASE`: Default database name (default: information_schema)
     *   `DORIS_MIN_CONNECTIONS`: Minimum connection pool size (default: 5)
     *   `DORIS_MAX_CONNECTIONS`: Maximum connection pool size (default: 20)
-    *   `DORIS_FE_HTTP_PORT`: FE HTTP API port for profile, table-size, and 
monitoring tools (default: 8030)
+    *   `DORIS_FE_HTTP_HOST`: Independent FE HTTP host for profile, 
table-size, and monitoring tools (default: empty, falling back to `DORIS_HOST`)
+    *   `DORIS_FE_HTTP_PORT`: Independent FE HTTP API port (default: 8030)
     *   `DORIS_BE_HOSTS`: Explicit BE HTTP allowlist for monitoring 
(comma-separated; BE HTTP metrics are disabled when empty)
     *   `DORIS_BE_WEBSERVER_PORT`: BE webserver port for monitoring tools 
(default: 8040)
     *   `DORIS_HTTP_CONNECT_TIMEOUT_SECONDS`: FE/BE HTTP connection timeout 
(default: 3)
@@ -1480,21 +1481,33 @@ If you have further requirements for the returned 
results, you can describe the
 
 ### Q: How to configure BE nodes for monitoring tools?
 
-**A:** Configure every BE HTTP node that the MCP server may contact:
+**A:** Configure SQL, FE HTTP, and BE HTTP endpoints independently when a
+proxy, tunnel, or split network exposes them at different addresses:
 
 ```bash
-# Explicitly allow these configured BE nodes
+# SQL/MySQL protocol endpoint
+DORIS_HOST=sql-gateway.internal
+DORIS_PORT=9030
+
+# FE HTTP endpoint; omit DORIS_FE_HTTP_HOST to reuse DORIS_HOST
+DORIS_FE_HTTP_HOST=fe-http-proxy.internal
+DORIS_FE_HTTP_PORT=8030
+
+# Explicit BE HTTP allowlist
 DORIS_BE_HOSTS=10.1.1.100,10.1.1.101,10.1.1.102
 DORIS_BE_WEBSERVER_PORT=8040
 ```
 
 BE HTTP endpoints are never inferred from `SHOW BACKENDS`: SQL metadata is not
 an outbound HTTP allowlist. If `DORIS_BE_HOSTS` is empty, BE HTTP metrics are
-disabled. FE and BE requests use only configured hosts and ports, pin validated
-DNS results for each request, reject metadata/link-local destinations, disable
-redirects, and enforce connection/read/total timeouts plus a response byte
-limit. Private and loopback addresses remain available for normal internal
-Doris deployments and SSH tunnels.
+disabled. `DORIS_FE_HTTP_HOST` falls back to `DORIS_HOST` only for backward
+compatibility; an explicit value is used for every FE monitoring, profile,
+trace, and table-size HTTP request. FE and BE requests use only configured
+hosts and ports, pin validated DNS results for each request, reject
+metadata/link-local destinations, disable redirects, and enforce
+connection/read/total timeouts plus a response byte limit. Private and loopback
+addresses remain available for normal internal Doris deployments and SSH
+tunnels.
 
 ### Q: How to use SQL Explain/Profile files with LLM for optimization?
 
diff --git a/doris_mcp_server/utils/analysis_tools.py 
b/doris_mcp_server/utils/analysis_tools.py
index 90ae515..878d06e 100644
--- a/doris_mcp_server/utils/analysis_tools.py
+++ b/doris_mcp_server/utils/analysis_tools.py
@@ -881,10 +881,13 @@ class SQLAnalyzer:
         """
         try:
             db_config = self.connection_manager.config.database
+            fe_http_host = (
+                getattr(db_config, "fe_http_host", "") or db_config.host
+            )
             http_client = DorisHTTPClient.from_database_config(db_config)
             response = await http_client.get(
                 role="fe",
-                host=db_config.host,
+                host=fe_http_host,
                 port=db_config.fe_http_port,
                 path=(
                     "/rest/v2/manager/query/trace_id/"
@@ -957,6 +960,9 @@ class SQLAnalyzer:
         """
         try:
             db_config = self.connection_manager.config.database
+            fe_http_host = (
+                getattr(db_config, "fe_http_host", "") or db_config.host
+            )
             endpoints = [
                 (
                     "/rest/v2/manager/query/profile/text/"
@@ -970,7 +976,7 @@ class SQLAnalyzer:
                 logger.info("Requesting profile from configured FE endpoint 
%s", i + 1)
                 response = await http_client.get(
                     role="fe",
-                    host=db_config.host,
+                    host=fe_http_host,
                     port=db_config.fe_http_port,
                     path=path,
                     params=params,
@@ -1058,10 +1064,13 @@ class SQLAnalyzer:
                 "Requesting table data size from configured FE endpoint with 
params: %s",
                 params,
             )
+            fe_http_host = (
+                getattr(db_config, "fe_http_host", "") or db_config.host
+            )
             http_client = DorisHTTPClient.from_database_config(db_config)
             response = await http_client.get(
                 role="fe",
-                host=db_config.host,
+                host=fe_http_host,
                 port=db_config.fe_http_port,
                 path="/api/show_table_data",
                 params=params,
diff --git a/doris_mcp_server/utils/config.py b/doris_mcp_server/utils/config.py
index c59e963..1adbef7 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -282,7 +282,9 @@ class DatabaseConfig:
     database: str = "information_schema"
     charset: str = "UTF8"
 
-    # FE HTTP API port for profile and other HTTP APIs
+    # FE HTTP API endpoint for profile and other HTTP APIs. An empty host keeps
+    # backward compatibility by falling back to the SQL host.
+    fe_http_host: str = ""
     fe_http_port: int = 8030
     
     # BE HTTP nodes must be configured explicitly. SQL metadata is not trusted
@@ -692,6 +694,9 @@ class DorisConfig:
         doris_database = os.getenv("DORIS_DATABASE", "").strip()
         config.database.database = doris_database if doris_database else 
config.database.database
         
+        doris_fe_http_host = os.getenv("DORIS_FE_HTTP_HOST", "").strip()
+        config.database.fe_http_host = doris_fe_http_host
+
         doris_fe_http_port = os.getenv("DORIS_FE_HTTP_PORT", "").strip()
         if doris_fe_http_port and doris_fe_http_port.isdigit():
             config.database.fe_http_port = int(doris_fe_http_port)
@@ -1210,6 +1215,7 @@ class DorisConfig:
                 "password": "***",  # Hide password
                 "database": self.database.database,
                 "charset": self.database.charset,
+                "fe_http_host": self.database.fe_http_host,
                 "fe_http_port": self.database.fe_http_port,
                 "be_hosts": self.database.be_hosts,
                 "be_webserver_port": self.database.be_webserver_port,
@@ -1364,6 +1370,19 @@ class DorisConfig:
         if self.database.max_connections <= 0:
             errors.append("Maximum connections must be greater than 0")
 
+        if (
+            self.database.fe_http_host
+            and (
+                self.database.fe_http_host != 
self.database.fe_http_host.strip()
+                or "://" in self.database.fe_http_host
+                or any(
+                    character in self.database.fe_http_host
+                    for character in "/\\?#@%"
+                )
+            )
+        ):
+            errors.append("Doris FE HTTP host must be a hostname or IP 
address")
+
         if not (1 <= self.database.fe_http_port <= 65535):
             errors.append("Doris FE HTTP port must be in the range 1-65535")
 
diff --git a/doris_mcp_server/utils/doris_http_client.py 
b/doris_mcp_server/utils/doris_http_client.py
index 7406e9f..1a26dbc 100644
--- a/doris_mcp_server/utils/doris_http_client.py
+++ b/doris_mcp_server/utils/doris_http_client.py
@@ -156,7 +156,7 @@ def _address_allowed(address: str) -> bool:
             or candidate.is_link_local
             or candidate.is_multicast
             or candidate.is_unspecified
-            or candidate.is_reserved
+            or (candidate.is_reserved and not candidate.is_loopback)
         )
         for candidate in candidates
     )
@@ -221,7 +221,9 @@ class DorisHTTPClient:
 
     @classmethod
     def from_database_config(cls, database_config: Any) -> DorisHTTPClient:
-        fe_host = _normalize_host(database_config.host)
+        fe_host = _normalize_host(
+            getattr(database_config, "fe_http_host", "") or 
database_config.host
+        )
         fe_port = _validate_port(database_config.fe_http_port)
         be_port = _validate_port(getattr(database_config, "be_webserver_port", 
8040))
         be_hosts = getattr(database_config, "be_hosts", []) or []
diff --git a/doris_mcp_server/utils/monitoring_tools.py 
b/doris_mcp_server/utils/monitoring_tools.py
index ba32b94..a732e2c 100644
--- a/doris_mcp_server/utils/monitoring_tools.py
+++ b/doris_mcp_server/utils/monitoring_tools.py
@@ -967,9 +967,12 @@ class DorisMonitoringTools:
         """Get FE monitoring metrics"""
         try:
             db_config = self.connection_manager.config.database
+            fe_http_host = (
+                getattr(db_config, "fe_http_host", "") or db_config.host
+            )
             fe_result = await self.fetch_metrics_from_node(
                 "fe",
-                {"host": db_config.host, "port": db_config.fe_http_port}
+                {"host": fe_http_host, "port": db_config.fe_http_port}
             )
             if not fe_result.get("success"):
                 return fe_result
diff --git a/test/integration/test_real_doris_transports.py 
b/test/integration/test_real_doris_transports.py
index 0e1f128..3f3dc01 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -20,9 +20,10 @@
 Set ``DORIS_REAL_INTEGRATION=1`` and provide ``DORIS_REAL_HOST``,
 ``DORIS_REAL_USER``, and ``DORIS_REAL_DATABASE``. ``DORIS_REAL_PORT`` defaults
 to 9030 and ``DORIS_REAL_PASSWORD`` may be empty. Set
-``DORIS_REAL_HTTP_INTEGRATION=1`` with ``DORIS_REAL_FE_HTTP_PORT``,
-``DORIS_REAL_BE_HOSTS``, and ``DORIS_REAL_BE_WEBSERVER_PORT`` to exercise the
-configured FE/BE HTTP monitoring boundary through both transports.
+``DORIS_REAL_HTTP_INTEGRATION=1`` with ``DORIS_REAL_FE_HTTP_HOST``,
+``DORIS_REAL_FE_HTTP_PORT``, ``DORIS_REAL_BE_HOSTS``, and
+``DORIS_REAL_BE_WEBSERVER_PORT`` to exercise independent SQL/FE/BE endpoints
+through both transports.
 """
 
 from __future__ import annotations
@@ -193,6 +194,9 @@ def _server_environment(
         "LOG_LEVEL": "ERROR",
         "PYTHONUNBUFFERED": "1",
     }
+    fe_http_host = os.getenv("DORIS_REAL_FE_HTTP_HOST", "").strip()
+    if fe_http_host:
+        environment["DORIS_FE_HTTP_HOST"] = fe_http_host
     fe_http_port = os.getenv("DORIS_REAL_FE_HTTP_PORT", "").strip()
     if fe_http_port:
         environment["DORIS_FE_HTTP_PORT"] = fe_http_port
@@ -499,8 +503,7 @@ async def test_real_doris_tool_regression_paths(
             "analyze_columns",
             {
                 "table_name": (
-                    f"{doris_sandbox.table}; "
-                    f"DROP TABLE {doris_sandbox.table}"
+                    f"{doris_sandbox.table}; DROP TABLE {doris_sandbox.table}"
                 ),
                 "columns": ["id"],
                 "analysis_types": ["completeness"],
@@ -510,8 +513,9 @@ async def test_real_doris_tool_regression_paths(
         )
         assert injected_identifier_result.is_error is True
         assert isinstance(injected_identifier_result.structured_content, dict)
-        assert "Invalid table name" in (
-            injected_identifier_result.structured_content["error"]
+        assert (
+            "Invalid table name"
+            in (injected_identifier_result.structured_content["error"])
         )
 
         intact_result, intact_payload = await _exec_query(
@@ -589,7 +593,7 @@ async def test_real_doris_tool_regression_paths(
 
 @pytest.mark.skipif(
     os.getenv("DORIS_REAL_HTTP_INTEGRATION") != "1",
-    reason="set DORIS_REAL_HTTP_INTEGRATION=1 with FE/BE HTTP endpoints",
+    reason="set DORIS_REAL_HTTP_INTEGRATION=1 with independent FE/BE HTTP 
endpoints",
 )
 @pytest.mark.parametrize("transport", ["http", "stdio"])
 async def test_real_doris_configured_monitoring_http_endpoints_and_recovery(
@@ -601,6 +605,7 @@ async def 
test_real_doris_configured_monitoring_http_endpoints_and_recovery(
         user=doris_sandbox.settings.user,
         password=doris_sandbox.settings.password,
     )
+    assert environment.get("DORIS_FE_HTTP_HOST")
     assert environment.get("DORIS_FE_HTTP_PORT")
     assert environment.get("DORIS_BE_HOSTS")
     assert environment.get("DORIS_BE_WEBSERVER_PORT")
@@ -621,7 +626,8 @@ async def 
test_real_doris_configured_monitoring_http_endpoints_and_recovery(
         fe_payload = fe_result.structured_content["data"]["fe"]
         assert fe_payload["success"] is True
         assert fe_payload["node_type"] == "fe"
-        assert fe_payload["node_info"]["host"] == doris_sandbox.settings.host
+        assert fe_payload["node_info"]["host"] == 
environment["DORIS_FE_HTTP_HOST"]
+        assert fe_payload["node_info"]["host"] != doris_sandbox.settings.host
 
         be_result = await client.call_tool(
             "get_monitoring_metrics",
diff --git a/test/protocol/stdio_capability_server.py 
b/test/protocol/stdio_capability_server.py
index f510a9c..60b2357 100644
--- a/test/protocol/stdio_capability_server.py
+++ b/test/protocol/stdio_capability_server.py
@@ -153,7 +153,8 @@ class SSRFMonitoringConnectionManager:
     def __init__(self) -> None:
         self.config = SimpleNamespace(
             database=SimpleNamespace(
-                host="169.254.169.254",
+                host="sql.invalid",
+                fe_http_host="169.254.169.254",
                 fe_http_port=80,
                 be_hosts=[],
                 be_webserver_port=8040,
diff --git a/test/protocol/test_mcp_v2_protocol.py 
b/test/protocol/test_mcp_v2_protocol.py
index e476f0a..072d8d8 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -915,6 +915,7 @@ async def 
test_http_monitoring_rejects_metadata_endpoint_and_recovers():
         fe_result = 
rejected.json()["result"]["structuredContent"]["data"]["fe"]
         assert fe_result["success"] is False
         assert fe_result["error_type"] == "prohibited_endpoint"
+        assert fe_result["node_info"]["host"] == "169.254.169.254"
 
         recovered = await client.post(
             "/mcp",
@@ -1188,6 +1189,7 @@ async def 
test_stdio_monitoring_rejects_metadata_endpoint_and_recovers():
         fe_result = rejected.structured_content["data"]["fe"]
         assert fe_result["success"] is False
         assert fe_result["error_type"] == "prohibited_endpoint"
+        assert fe_result["node_info"]["host"] == "169.254.169.254"
 
         recovered = await modern.call_tool("echo", {})
         assert recovered.is_error is False
diff --git a/test/security/test_doris_http_ssrf.py 
b/test/security/test_doris_http_ssrf.py
index 13978f1..95cff54 100644
--- a/test/security/test_doris_http_ssrf.py
+++ b/test/security/test_doris_http_ssrf.py
@@ -42,6 +42,7 @@ from doris_mcp_server.utils.monitoring_tools import 
DorisMonitoringTools
 def _database_config(
     *,
     host: str,
+    fe_http_host: str = "",
     fe_http_port: int,
     be_hosts: list[str] | None = None,
     be_webserver_port: int = 8040,
@@ -52,6 +53,7 @@ def _database_config(
 ) -> SimpleNamespace:
     return SimpleNamespace(
         host=host,
+        fe_http_host=fe_http_host,
         fe_http_port=fe_http_port,
         be_hosts=be_hosts or [],
         be_webserver_port=be_webserver_port,
@@ -198,6 +200,30 @@ async def 
test_hostname_resolving_to_link_local_is_rejected(
     fake_loop.getaddrinfo.assert_awaited_once()
 
 
+async def test_hostname_resolving_to_ipv4_and_ipv6_loopback_is_allowed(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    fake_loop = SimpleNamespace(
+        getaddrinfo=AsyncMock(
+            return_value=[
+                (10, 1, 6, "", ("::1", 8030, 0, 0)),
+                (2, 1, 6, "", ("127.0.0.1", 8030)),
+            ]
+        )
+    )
+    monkeypatch.setattr(
+        doris_http_client_module.asyncio,
+        "get_running_loop",
+        lambda: fake_loop,
+    )
+    client = _http_client("localhost", 8030)
+    assert await client._resolve_addresses("localhost", 8030) == (
+        "::1",
+        "127.0.0.1",
+    )
+    fake_loop.getaddrinfo.assert_awaited_once()
+
+
 async def test_redirects_are_not_followed() -> None:
     async with _http_server(
         status=302,
@@ -293,7 +319,8 @@ async def 
test_monitoring_fetches_configured_loopback_metrics() -> None:
     ) as port:
         manager = _manager(
             _database_config(
-                host="127.0.0.1",
+                host="sql.invalid",
+                fe_http_host="127.0.0.1",
                 fe_http_port=port,
             )
         )
@@ -304,13 +331,15 @@ async def 
test_monitoring_fetches_configured_loopback_metrics() -> None:
         )
     assert result["success"] is True
     assert result["data"]["fe"]["success"] is True
+    assert result["data"]["fe"]["node_info"]["host"] == "127.0.0.1"
     assert result["data"]["fe"]["metrics"]["doris_fe_query_total"] == 7
 
 
 async def test_analysis_fe_http_path_uses_same_policy() -> None:
     manager = _manager(
         _database_config(
-            host="metadata.google.internal",
+            host="sql.invalid",
+            fe_http_host="metadata.google.internal",
             fe_http_port=80,
         )
     )
@@ -328,7 +357,12 @@ def 
test_http_safety_configuration_loads_and_has_runtime_hard_caps(
     monkeypatch.setenv("DORIS_HTTP_READ_TIMEOUT_SECONDS", "4")
     monkeypatch.setenv("DORIS_HTTP_TOTAL_TIMEOUT_SECONDS", "8")
     monkeypatch.setenv("DORIS_HTTP_MAX_RESPONSE_BYTES", "8192")
+    monkeypatch.setenv("DORIS_HOST", "sql.internal")
+    monkeypatch.setenv("DORIS_FE_HTTP_HOST", "fe-http.internal")
     config = DorisConfig.from_env()
+    assert config.database.host == "sql.internal"
+    assert config.database.fe_http_host == "fe-http.internal"
+    assert config.to_dict()["database"]["fe_http_host"] == "fe-http.internal"
     assert config.database.http_connect_timeout_seconds == 2
     assert config.database.http_read_timeout_seconds == 4
     assert config.database.http_total_timeout_seconds == 8
@@ -336,7 +370,8 @@ def 
test_http_safety_configuration_loads_and_has_runtime_hard_caps(
     assert not [error for error in config.validate() if 
error.startswith("Doris HTTP")]
 
     database_config = _database_config(
-        host="127.0.0.1",
+        host="sql.internal",
+        fe_http_host="127.0.0.1",
         fe_http_port=8030,
         connect_timeout=999,
         read_timeout=999,
@@ -348,3 +383,12 @@ def 
test_http_safety_configuration_loads_and_has_runtime_hard_caps(
     assert client.read_timeout_seconds == MAX_TIMEOUT_SECONDS
     assert client.total_timeout_seconds == MAX_TIMEOUT_SECONDS
     assert client.max_response_bytes == MAX_RESPONSE_BYTES
+    assert ("127.0.0.1", 8030) in client.allowed_endpoints["fe"]
+    assert ("sql.internal", 8030) not in client.allowed_endpoints["fe"]
+
+    fallback_config = _database_config(
+        host="127.0.0.1",
+        fe_http_port=8030,
+    )
+    fallback_client = DorisHTTPClient.from_database_config(fallback_config)
+    assert ("127.0.0.1", 8030) in fallback_client.allowed_endpoints["fe"]


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

Reply via email to