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]