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 ce116c2  feat: support multi-instance and multi-FE routing (#163)
ce116c2 is described below

commit ce116c2ec47ae8594c1041738128bc7f1b609675
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 21:59:48 2026 +0800

    feat: support multi-instance and multi-FE routing (#163)
    
    Add ordered FE failover for global and token-bound Doris routes, 
token-aware HTTP endpoint routing, and explicit fail-closed ADBC behavior for 
token-bound clusters.
    
    Closes #73
---
 .env.example                                |   5 +
 README.md                                   |  36 +-
 README.zh-CN.md                             |  30 ++
 docs/doris-fine-grained-access-control.md   |  18 +-
 doris_mcp_server/auth/token_handlers.py     |  84 ++++-
 doris_mcp_server/auth/token_manager.py      |  91 ++++-
 doris_mcp_server/main.py                    |   6 +
 doris_mcp_server/utils/adbc_query_tools.py  |  38 ++-
 doris_mcp_server/utils/analysis_tools.py    |  30 +-
 doris_mcp_server/utils/config.py            |  61 ++++
 doris_mcp_server/utils/db.py                | 494 +++++++++++++++++-----------
 doris_mcp_server/utils/doris_http_client.py |  94 +++++-
 doris_mcp_server/utils/monitoring_tools.py  |  41 ++-
 test/auth/test_token_handlers_boundaries.py |  16 +-
 test/protocol/test_multiworker_config.py    |  11 +
 test/security/test_token_digest_storage.py  |  58 +++-
 test/utils/test_multi_fe_routing.py         | 354 ++++++++++++++++++++
 17 files changed, 1224 insertions(+), 243 deletions(-)

diff --git a/.env.example b/.env.example
index 84b4686..ab9b64f 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,9 @@
 
 # Doris FE (Frontend) connection settings
 DORIS_HOST=localhost
+# Optional ordered FE MySQL endpoints for one Doris cluster. DORIS_HOST is
+# prepended when both settings are present.
+DORIS_HOSTS=
 DORIS_PORT=9030
 DORIS_USER=root
 DORIS_PASSWORD=
@@ -33,6 +36,8 @@ DORIS_DATABASE=information_schema
 # 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=
+# Optional ordered FE HTTP endpoints for the same Doris cluster.
+DORIS_FE_HTTP_HOSTS=
 DORIS_FE_HTTP_PORT=8030
 
 # Doris BE (Backend) HTTP allowlist for monitoring metrics.
diff --git a/README.md b/README.md
index a8a88b8..4629b3a 100644
--- a/README.md
+++ b/README.md
@@ -296,6 +296,7 @@ cp .env.example .env
 
 *   **Database Connection**:
     *   `DORIS_HOST`: Database hostname (default: localhost)
+    *   `DORIS_HOSTS`: Ordered FE MySQL failover hosts for one Doris cluster 
(comma-separated; `DORIS_HOST` is prepended when both are set)
     *   `DORIS_PORT`: Database port (default: 9030)
     *   `DORIS_USER`: Database username (default: root)
     *   `DORIS_PASSWORD`: Database password
@@ -303,6 +304,7 @@ cp .env.example .env
     *   `DORIS_MIN_CONNECTIONS`: Minimum connection pool size (default: 5)
     *   `DORIS_MAX_CONNECTIONS`: Maximum connection pool size (default: 20)
     *   `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_HOSTS`: Ordered FE HTTP failover hosts for the same 
Doris cluster (comma-separated)
     *   `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)
@@ -1731,10 +1733,12 @@ proxy, tunnel, or split network exposes them at 
different addresses:
 ```bash
 # SQL/MySQL protocol endpoint
 DORIS_HOST=sql-gateway.internal
+DORIS_HOSTS=sql-gateway.internal,fe-2.internal,fe-3.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_HOSTS=fe-http-proxy.internal,fe-2.internal,fe-3.internal
 DORIS_FE_HTTP_PORT=8030
 
 # Explicit BE HTTP allowlist
@@ -1753,6 +1757,17 @@ connection/read/total timeouts plus a response byte 
limit. Private and loopback
 addresses remain available for normal internal Doris deployments and SSH
 tunnels.
 
+`DORIS_HOSTS` and `DORIS_FE_HTTP_HOSTS` are ordered failover lists, not
+load-balancing or cluster-discovery settings. Every host in one list must
+belong to the same Doris cluster and use the configured shared port and
+credentials. The server probes candidates in order during global/static-token
+pool creation and recovery; FE HTTP requests move to the next configured
+endpoint only on a transport error or `502`/`503`/`504`. Doris-backed OAuth
+tries the candidates during sign-in, but an established per-user pool cannot
+be reconstructed after failure because the server intentionally does not
+retain the user's raw password; the user must sign in again. A stable load
+balancer or SQL gateway is still recommended for large production deployments.
+
 ### Q: How to use SQL Explain/Profile files with LLM for optimization?
 
 **A:** The tools provide both truncated content and complete files for LLM 
analysis:
@@ -2076,11 +2091,20 @@ cat logs/doris_mcp_server_critical.log
          "is_active": true,
          "database_config": {
            "host": "tenant-alpha-db.company.com",
+           "hosts": [
+             "tenant-alpha-fe-1.company.com",
+             "tenant-alpha-fe-2.company.com"
+           ],
            "port": 9030,
            "user": "alpha_user",
            "password": "secure_password",
            "database": "alpha_analytics",
-           "charset": "UTF8"
+           "charset": "UTF8",
+           "fe_http_hosts": [
+             "tenant-alpha-fe-1.company.com",
+             "tenant-alpha-fe-2.company.com"
+           ],
+           "fe_http_port": 8030
          }
        }
      ]
@@ -2109,6 +2133,16 @@ cat logs/doris_mcp_server_critical.log
    curl -H "Authorization: Bearer $TOKEN_TENANT_BETA" http://localhost:3000/mcp
    ```
 
+   Each token may bind a different Doris cluster. Within one token binding,
+   `hosts` and `fe_http_hosts` are ordered FE candidates for that same cluster.
+   The authenticated token fixes the route; MCP tool arguments cannot select
+   or override another cluster. This multi-instance mode requires the HTTP
+   transport with static-token authentication. A stdio process has one global
+   database route, so run separate stdio processes when clients need separate
+   clusters. `exec_adbc_query` is intentionally fail-closed on token-bound
+   routes because the current Arrow Flight client is process-global; use
+   `exec_query` or a separate MCP process for that cluster.
+
 ### Q: How is Doris-backed OAuth different from external OAuth/OIDC?
 
 **A:** External OAuth/OIDC delegates identity to an external provider such as 
Google, Azure AD, GitHub, GitLab, or Keycloak. Doris-backed OAuth is issued by 
this MCP server after the user signs in with Doris credentials. The server 
validates the Doris username/password, creates a per-user Doris connection 
pool, issues `doa_` access and refresh tokens, and lets Doris RBAC decide what 
data and metadata that user can access.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ab05c1b..24478f2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -151,6 +151,7 @@ curl --fail http://127.0.0.1:3000/ready
 | 环境变量 | 用途 | 默认值 |
 |:---------|:-----|:-------|
 | `DORIS_HOST` | Doris FE MySQL 主机 | `localhost` |
+| `DORIS_HOSTS` | 同一集群的有序 FE MySQL 故障切换主机,逗号分隔 | 空 |
 | `DORIS_PORT` | Doris FE MySQL 端口 | `9030` |
 | `DORIS_USER` | Doris 用户 | `root` |
 | `DORIS_PASSWORD` | Doris 密码 | 空 |
@@ -158,6 +159,7 @@ curl --fail http://127.0.0.1:3000/ready
 | `DORIS_MIN_CONNECTIONS` | 最小连接数 | `5` |
 | `DORIS_MAX_CONNECTIONS` | 最大连接数 | `20` |
 | `DORIS_FE_HTTP_HOST` | FE HTTP 工具使用的主机 | 回退到 `DORIS_HOST` |
+| `DORIS_FE_HTTP_HOSTS` | 同一集群的有序 FE HTTP 故障切换主机,逗号分隔 | 空 |
 | `DORIS_FE_HTTP_PORT` | FE HTTP 端口 | `8030` |
 | `DORIS_BE_HOSTS` | BE HTTP 主机白名单 | 空,表示禁用 BE HTTP 指标 |
 | `MAX_RESULT_ROWS` | 查询返回行数上限 | `10000` |
@@ -262,10 +264,38 @@ curl \
 请求通过该 Token 鉴权后,数据库操作使用它自己的连接池,不回退到全局服务账号。
 这适合多租户或按客户隔离 Doris 身份的部署。
 
+一个 HTTP 进程可以为不同 Token 绑定不同 Doris 集群;Token 是固定路由边界,
+工具参数不能临时切换集群。每套绑定还可以通过 `hosts` 和 `fe_http_hosts`
+配置同一集群内的多个 FE。stdio 进程只有一套全局数据库路由,需要连接不同
+集群时应分别启动多个进程。当前 Arrow Flight 客户端仍是进程级状态,因此
+`exec_adbc_query` 在 Token 绑定路由上会拒绝执行;应使用 `exec_query` 或为该
+集群单独启动 MCP 进程。
+
 `tokens.json` v2 只保存 Bearer Token 的摘要,不保存可恢复的明文。托管创建接口
 只返回一次明文,调用方必须立即存入客户端密钥存储。手工配置示例、迁移规则和
 文件格式见[英文 Token 绑定说明](README.md#token-bound-database-configuration-new-in-v060)。
 
+### 多 FE 故障切换
+
+同一 Doris 集群可以配置多个 FE:
+
+```bash
+DORIS_HOST=fe-1.internal
+DORIS_HOSTS=fe-1.internal,fe-2.internal,fe-3.internal
+DORIS_PORT=9030
+
+DORIS_FE_HTTP_HOST=fe-1.internal
+DORIS_FE_HTTP_HOSTS=fe-1.internal,fe-2.internal,fe-3.internal
+DORIS_FE_HTTP_PORT=8030
+```
+
+列表是有序故障切换,不是负载均衡或集群发现。全局连接池和静态 Token 连接池
+会在创建、恢复时依次尝试 FE;Doris OAuth 会在登录时依次尝试 FE,但服务端
+不会保留用户明文密码,已建立的用户连接池失效后不能自动重建,用户需要重新
+登录。Profile、Trace、表容量和 FE 监控只在网络错误或 `502`/`503`/`504` 时
+尝试下一节点。列表中的节点必须属于同一集群并共用端口和凭据。大型生产部署
+仍建议在前面使用稳定的负载均衡或 SQL 网关。
+
 ### 外部 OAuth/OIDC
 
 外部 OAuth 使用受信任的 RFC 7662 Introspection 端点校验访问 Token,并检查
diff --git a/docs/doris-fine-grained-access-control.md 
b/docs/doris-fine-grained-access-control.md
index 2218c3b..e522cc6 100644
--- a/docs/doris-fine-grained-access-control.md
+++ b/docs/doris-fine-grained-access-control.md
@@ -200,12 +200,20 @@ Doris user:
 ```json
 {
   "database_config": {
-    "host": "doris-fe.internal.example",
+    "host": "doris-fe-1.internal.example",
+    "hosts": [
+      "doris-fe-1.internal.example",
+      "doris-fe-2.internal.example"
+    ],
     "port": 9030,
     "user": "mcp_orders_east",
     "password": "<secret-store-value>",
     "database": "sales",
     "charset": "UTF8",
+    "fe_http_hosts": [
+      "doris-fe-1.internal.example",
+      "doris-fe-2.internal.example"
+    ],
     "fe_http_port": 8030
   }
 }
@@ -216,6 +224,10 @@ pool for this route. It must not fall back to the global 
Doris account. Store
 only a token digest in `tokens.json`, keep the file at mode `0600`, and protect
 the Doris password as a secret.
 
+All entries in `hosts` and `fe_http_hosts` must be FE nodes of the same Doris
+cluster. Use separate static-token bindings for separate clusters; the bearer
+token selects the route and a tool call cannot override it.
+
 Do not bind a fine-grained token to `root`, `admin`, or a service account that
 has global/table-level privileges.
 
@@ -237,6 +249,10 @@ DORIS_OAUTH_EXPLAIN_TOOLS_ENABLED=true
 `exec_query`, reviewed metadata tools, and SQL explain then reach Doris as the
 signed-in user. Doris-backed OAuth must fail closed if its per-user pool is
 missing; it must never use the global service account as a fallback.
+When multiple FE candidates are configured, sign-in tries them in order.
+If an established per-user pool later fails, the user must sign in again:
+the server intentionally does not retain the raw Doris password needed to
+reconstruct that pool.
 
 The current Doris-backed OAuth implementation is single-process and requires
 `WORKERS=1`. See the main README for its complete operational limits.
diff --git a/doris_mcp_server/auth/token_handlers.py 
b/doris_mcp_server/auth/token_handlers.py
index c855640..6ea38a5 100644
--- a/doris_mcp_server/auth/token_handlers.py
+++ b/doris_mcp_server/auth/token_handlers.py
@@ -37,6 +37,25 @@ if TYPE_CHECKING:
     from ..utils.security import DorisSecurityManager
 
 
+def _parse_hosts(value: object) -> list[str]:
+    if value in (None, ""):
+        return []
+    values = value.split(",") if isinstance(value, str) else value
+    if not isinstance(values, list):
+        raise ValueError("host list must be a list or comma-separated string")
+    if len(values) > 16:
+        raise ValueError("host list cannot contain more than 16 entries")
+    hosts = [host.strip() for host in values if isinstance(host, str) and 
host.strip()]
+    if len(hosts) != len(values):
+        raise ValueError("host list contains an invalid entry")
+    if any(
+        "://" in host or any(character in host for character in "/\\?#@%")
+        for host in hosts
+    ):
+        raise ValueError("host list contains a malformed entry")
+    return list(dict.fromkeys(hosts))
+
+
 class TokenHandlers:
     """Token Authentication HTTP Handlers"""
 
@@ -85,14 +104,24 @@ class TokenHandlers:
                 custom_token = query_params.get("custom_token")
                 # Database configuration from query params
                 db_config = None
-                if query_params.get("db_host"):
+                if query_params.get("db_host") or query_params.get("db_hosts"):
+                    db_hosts = _parse_hosts(query_params.get("db_hosts"))
                     db_config = DatabaseConfig(
-                        host=query_params.get("db_host", "localhost"),
+                        host=query_params.get("db_host") or db_hosts[0],
+                        hosts=db_hosts,
                         port=int(query_params.get("db_port", "9030")),
                         user=query_params.get("db_user", "root"),
                         password=query_params.get("db_password", ""),
                         database=query_params.get("db_database", 
"information_schema"),
+                        fe_http_host=query_params.get("db_fe_http_host", ""),
+                        fe_http_hosts=_parse_hosts(
+                            query_params.get("db_fe_http_hosts")
+                        ),
                         fe_http_port=int(query_params.get("db_fe_http_port", 
"8030")),
+                        be_hosts=_parse_hosts(query_params.get("db_be_hosts")),
+                        be_webserver_port=int(
+                            query_params.get("db_be_webserver_port", "8040")
+                        ),
                     )
             else:
                 # POST request with JSON body
@@ -110,13 +139,40 @@ class TokenHandlers:
                 if body.get("database_config"):
                     db_data = body["database_config"]
                     try:
+                        db_hosts = _parse_hosts(db_data.get("hosts"))
                         db_config = DatabaseConfig(
-                            host=db_data.get("host", "localhost"),
+                            host=db_data.get("host") or (
+                                db_hosts[0] if db_hosts else "localhost"
+                            ),
+                            hosts=db_hosts,
                             port=int(db_data.get("port", 9030)),
                             user=db_data.get("user", "root"),
                             password=db_data.get("password", ""),
                             database=db_data.get("database", 
"information_schema"),
+                            fe_http_host=db_data.get("fe_http_host", ""),
+                            fe_http_hosts=_parse_hosts(
+                                db_data.get("fe_http_hosts")
+                            ),
                             fe_http_port=int(db_data.get("fe_http_port", 
8030)),
+                            be_hosts=_parse_hosts(db_data.get("be_hosts")),
+                            be_webserver_port=int(
+                                db_data.get("be_webserver_port", 8040)
+                            ),
+                            http_connect_timeout_seconds=float(
+                                db_data.get("http_connect_timeout_seconds", 
3.0)
+                            ),
+                            http_read_timeout_seconds=float(
+                                db_data.get("http_read_timeout_seconds", 15.0)
+                            ),
+                            http_total_timeout_seconds=float(
+                                db_data.get("http_total_timeout_seconds", 30.0)
+                            ),
+                            http_max_response_bytes=int(
+                                db_data.get(
+                                    "http_max_response_bytes",
+                                    4 * 1024 * 1024,
+                                )
+                            ),
                         )
                     except (ValueError, TypeError):
                         return JSONResponse(
@@ -502,6 +558,10 @@ class TokenHandlers:
                                         <label for="db_host">Host:</label>
                                         <input type="text" id="db_host" 
name="db_host" placeholder="localhost">
                                     </div>
+                                    <div class="form-group">
+                                        <label for="db_hosts">FE MySQL 
Hosts:</label>
+                                        <input type="text" id="db_hosts" 
name="db_hosts" placeholder="fe-1,fe-2,fe-3">
+                                    </div>
                                     <div class="form-group">
                                         <label for="db_port">Port:</label>
                                         <input type="number" id="db_port" 
name="db_port" placeholder="9030">
@@ -518,6 +578,10 @@ class TokenHandlers:
                                         <label 
for="db_database">Database:</label>
                                         <input type="text" id="db_database" 
name="db_database" placeholder="information_schema">
                                     </div>
+                                    <div class="form-group">
+                                        <label for="db_fe_http_hosts">FE HTTP 
Hosts:</label>
+                                        <input type="text" 
id="db_fe_http_hosts" name="db_fe_http_hosts" placeholder="fe-1,fe-2,fe-3">
+                                    </div>
                                     <div class="form-group">
                                         <label for="db_fe_http_port">FE HTTP 
Port:</label>
                                         <input type="number" 
id="db_fe_http_port" name="db_fe_http_port" placeholder="8030">
@@ -589,23 +653,33 @@ class TokenHandlers:
                         if (!data.custom_token) delete data.custom_token;
 
                         // Handle database configuration
-                        if (data.db_host) {{
+                        if (data.db_host || data.db_hosts) {{
+                            const sqlHosts = data.db_hosts
+                                ? data.db_hosts.split(',').map(host => 
host.trim()).filter(Boolean)
+                                : [];
+                            const httpHosts = data.db_fe_http_hosts
+                                ? data.db_fe_http_hosts.split(',').map(host => 
host.trim()).filter(Boolean)
+                                : [];
                             data.database_config = {{
-                                host: data.db_host,
+                                host: data.db_host || sqlHosts[0],
+                                hosts: sqlHosts,
                                 port: data.db_port ? parseInt(data.db_port) : 
9030,
                                 user: data.db_user || 'root',
                                 password: data.db_password || '',
                                 database: data.db_database || 
'information_schema',
+                                fe_http_hosts: httpHosts,
                                 fe_http_port: data.db_fe_http_port ? 
parseInt(data.db_fe_http_port) : 8030
                             }};
                         }}
 
                         // Remove individual database fields from data
                         delete data.db_host;
+                        delete data.db_hosts;
                         delete data.db_port;
                         delete data.db_user;
                         delete data.db_password;
                         delete data.db_database;
+                        delete data.db_fe_http_hosts;
                         delete data.db_fe_http_port;
 
                         try {{
diff --git a/doris_mcp_server/auth/token_manager.py 
b/doris_mcp_server/auth/token_manager.py
index ac256ac..45abd09 100644
--- a/doris_mcp_server/auth/token_manager.py
+++ b/doris_mcp_server/auth/token_manager.py
@@ -53,12 +53,21 @@ class DatabaseConfig:
     """Database connection configuration for token binding"""
 
     host: str
+    hosts: list[str] = field(default_factory=list)
     port: int = 9030
     user: str = ""
     password: str = ""
     database: str = "information_schema"
     charset: str = "UTF8"
+    fe_http_host: str = ""
+    fe_http_hosts: list[str] = field(default_factory=list)
     fe_http_port: int = 8030
+    be_hosts: list[str] = field(default_factory=list)
+    be_webserver_port: int = 8040
+    http_connect_timeout_seconds: float = 3.0
+    http_read_timeout_seconds: float = 15.0
+    http_total_timeout_seconds: float = 30.0
+    http_max_response_bytes: int = 4 * 1024 * 1024
 
 
 @dataclass
@@ -236,6 +245,33 @@ class TokenManager:
             raise ValueError(f"{setting} must be an RFC 3339 timestamp") from 
exc
         return as_utc(parsed)
 
+    @staticmethod
+    def _parse_hosts(value: Any, *, setting: str) -> list[str]:
+        """Parse an ordered hostname/IP list from JSON or a CSV query value."""
+        if value in (None, ""):
+            return []
+        values = value.split(",") if isinstance(value, str) else value
+        if not isinstance(values, list):
+            raise ValueError(f"{setting} must be a list or comma-separated 
string")
+        if len(values) > 16:
+            raise ValueError(f"{setting} cannot contain more than 16 entries")
+        hosts: list[str] = []
+        for raw_host in values:
+            if not isinstance(raw_host, str):
+                raise ValueError(f"{setting} entries must be strings")
+            host = raw_host.strip()
+            if (
+                not host
+                or "://" in host
+                or any(character in host for character in "/\\?#@%")
+            ):
+                raise ValueError(
+                    f"{setting} entries must be hostname or IP address values"
+                )
+            if host not in hosts:
+                hosts.append(host)
+        return hosts
+
     def _add_token_from_config(
         self,
         token_config: dict[str, Any],
@@ -274,14 +310,46 @@ class TokenManager:
             database_config = None
             if 'database_config' in token_config:
                 db_config = token_config['database_config']
+                hosts = self._parse_hosts(
+                    db_config.get('hosts'),
+                    setting=f"static token '{token_id}' database hosts",
+                )
+                fe_http_hosts = self._parse_hosts(
+                    db_config.get('fe_http_hosts'),
+                    setting=f"static token '{token_id}' FE HTTP hosts",
+                )
                 database_config = DatabaseConfig(
-                    host=db_config.get('host', 'localhost'),
+                    host=db_config.get('host') or (hosts[0] if hosts else 
'localhost'),
+                    hosts=hosts,
                     port=db_config.get('port', 9030),
                     user=db_config.get('user', 'root'),
                     password=db_config.get('password', ''),
                     database=db_config.get('database', 'information_schema'),
                     charset=db_config.get('charset', 'UTF8'),
-                    fe_http_port=db_config.get('fe_http_port', 8030)
+                    fe_http_host=db_config.get('fe_http_host', ''),
+                    fe_http_hosts=fe_http_hosts,
+                    fe_http_port=db_config.get('fe_http_port', 8030),
+                    be_hosts=self._parse_hosts(
+                        db_config.get('be_hosts'),
+                        setting=f"static token '{token_id}' BE HTTP hosts",
+                    ),
+                    be_webserver_port=db_config.get('be_webserver_port', 8040),
+                    http_connect_timeout_seconds=db_config.get(
+                        'http_connect_timeout_seconds',
+                        3.0,
+                    ),
+                    http_read_timeout_seconds=db_config.get(
+                        'http_read_timeout_seconds',
+                        15.0,
+                    ),
+                    http_total_timeout_seconds=db_config.get(
+                        'http_total_timeout_seconds',
+                        30.0,
+                    ),
+                    http_max_response_bytes=db_config.get(
+                        'http_max_response_bytes',
+                        4 * 1024 * 1024,
+                    ),
                 )
 
             # Create token info
@@ -938,12 +1006,29 @@ class TokenManager:
         if token_info.database_config:
             token_config["database_config"] = {
                 "host": token_info.database_config.host,
+                "hosts": token_info.database_config.hosts,
                 "port": token_info.database_config.port,
                 "user": token_info.database_config.user,
                 "password": token_info.database_config.password,
                 "database": token_info.database_config.database,
                 "charset": token_info.database_config.charset,
-                "fe_http_port": token_info.database_config.fe_http_port
+                "fe_http_host": token_info.database_config.fe_http_host,
+                "fe_http_hosts": token_info.database_config.fe_http_hosts,
+                "fe_http_port": token_info.database_config.fe_http_port,
+                "be_hosts": token_info.database_config.be_hosts,
+                "be_webserver_port": 
token_info.database_config.be_webserver_port,
+                "http_connect_timeout_seconds": (
+                    token_info.database_config.http_connect_timeout_seconds
+                ),
+                "http_read_timeout_seconds": (
+                    token_info.database_config.http_read_timeout_seconds
+                ),
+                "http_total_timeout_seconds": (
+                    token_info.database_config.http_total_timeout_seconds
+                ),
+                "http_max_response_bytes": (
+                    token_info.database_config.http_max_response_bytes
+                ),
             }
 
         return token_config
diff --git a/doris_mcp_server/main.py b/doris_mcp_server/main.py
index 40017fa..ad703ae 100644
--- a/doris_mcp_server/main.py
+++ b/doris_mcp_server/main.py
@@ -72,10 +72,16 @@ def _multiworker_environment(
     """Serialize resolved parent settings inherited by Uvicorn workers."""
     return {
         "DORIS_HOST": config.database.host,
+        "DORIS_HOSTS": ",".join(config.database.hosts),
         "DORIS_PORT": str(config.database.port),
         "DORIS_USER": config.database.user,
         "DORIS_PASSWORD": config.database.password,
         "DORIS_DATABASE": config.database.database,
+        "DORIS_FE_HTTP_HOST": config.database.fe_http_host,
+        "DORIS_FE_HTTP_HOSTS": ",".join(config.database.fe_http_hosts),
+        "DORIS_FE_HTTP_PORT": str(config.database.fe_http_port),
+        "DORIS_BE_HOSTS": ",".join(config.database.be_hosts),
+        "DORIS_BE_WEBSERVER_PORT": str(config.database.be_webserver_port),
         "SERVER_HOST": host,
         "SERVER_PORT": str(port),
         "MCP_ALLOWED_HOSTS": ",".join(config.mcp_allowed_hosts),
diff --git a/doris_mcp_server/utils/adbc_query_tools.py 
b/doris_mcp_server/utils/adbc_query_tools.py
index 7de8b44..58c37cb 100644
--- a/doris_mcp_server/utils/adbc_query_tools.py
+++ b/doris_mcp_server/utils/adbc_query_tools.py
@@ -34,6 +34,7 @@ from ..result_limits import (
     resolve_result_limits,
 )
 from ..utils.db import DorisConnectionManager
+from ..utils.doris_http_client import database_config_for_request
 from ..utils.logger import get_logger
 from ..utils.security import AuthContext
 from ..utils.sql_security_utils import get_auth_context
@@ -78,6 +79,26 @@ class DorisADBCQueryTools:
         # ADBC connections and cursors are not safe to replace concurrently.
         self._query_lock = asyncio.Lock()
 
+    def _token_bound_route_error(self) -> dict[str, Any] | None:
+        global_database_config = getattr(
+            self.connection_manager.config,
+            "database",
+            None,
+        )
+        if global_database_config is None:
+            return None
+        selected = database_config_for_request(self.connection_manager)
+        if selected is global_database_config:
+            return None
+        return {
+            "success": False,
+            "error": (
+                "ADBC is unavailable for token-bound database routes; use "
+                "exec_query or a separately configured MCP process"
+            ),
+            "error_type": "token_bound_adbc_unsupported",
+        }
+
     async def exec_adbc_query(
         self,
         sql: str,
@@ -100,6 +121,9 @@ class DorisADBCQueryTools:
             Query results in specified format with metadata
         """
         try:
+            route_error = self._token_bound_route_error()
+            if route_error is not None:
+                return route_error
             # Use configuration defaults if parameters not specified
             adbc_config = self.connection_manager.config.adbc
             try:
@@ -218,8 +242,7 @@ class DorisADBCQueryTools:
                 }
 
             # Get host address
-            db_config = self.connection_manager.config.database
-            fe_host = db_config.host
+            fe_host = self.connection_manager.host
 
             # Check FE Arrow Flight SQL port availability
             fe_available = self._check_port_connectivity(fe_host, fe_port)
@@ -393,7 +416,7 @@ class DorisADBCQueryTools:
             fe_port = int(fe_port_raw)
 
             # Build connection URI
-            uri = f"grpc://{db_config.host}:{fe_port}"
+            uri = f"grpc://{self.connection_manager.host}:{fe_port}"
 
             # Create database connection parameters
             db_kwargs = {
@@ -623,6 +646,13 @@ class DorisADBCQueryTools:
     async def get_adbc_connection_info(self) -> dict[str, Any]:
         """Get ADBC connection information and status"""
         try:
+            route_error = self._token_bound_route_error()
+            if route_error is not None:
+                return {
+                    **route_error,
+                    "status": "not_ready",
+                    "timestamp": datetime.now().isoformat(),
+                }
             # Check port status
             port_status = await self._check_arrow_flight_ports()
 
@@ -638,7 +668,7 @@ class DorisADBCQueryTools:
                 "adbc_available": module_status["success"],
                 "ports_available": port_status["success"],
                 "configuration": {
-                    "fe_host": db_config.host,
+                    "fe_host": self.connection_manager.host,
                     "fe_arrow_flight_port": fe_port,
                     "be_arrow_flight_port": be_port,
                     "user": db_config.user
diff --git a/doris_mcp_server/utils/analysis_tools.py 
b/doris_mcp_server/utils/analysis_tools.py
index 08fc8b4..5fd0011 100644
--- a/doris_mcp_server/utils/analysis_tools.py
+++ b/doris_mcp_server/utils/analysis_tools.py
@@ -27,7 +27,11 @@ from typing import Any
 from urllib.parse import quote
 
 from .db import DorisConnectionManager
-from .doris_http_client import DorisHTTPClient
+from .doris_http_client import (
+    DorisHTTPClient,
+    configured_fe_http_hosts,
+    database_config_for_request,
+)
 from .logger import get_logger
 from .sql_security_utils import (
     SQLSecurityError,
@@ -924,12 +928,12 @@ class SQLAnalyzer:
             Query ID string or None if not found
         """
         try:
-            db_config = self.connection_manager.config.database
-            fe_http_host = getattr(db_config, "fe_http_host", "") or 
db_config.host
+            db_config = database_config_for_request(self.connection_manager)
+            fe_http_hosts = configured_fe_http_hosts(db_config)
             http_client = DorisHTTPClient.from_database_config(db_config)
-            response = await http_client.get(
+            response = await http_client.get_first_available(
                 role="fe",
-                host=fe_http_host,
+                hosts=fe_http_hosts,
                 port=db_config.fe_http_port,
                 path=(f"/rest/v2/manager/query/trace_id/{quote(trace_id, 
safe='')}"),
                 headers={"Accept": "application/json"},
@@ -1001,8 +1005,8 @@ class SQLAnalyzer:
             Profile data dict or None if failed
         """
         try:
-            db_config = self.connection_manager.config.database
-            fe_http_host = getattr(db_config, "fe_http_host", "") or 
db_config.host
+            db_config = database_config_for_request(self.connection_manager)
+            fe_http_hosts = configured_fe_http_hosts(db_config)
             endpoints = [
                 (
                     f"/rest/v2/manager/query/profile/text/{quote(query_id, 
safe='')}",
@@ -1013,9 +1017,9 @@ class SQLAnalyzer:
             http_client = DorisHTTPClient.from_database_config(db_config)
             for i, (path, params) in enumerate(endpoints):
                 logger.info("Requesting profile from configured FE endpoint 
%s", i + 1)
-                response = await http_client.get(
+                response = await http_client.get_first_available(
                     role="fe",
-                    host=fe_http_host,
+                    hosts=fe_http_hosts,
                     port=db_config.fe_http_port,
                     path=path,
                     params=params,
@@ -1091,7 +1095,7 @@ class SQLAnalyzer:
             Dict containing table data size information
         """
         try:
-            db_config = self.connection_manager.config.database
+            db_config = database_config_for_request(self.connection_manager)
             params = {}
             if db_name:
                 params["db"] = db_name
@@ -1103,11 +1107,11 @@ 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
+            fe_http_hosts = configured_fe_http_hosts(db_config)
             http_client = DorisHTTPClient.from_database_config(db_config)
-            response = await http_client.get(
+            response = await http_client.get_first_available(
                 role="fe",
-                host=fe_http_host,
+                hosts=fe_http_hosts,
                 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 42f237d..1714d03 100644
--- a/doris_mcp_server/utils/config.py
+++ b/doris_mcp_server/utils/config.py
@@ -359,6 +359,10 @@ class DatabaseConfig:
     """Database connection configuration"""
 
     host: str = "localhost"
+    # Ordered FE MySQL endpoints for one Doris cluster. When populated, the
+    # connection manager tries these hosts in order and keeps ``host`` as the
+    # backward-compatible primary endpoint.
+    hosts: list[str] = field(default_factory=list)
     port: int = 9030
     user: str = "root"
     password: str = ""
@@ -368,6 +372,7 @@ class DatabaseConfig:
     # 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_hosts: list[str] = field(default_factory=list)
     fe_http_port: int = 8030
 
     # BE HTTP nodes must be configured explicitly. SQL metadata is not trusted
@@ -936,6 +941,19 @@ class DorisConfig:
         doris_host = os.getenv("DORIS_HOST", "").strip()
         config.database.host = doris_host if doris_host else 
config.database.host
 
+        doris_hosts_env = os.getenv("DORIS_HOSTS", "")
+        if doris_hosts_env:
+            doris_hosts = [
+                host.strip()
+                for host in doris_hosts_env.split(",")
+                if host.strip()
+            ]
+            if doris_host:
+                doris_hosts = list(dict.fromkeys([doris_host, *doris_hosts]))
+            if doris_hosts:
+                config.database.hosts = doris_hosts
+                config.database.host = doris_hosts[0]
+
         doris_port = os.getenv("DORIS_PORT", "").strip()
         if doris_port and doris_port.isdigit():
             config.database.port = int(doris_port)
@@ -952,6 +970,21 @@ class DorisConfig:
         doris_fe_http_host = os.getenv("DORIS_FE_HTTP_HOST", "").strip()
         config.database.fe_http_host = doris_fe_http_host
 
+        doris_fe_http_hosts_env = os.getenv("DORIS_FE_HTTP_HOSTS", "")
+        if doris_fe_http_hosts_env:
+            doris_fe_http_hosts = [
+                host.strip()
+                for host in doris_fe_http_hosts_env.split(",")
+                if host.strip()
+            ]
+            if doris_fe_http_host:
+                doris_fe_http_hosts = list(
+                    dict.fromkeys([doris_fe_http_host, *doris_fe_http_hosts])
+                )
+            if doris_fe_http_hosts:
+                config.database.fe_http_hosts = doris_fe_http_hosts
+                config.database.fe_http_host = doris_fe_http_hosts[0]
+
         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)
@@ -1519,6 +1552,10 @@ class DorisConfig:
             for key, value in db_config.items():
                 if hasattr(config.database, key):
                     setattr(config.database, key, value)
+            if config.database.hosts and "host" not in db_config:
+                config.database.host = config.database.hosts[0]
+            if config.database.fe_http_hosts and "fe_http_host" not in 
db_config:
+                config.database.fe_http_host = config.database.fe_http_hosts[0]
 
         # Update security configuration
         if "security" in config_data:
@@ -1585,12 +1622,14 @@ class DorisConfig:
             "temp_files_dir": self.temp_files_dir,
             "database": {
                 "host": self.database.host,
+                "hosts": self.database.hosts,
                 "port": self.database.port,
                 "user": self.database.user,
                 "password": "***",  # Hide password
                 "database": self.database.database,
                 "charset": self.database.charset,
                 "fe_http_host": self.database.fe_http_host,
+                "fe_http_hosts": self.database.fe_http_hosts,
                 "fe_http_port": self.database.fe_http_port,
                 "be_hosts": self.database.be_hosts,
                 "be_webserver_port": self.database.be_webserver_port,
@@ -1745,6 +1784,28 @@ class DorisConfig:
         if not self.database.host:
             errors.append("Database host address cannot be empty")
 
+        database_host_lists: tuple[tuple[str, Any], ...] = (
+            ("Doris FE SQL hosts", self.database.hosts),
+            ("Doris FE HTTP hosts", self.database.fe_http_hosts),
+        )
+        for field_name, hosts in database_host_lists:
+            if not isinstance(hosts, list):
+                errors.append(f"{field_name} must be a list")
+                continue
+            if len(hosts) > 16:
+                errors.append(f"{field_name} cannot contain more than 16 
entries")
+            if any(
+                not isinstance(host, str)
+                or host != host.strip()
+                or not host
+                or "://" in host
+                or any(character in host for character in "/\\?#@%")
+                for host in hosts
+            ):
+                errors.append(
+                    f"{field_name} must contain only hostname or IP address 
values"
+                )
+
         if not (1 <= self.database.port <= 65535):
             errors.append("Database port must be in the range 1-65535")
 
diff --git a/doris_mcp_server/utils/db.py b/doris_mcp_server/utils/db.py
index b596525..0f2c217 100644
--- a/doris_mcp_server/utils/db.py
+++ b/doris_mcp_server/utils/db.py
@@ -36,7 +36,7 @@ from collections.abc import AsyncIterator, Mapping
 from contextlib import asynccontextmanager
 from dataclasses import dataclass
 from datetime import datetime
-from typing import TYPE_CHECKING, Any, TypedDict
+from typing import TYPE_CHECKING, Any, NotRequired, TypedDict
 
 import aiomysql
 from aiomysql import Connection, Pool
@@ -96,6 +96,7 @@ class DatabasePoolConfig(TypedDict):
     """Connection parameters shared by global and token-owned pools."""
 
     host: str
+    hosts: NotRequired[list[str]]
     port: int
     user: str
     password: str
@@ -533,8 +534,13 @@ class DorisConnectionManager:
         )
 
         # Store original database config for fallback
+        configured_hosts = self._ordered_hosts(
+            config.database.host,
+            getattr(config.database, "hosts", []),
+        )
         self.original_db_config: DatabasePoolConfig = {
-            "host": config.database.host,
+            "host": configured_hosts[0],
+            "hosts": configured_hosts,
             "port": config.database.port,
             "user": config.database.user,
             "password": config.database.password,
@@ -596,6 +602,25 @@ class DorisConnectionManager:
             db_config["charset"].upper(), db_config["charset"].lower()
         )
 
+    @staticmethod
+    def _ordered_hosts(primary: str, configured: object) -> list[str]:
+        hosts = (
+            [host.strip() for host in configured if isinstance(host, str) and 
host.strip()]
+            if isinstance(configured, list)
+            else []
+        )
+        return list(dict.fromkeys(([primary] if primary else []) + hosts))
+
+    def _host_candidates(self, db_config: Mapping[str, Any]) -> list[str]:
+        return self._ordered_hosts(
+            str(db_config.get("host", "")),
+            db_config.get("hosts", []),
+        )
+
+    def _set_active_global_host(self, host: str) -> None:
+        self.host = host
+        self.active_db_config["host"] = host
+
     def _is_config_empty(self, config_value: object) -> bool:
         """Check if a config value is empty (None, empty string, or 'null')"""
         return (
@@ -629,7 +654,7 @@ class DorisConnectionManager:
 
         token_db_config = 
self.token_manager.get_database_config_by_token(token)
         if token_db_config:
-            return {
+            db_config: DatabasePoolConfig = {
                 "host": token_db_config.host,
                 "port": token_db_config.port,
                 "user": token_db_config.user,
@@ -637,6 +662,13 @@ class DorisConnectionManager:
                 "database": token_db_config.database,
                 "charset": token_db_config.charset,
             }
+            token_hosts = getattr(token_db_config, "hosts", [])
+            if token_hosts:
+                db_config["hosts"] = self._ordered_hosts(
+                    token_db_config.host,
+                    token_hosts,
+                )
+            return db_config
         return None
 
     def _config_changed(
@@ -649,7 +681,7 @@ class DorisConnectionManager:
             return old_config != new_config
 
         # Compare key fields
-        for key in ["host", "port", "user", "password", "database"]:
+        for key in ["host", "hosts", "port", "user", "password", "database"]:
             if old_config.get(key) != new_config.get(key):
                 return True
         return False
@@ -705,7 +737,8 @@ class DorisConnectionManager:
 
     def _build_doris_user_db_config(self, user: str, password: str) -> dict:
         return {
-            "host": self.original_db_config["host"],
+            "host": self.host,
+            "hosts": self._host_candidates(self.original_db_config),
             "port": self.original_db_config["port"],
             "user": user,
             "password": password,
@@ -803,35 +836,41 @@ class DorisConnectionManager:
         retries = self._validate_doris_auth_retries(max_retries)
 
         last_error: Exception | None = None
+        candidate_config = self.original_db_config.copy()
+        candidate_config["host"] = self.host
+        hosts = self._host_candidates(candidate_config)
         for attempt in range(1, retries + 1):
-            conn = None
-            try:
-                conn = await aiomysql.connect(
-                    host=self.original_db_config["host"],
-                    port=self.original_db_config["port"],
-                    user=normalized_user,
-                    password=password,
-                    db="information_schema",
-                    charset=self.charset,
-                    connect_timeout=self.connect_timeout,
-                    autocommit=True,
-                )
-                return
-            except DorisUserAuthenticationError:
-                raise
-            except Exception as e:
-                last_error = e
-                self.logger.warning(
-                    "Doris user authentication failed for %s@%s:%s on attempt 
%s/%s: %s",
-                    normalized_user,
-                    self.original_db_config["host"],
-                    self.original_db_config["port"],
-                    attempt,
-                    retries,
-                    type(e).__name__,
-                )
-            finally:
-                await self._close_auth_connection(conn)
+            for host in hosts:
+                conn = None
+                try:
+                    conn = await aiomysql.connect(
+                        host=host,
+                        port=self.original_db_config["port"],
+                        user=normalized_user,
+                        password=password,
+                        db="information_schema",
+                        charset=self.charset,
+                        connect_timeout=self.connect_timeout,
+                        autocommit=True,
+                    )
+                    self._set_active_global_host(host)
+                    return
+                except DorisUserAuthenticationError:
+                    raise
+                except Exception as exc:
+                    last_error = exc
+                    self.logger.warning(
+                        "Doris user authentication failed for %s@%s:%s "
+                        "on attempt %s/%s (%s)",
+                        normalized_user,
+                        host,
+                        self.original_db_config["port"],
+                        attempt,
+                        retries,
+                        type(exc).__name__,
+                    )
+                finally:
+                    await self._close_auth_connection(conn)
 
         raise DorisUserAuthenticationError(
             f"Doris user authentication failed for {normalized_user}: 
{type(last_error).__name__}"
@@ -1125,6 +1164,12 @@ class DorisConnectionManager:
                         "database": token_db_config.database,
                         "charset": token_db_config.charset,
                     }
+                    token_hosts = getattr(token_db_config, "hosts", [])
+                    if token_hosts:
+                        db_config["hosts"] = self._ordered_hosts(
+                            token_db_config.host,
+                            token_hosts,
+                        )
                     config_source = "token-bound"
 
             # Fallback to global config if token has no specific config
@@ -1169,57 +1214,105 @@ class DorisConnectionManager:
         self,
         db_config: Mapping[str, Any],
     ) -> Pool:
-        """Create a connection pool with specified configuration
+        """Create a pool, failing over when multiple FE hosts are 
configured."""
+        pool, _ = await self._create_pool_with_candidates(db_config)
+        return pool
 
-        Args:
-            db_config: Database configuration dictionary
+    async def _probe_pool(self, pool: Pool) -> bool:
+        raw_connection = None
+        try:
+            raw_connection = await asyncio.wait_for(
+                pool.acquire(),
+                timeout=self.connect_timeout,
+            )
+            async with raw_connection.cursor() as cursor:
+                await cursor.execute("SELECT 1")
+                result = await cursor.fetchone()
+                return bool(result and result[0] == 1)
+        except Exception as exc:
+            self.logger.debug(
+                "Doris FE candidate health probe failed: %s",
+                type(exc).__name__,
+            )
+            return False
+        finally:
+            if raw_connection is not None:
+                try:
+                    pool.release(raw_connection)
+                except Exception:
+                    await self._force_close_raw_connection(
+                        raw_connection,
+                        "candidate health probe release failure",
+                    )
 
-        Returns:
-            Created connection pool
-        """
+    async def _create_pool_with_candidates(
+        self,
+        db_config: Mapping[str, Any],
+        *,
+        minsize: int = 0,
+        maxsize: int | None = None,
+        timeout: float | None = None,
+        require_health: bool | None = None,
+    ) -> tuple[Pool, str]:
+        """Create a pool against the first reachable FE in an ordered list."""
         # Convert charset to aiomysql compatible format
         charset_map = {"UTF8": "utf8", "UTF8MB4": "utf8mb4"}
         charset = charset_map.get(
             db_config["charset"].upper(), db_config["charset"].lower()
         )
+        candidates = self._host_candidates(db_config)
+        should_probe = len(candidates) > 1 if require_health is None else 
require_health
+        pool_timeout = timeout or (self.connect_timeout + 5)
+        last_error: Exception | None = None
 
-        self.logger.debug(
-            f"Creating pool for 
{db_config['user']}@{db_config['host']}:{db_config['port']}/{db_config['database']}"
-        )
-
-        try:
-            pool = await asyncio.wait_for(
-                aiomysql.create_pool(
-                    host=db_config["host"],
-                    port=db_config["port"],
-                    user=db_config["user"],
-                    password=db_config["password"],
-                    db=db_config["database"],
-                    charset=charset,
-                    minsize=0,  # Don't pre-create connections
-                    maxsize=db_config.get("maxsize", self.maxsize),
-                    connect_timeout=self.connect_timeout,
-                    autocommit=True,
-                    pool_recycle=self.pool_recycle,
-                ),
-                timeout=self.connect_timeout + 5,  # Give extra time for pool 
creation
-            )
-            self.logger.info(
-                f"Successfully created pool for 
{db_config['user']}@{db_config['host']}:{db_config['port']}"
-            )
-            return pool
-        except TimeoutError:
-            self.logger.error(
-                f"Timeout creating pool for 
{db_config['user']}@{db_config['host']}:{db_config['port']}"
-            )
-            raise RuntimeError(
-                f"Timeout creating connection pool for 
{db_config['user']}@{db_config['host']}:{db_config['port']}"
-            )
-        except Exception as e:
-            self.logger.error(
-                f"Failed to create pool for 
{db_config['user']}@{db_config['host']}:{db_config['port']}: 
{type(e).__name__}: {e}"
+        for host in candidates:
+            pool: Pool | None = None
+            self.logger.debug(
+                "Creating pool for %s@%s:%s/%s",
+                db_config["user"],
+                host,
+                db_config["port"],
+                db_config["database"],
             )
-            raise
+            try:
+                pool = await asyncio.wait_for(
+                    aiomysql.create_pool(
+                        host=host,
+                        port=db_config["port"],
+                        user=db_config["user"],
+                        password=db_config["password"],
+                        db=db_config["database"],
+                        charset=charset,
+                        minsize=minsize,
+                        maxsize=maxsize or db_config.get("maxsize", 
self.maxsize),
+                        connect_timeout=self.connect_timeout,
+                        autocommit=True,
+                        pool_recycle=self.pool_recycle,
+                    ),
+                    timeout=pool_timeout,
+                )
+                if should_probe and not await self._probe_pool(pool):
+                    raise RuntimeError("Doris FE candidate failed its health 
probe")
+                self.logger.info(
+                    "Successfully created pool for %s@%s:%s",
+                    db_config["user"],
+                    host,
+                    db_config["port"],
+                )
+                return pool, host
+            except Exception as exc:
+                last_error = exc
+                await self._close_pool_safely(pool, f"failed FE candidate 
{host}")
+                self.logger.warning(
+                    "Doris FE candidate %s:%s failed during pool creation 
(%s)",
+                    host,
+                    db_config["port"],
+                    type(exc).__name__,
+                )
+
+        raise RuntimeError(
+            "Unable to create a Doris connection pool from the configured FE 
hosts"
+        ) from last_error
 
     async def get_connection_for_token(
         self,
@@ -1235,41 +1328,73 @@ class DorisConnectionManager:
         Returns:
             DorisConnection wrapper
         """
-        pool, db_config = await self.get_pool_for_token(token)
+        token_hash = self._get_token_hash(token)
+        last_error: Exception | None = None
+        for attempt in range(2):
+            pool, db_config = await self.get_pool_for_token(token)
+            try:
+                connection = await asyncio.wait_for(
+                    pool.acquire(), timeout=self.connect_timeout
+                )
+                if getattr(connection, "closed", False):
+                    pool.release(connection)
+                    raise RuntimeError("Token pool returned a closed 
connection")
 
-        try:
-            connection = await asyncio.wait_for(
-                pool.acquire(), timeout=self.connect_timeout
-            )
+                self.logger.debug(
+                    f"Session {session_id}: Acquired connection from token 
pool "
+                    f"(user: {db_config['user']}@{db_config['host']})"
+                )
 
-            self.logger.debug(
-                f"Session {session_id}: Acquired connection from token pool "
-                f"(user: {db_config['user']}@{db_config['host']})"
-            )
+                return DorisConnection(
+                    connection,
+                    session_id,
+                    self.security_manager,
+                    pool_kind="static_token",
+                    route_key=f"static_token:{token_hash}",
+                    owner_id=self._token_pool_owner_ids.get(
+                        token_hash, f"static_token:{token_hash}:0"
+                    ),
+                    generation=self._token_pool_generations.get(token_hash, 0),
+                    owner_pool=pool,
+                )
+            except Exception as exc:
+                last_error = exc
+                has_failover = len(self._host_candidates(db_config)) > 1
+                if attempt == 0 and has_failover:
+                    self.logger.warning(
+                        "Session %s: token pool acquisition failed; "
+                        "recreating it from configured FE candidates (%s)",
+                        session_id,
+                        type(exc).__name__,
+                    )
+                    await self._evict_token_pool_for_recovery(token_hash, pool)
+                    continue
+                self.logger.error(
+                    "Session %s: Failed to acquire connection from token pool 
(%s)",
+                    session_id,
+                    type(exc).__name__,
+                )
+                raise
 
-            token_hash = self._get_token_hash(token)
-            return DorisConnection(
-                connection,
-                session_id,
-                self.security_manager,
-                pool_kind="static_token",
-                route_key=f"static_token:{token_hash}",
-                owner_id=self._token_pool_owner_ids.get(
-                    token_hash, f"static_token:{token_hash}:0"
-                ),
-                generation=self._token_pool_generations.get(token_hash, 0),
-                owner_pool=pool,
-            )
+        raise RuntimeError("Token pool recovery failed") from last_error
 
-        except Exception as e:
-            self.logger.error(
-                "Session %s: Failed to acquire connection from token pool "
-                "(%s): %s",
-                session_id,
-                type(e).__name__,
-                str(e) or "<no message>",
+    async def _evict_token_pool_for_recovery(
+        self,
+        token_hash: str,
+        expected_pool: Pool,
+    ) -> None:
+        """Remove a failed token pool without evicting a concurrent 
replacement."""
+        pool_to_close: Pool | None = None
+        async with self._token_pools_lock:
+            if self.token_pools.get(token_hash) is expected_pool:
+                pool_to_close = self.token_pools.pop(token_hash)
+                self.token_configs.pop(token_hash, None)
+                self._token_pool_owner_ids.pop(token_hash, None)
+        if pool_to_close is not None:
+            await self._close_pool_safely(
+                pool_to_close,
+                f"failed static token pool {token_hash[:8]}",
             )
-            raise
 
     async def release_connection_for_token(
         self,
@@ -1469,20 +1594,7 @@ class DorisConnectionManager:
     async def _create_pool_with_current_config(self) -> None:
         """Create connection pool with current database configuration"""
         try:
-            self.pool = await aiomysql.create_pool(
-                host=self.host,
-                port=self.port,
-                user=self.user,
-                password=self.password,
-                db=self.database,
-                charset=self.charset,
-                minsize=self.minsize,
-                maxsize=self.maxsize,
-                pool_recycle=self.pool_recycle,
-                connect_timeout=self.connect_timeout,
-                autocommit=True,
-            )
-            self._mark_global_pool_created()
+            await self._create_global_pool()
 
             # Store the current config for comparison later
             self._last_pool_config = {
@@ -1517,6 +1629,21 @@ class DorisConnectionManager:
             self.logger.error(f"Failed to create connection pool: {e}")
             raise
 
+    async def _create_global_pool(self, *, timeout: float | None = None) -> 
None:
+        """Create the global pool against the first healthy configured FE."""
+        candidate_config = self.original_db_config.copy()
+        candidate_config["host"] = self.host
+        pool, selected_host = await self._create_pool_with_candidates(
+            candidate_config,
+            minsize=self.minsize,
+            maxsize=self.maxsize,
+            timeout=timeout,
+            require_health=True,
+        )
+        self.pool = pool
+        self._set_active_global_host(selected_host)
+        self._mark_global_pool_created()
+
     async def _recreate_pool(self) -> None:
         """Recreate connection pool with current database configuration"""
         try:
@@ -1628,21 +1755,8 @@ class DorisConnectionManager:
                 )
                 return
 
-            # Create connection pool
-            self.pool = await aiomysql.create_pool(
-                host=self.host,
-                port=self.port,
-                user=self.user,
-                password=self.password,
-                db=self.database,
-                charset=self.charset,
-                minsize=self.minsize,
-                maxsize=self.maxsize,
-                pool_recycle=self.pool_recycle,
-                connect_timeout=self.connect_timeout,
-                autocommit=True,
-            )
-            self._mark_global_pool_created()
+            # Create connection pool against the first healthy configured FE.
+            await self._create_global_pool()
 
             # Test initial connection
             if not await self._test_pool_health():
@@ -1875,30 +1989,43 @@ class DorisConnectionManager:
         """
         import aiomysql
 
-        conn = None
-        try:
-            conn = await aiomysql.connect(
-                host=self.host,
-                port=self.port,
-                user=self.user,
-                password=self.password,
-                db=self.database,
-                charset=self.charset,
-                connect_timeout=self.connect_timeout,
-                autocommit=True,
-            )
+        last_error: Exception | None = None
+        for host in self._host_candidates(self.original_db_config):
+            conn = None
+            try:
+                conn = await aiomysql.connect(
+                    host=host,
+                    port=self.port,
+                    user=self.user,
+                    password=self.password,
+                    db=self.database,
+                    charset=self.charset,
+                    connect_timeout=self.connect_timeout,
+                    autocommit=True,
+                )
 
-            async with conn.cursor() as cursor:
-                await cursor.execute("SELECT 1")
-                result = await cursor.fetchone()
-                if not result or result[0] != 1:
-                    raise RuntimeError("Database connectivity test query 
failed")
+                async with conn.cursor() as cursor:
+                    await cursor.execute("SELECT 1")
+                    result = await cursor.fetchone()
+                    if not result or result[0] != 1:
+                        raise RuntimeError("Database connectivity test query 
failed")
+                self._set_active_global_host(host)
+                return
+            except Exception as exc:
+                last_error = exc
+                self.logger.warning(
+                    "Doris FE candidate %s:%s failed connectivity check (%s)",
+                    host,
+                    self.port,
+                    type(exc).__name__,
+                )
+            finally:
+                if conn:
+                    conn.close()
 
-        except Exception as e:
-            raise RuntimeError(f"Database connectivity test failed: {e}")
-        finally:
-            if conn:
-                conn.close()
+        raise RuntimeError(
+            "Database connectivity test failed for all configured Doris FE 
hosts"
+        ) from last_error
 
     async def _create_connection_pool(self) -> None:
         """
@@ -1907,29 +2034,7 @@ class DorisConnectionManager:
         Raises:
             Exception: If pool creation fails
         """
-        self.pool = await aiomysql.create_pool(
-            host=self.host,
-            port=self.port,
-            user=self.user,
-            password=self.password,
-            db=self.database,
-            charset=self.charset,
-            minsize=self.minsize,
-            maxsize=self.maxsize,
-            pool_recycle=self.pool_recycle,
-            connect_timeout=self.connect_timeout,
-            autocommit=True,
-        )
-        self._mark_global_pool_created()
-
-        # Test pool health
-        if not await self._test_pool_health():
-            # Clean up the pool if health test fails
-            if self.pool:
-                self.pool.close()
-                await self.pool.wait_closed()
-                self.pool = None
-            raise RuntimeError("Connection pool health check failed")
+        await self._create_global_pool()
 
     async def _test_pool_health(self) -> bool:
         """Test connection pool health"""
@@ -2144,23 +2249,7 @@ class DorisConnectionManager:
 
                         # Recreate pool with timeout
                         self.logger.debug("Creating new connection pool...")
-                        self.pool = await asyncio.wait_for(
-                            aiomysql.create_pool(
-                                host=self.host,
-                                port=self.port,
-                                user=self.user,
-                                password=self.password,
-                                db=self.database,
-                                charset=self.charset,
-                                minsize=self.minsize,
-                                maxsize=self.maxsize,
-                                pool_recycle=self.pool_recycle,
-                                connect_timeout=self.connect_timeout,
-                                autocommit=True,
-                            ),
-                            timeout=10.0,
-                        )
-                        self._mark_global_pool_created()
+                        await self._create_global_pool(timeout=10.0)
 
                         # Test recovered pool with timeout
                         if await asyncio.wait_for(
@@ -2242,6 +2331,19 @@ class DorisConnectionManager:
             self.logger.debug(f"Could not get auth_context: {e}")
             return None
 
+    def get_database_config_for_auth_context(
+        self,
+        auth_context: AuthContext | None = None,
+    ) -> Any:
+        """Return the endpoint config for the current authenticated route."""
+        effective_context = self._get_effective_auth_context(auth_context)
+        token = getattr(effective_context, "token", "") if effective_context 
else ""
+        if token and self.token_manager:
+            token_config = 
self.token_manager.get_database_config_by_token(token)
+            if token_config is not None:
+                return token_config
+        return self.config.database
+
     async def _get_connection_for_auth_context(
         self,
         session_id: str,
diff --git a/doris_mcp_server/utils/doris_http_client.py 
b/doris_mcp_server/utils/doris_http_client.py
index 4e47eb9..798eadb 100644
--- a/doris_mcp_server/utils/doris_http_client.py
+++ b/doris_mcp_server/utils/doris_http_client.py
@@ -34,6 +34,7 @@ DEFAULT_TOTAL_TIMEOUT_SECONDS = 30.0
 DEFAULT_MAX_RESPONSE_BYTES = 4 * 1024 * 1024
 MAX_TIMEOUT_SECONDS = 60.0
 MAX_RESPONSE_BYTES = 16 * 1024 * 1024
+MAX_CONFIGURED_HOSTS = 16
 
 _METADATA_HOSTS = frozenset(
     {
@@ -182,6 +183,53 @@ def _bounded_response_limit(value: Any) -> int:
     return min(parsed, MAX_RESPONSE_BYTES)
 
 
+def configured_fe_http_hosts(database_config: Any) -> tuple[str, ...]:
+    """Return ordered FE HTTP candidates without widening the allowlist."""
+    configured_hosts = getattr(database_config, "fe_http_hosts", []) or []
+    if not isinstance(configured_hosts, list) or any(
+        not isinstance(host, str) for host in configured_hosts
+    ):
+        raise DorisHTTPPolicyError("Configured Doris FE HTTP hosts are 
invalid")
+    if len(configured_hosts) > MAX_CONFIGURED_HOSTS:
+        raise DorisHTTPPolicyError("Too many Doris FE HTTP hosts are 
configured")
+
+    configured_host = getattr(database_config, "fe_http_host", "")
+    if configured_hosts:
+        candidates = (
+            [configured_host, *configured_hosts]
+            if configured_host
+            else configured_hosts
+        )
+    else:
+        if configured_host:
+            candidates = [configured_host]
+        else:
+            sql_hosts = getattr(database_config, "hosts", []) or []
+            if not isinstance(sql_hosts, list) or any(
+                not isinstance(host, str) for host in sql_hosts
+            ):
+                raise DorisHTTPPolicyError("Configured Doris FE SQL hosts are 
invalid")
+            if len(sql_hosts) > MAX_CONFIGURED_HOSTS:
+                raise DorisHTTPPolicyError(
+                    "Too many Doris FE SQL hosts are configured"
+                )
+            candidates = sql_hosts or [database_config.host]
+
+    return tuple(dict.fromkeys(_normalize_host(host) for host in candidates))
+
+
+def database_config_for_request(connection_manager: Any) -> Any:
+    """Resolve a token-bound endpoint config with legacy-manager fallback."""
+    resolver = getattr(
+        connection_manager,
+        "get_database_config_for_auth_context",
+        None,
+    )
+    if callable(resolver):
+        return resolver()
+    return connection_manager.config.database
+
+
 class DorisHTTPClient:
     """Fetch only configured Doris HTTP endpoints with SSRF controls."""
 
@@ -221,9 +269,7 @@ class DorisHTTPClient:
 
     @classmethod
     def from_database_config(cls, database_config: Any) -> DorisHTTPClient:
-        fe_host = _normalize_host(
-            getattr(database_config, "fe_http_host", "") or 
database_config.host
-        )
+        fe_hosts = configured_fe_http_hosts(database_config)
         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 []
@@ -235,7 +281,7 @@ class DorisHTTPClient:
             user=str(database_config.user),
             password=str(database_config.password),
             allowed_endpoints={
-                "fe": {(fe_host, fe_port)},
+                "fe": {(host, fe_port) for host in fe_hosts},
                 "be": {(_normalize_host(host), be_port) for host in be_hosts},
             },
             connect_timeout_seconds=getattr(
@@ -260,6 +306,46 @@ class DorisHTTPClient:
             ),
         )
 
+    async def get_first_available(
+        self,
+        *,
+        role: Literal["fe", "be"],
+        hosts: list[str] | tuple[str, ...],
+        port: int,
+        path: str,
+        params: Mapping[str, str] | None = None,
+        headers: Mapping[str, str] | None = None,
+    ) -> DorisHTTPResponse:
+        """Try configured endpoints in order on transport or gateway 
failure."""
+        candidates = tuple(dict.fromkeys(_normalize_host(host) for host in 
hosts))
+        if not candidates:
+            raise DorisHTTPPolicyError("No Doris HTTP endpoint is configured")
+
+        last_error: DorisHTTPRequestError | None = None
+        last_response: DorisHTTPResponse | None = None
+        for host in candidates:
+            try:
+                response = await self.get(
+                    role=role,
+                    host=host,
+                    port=port,
+                    path=path,
+                    params=params,
+                    headers=headers,
+                )
+            except DorisHTTPRequestError as exc:
+                last_error = exc
+                continue
+            if response.status not in {502, 503, 504}:
+                return response
+            last_response = response
+
+        if last_response is not None:
+            return last_response
+        if last_error is not None:
+            raise last_error
+        raise DorisHTTPRequestError("All configured Doris HTTP endpoints 
failed")
+
     async def get(
         self,
         *,
diff --git a/doris_mcp_server/utils/monitoring_tools.py 
b/doris_mcp_server/utils/monitoring_tools.py
index 30767ab..5ac22d3 100644
--- a/doris_mcp_server/utils/monitoring_tools.py
+++ b/doris_mcp_server/utils/monitoring_tools.py
@@ -28,6 +28,8 @@ from .doris_http_client import (
     DorisHTTPPolicyError,
     DorisHTTPRequestError,
     DorisHTTPResponseTooLarge,
+    configured_fe_http_hosts,
+    database_config_for_request,
 )
 from .logger import get_logger
 
@@ -686,7 +688,7 @@ class DorisMonitoringTools:
     async def get_be_nodes(self) -> list[dict[str, Any]]:
         """Return only BE HTTP nodes from the explicit configuration 
allowlist."""
         try:
-            db_config = self.connection_manager.config.database
+            db_config = database_config_for_request(self.connection_manager)
             be_hosts = getattr(db_config, "be_hosts", []) or []
             if be_hosts:
                 logger.info(f"Using configured BE hosts: {be_hosts}")
@@ -726,16 +728,25 @@ class DorisMonitoringTools:
         """Fetch metrics from an explicitly configured FE or BE node."""
         endpoint = f"{node_info.get('host')}:{node_info.get('port') or 
node_info.get('http_port')}"
         try:
-            db_config = self.connection_manager.config.database
+            db_config = database_config_for_request(self.connection_manager)
             port_key = "port" if node_type == "fe" else "http_port"
             http_client = DorisHTTPClient.from_database_config(db_config)
-            response = await http_client.get(
-                role=node_type,
-                host=node_info["host"],
-                port=int(node_info[port_key]),
-                path="/metrics",
-                headers={"Accept": "text/plain"},
-            )
+            if node_type == "fe":
+                response = await http_client.get_first_available(
+                    role="fe",
+                    hosts=node_info.get("hosts") or [node_info["host"]],
+                    port=int(node_info[port_key]),
+                    path="/metrics",
+                    headers={"Accept": "text/plain"},
+                )
+            else:
+                response = await http_client.get(
+                    role="be",
+                    host=node_info["host"],
+                    port=int(node_info[port_key]),
+                    path="/metrics",
+                    headers={"Accept": "text/plain"},
+                )
             if response.status == 200:
                 metrics_data = self._parse_prometheus_metrics(response.text())
                 return {
@@ -970,13 +981,15 @@ class DorisMonitoringTools:
     async def _get_fe_metrics(self, monitor_type: str, priority: str, 
format_type: str, include_raw_metrics: bool) -> dict[str, Any]:
         """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
-            )
+            db_config = database_config_for_request(self.connection_manager)
+            fe_http_hosts = configured_fe_http_hosts(db_config)
             fe_result = await self.fetch_metrics_from_node(
                 "fe",
-                {"host": fe_http_host, "port": db_config.fe_http_port}
+                {
+                    "host": fe_http_hosts[0],
+                    "hosts": list(fe_http_hosts),
+                    "port": db_config.fe_http_port,
+                },
             )
             if not fe_result.get("success"):
                 return fe_result
diff --git a/test/auth/test_token_handlers_boundaries.py 
b/test/auth/test_token_handlers_boundaries.py
index 12edf71..58d4684 100644
--- a/test/auth/test_token_handlers_boundaries.py
+++ b/test/auth/test_token_handlers_boundaries.py
@@ -89,10 +89,15 @@ async def 
test_create_token_from_get_query_includes_database_config():
                 "description": "read only",
                 "custom_token": "chosen-token",
                 "db_host": "doris.internal",
+                "db_hosts": "doris.internal,doris-2.internal",
                 "db_port": "9031",
                 "db_user": "readonly",
                 "db_password": "secret",
                 "db_database": "analytics",
+                "db_fe_http_host": "doris-http.internal",
+                "db_fe_http_hosts": (
+                    "doris-http.internal,doris-http-2.internal"
+                ),
                 "db_fe_http_port": "8031",
             },
         )
@@ -105,10 +110,16 @@ async def 
test_create_token_from_get_query_includes_database_config():
     assert call.kwargs["custom_token"] == "chosen-token"
     database = call.kwargs["database_config"]
     assert database.host == "doris.internal"
+    assert database.hosts == ["doris.internal", "doris-2.internal"]
     assert database.port == 9031
     assert database.user == "readonly"
     assert database.password == "secret"
     assert database.database == "analytics"
+    assert database.fe_http_host == "doris-http.internal"
+    assert database.fe_http_hosts == [
+        "doris-http.internal",
+        "doris-http-2.internal",
+    ]
     assert database.fe_http_port == 8031
 
 
@@ -121,13 +132,16 @@ async def 
test_create_token_from_post_json_uses_defaults():
             "/token/create",
             json={
                 "token_id": "writer",
-                "database_config": {"host": "doris.internal"},
+                "database_config": {
+                    "hosts": ["doris.internal", "doris-2.internal"]
+                },
             },
         )
 
     assert response.status_code == 200
     database = manager.create_token.await_args.kwargs["database_config"]
     assert database.host == "doris.internal"
+    assert database.hosts == ["doris.internal", "doris-2.internal"]
     assert database.port == 9030
     assert database.user == "root"
     assert database.database == "information_schema"
diff --git a/test/protocol/test_multiworker_config.py 
b/test/protocol/test_multiworker_config.py
index 25996b2..73f4357 100644
--- a/test/protocol/test_multiworker_config.py
+++ b/test/protocol/test_multiworker_config.py
@@ -100,10 +100,14 @@ def 
test_state_handle_secret_and_ttl_are_configurable_without_serializing_secret
 def test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
     config = DorisConfig()
     config.database.host = "127.0.0.1"
+    config.database.hosts = ["127.0.0.1", "127.0.0.2"]
     config.database.port = 19030
     config.database.user = "loader"
     config.database.password = "test-password"
     config.database.database = "hhm_dt_sim"
+    config.database.fe_http_host = "127.0.0.10"
+    config.database.fe_http_hosts = ["127.0.0.10", "127.0.0.11"]
+    config.database.be_hosts = ["127.0.0.20", "127.0.0.21"]
     config.server_name = "doris-mcp-server"
     config.mcp_allowed_hosts = ["mcp.example.test", "mcp.example.test:*"]
     config.mcp_allowed_origins = ["https://client.example.test";]
@@ -126,10 +130,17 @@ def 
test_multiworker_environment_preserves_resolved_parent_config(monkeypatch):
 
     child_config = DorisConfig.from_env()
     assert child_config.database.host == "127.0.0.1"
+    assert child_config.database.hosts == ["127.0.0.1", "127.0.0.2"]
     assert child_config.database.port == 19030
     assert child_config.database.user == "loader"
     assert child_config.database.password == "test-password"
     assert child_config.database.database == "hhm_dt_sim"
+    assert child_config.database.fe_http_host == "127.0.0.10"
+    assert child_config.database.fe_http_hosts == [
+        "127.0.0.10",
+        "127.0.0.11",
+    ]
+    assert child_config.database.be_hosts == ["127.0.0.20", "127.0.0.21"]
     assert child_config.server_host == "127.0.0.1"
     assert child_config.server_port == 31133
     assert child_config.mcp_allowed_hosts == [
diff --git a/test/security/test_token_digest_storage.py 
b/test/security/test_token_digest_storage.py
index 22ef3b3..6ca46a3 100644
--- a/test/security/test_token_digest_storage.py
+++ b/test/security/test_token_digest_storage.py
@@ -23,7 +23,7 @@ from pathlib import Path
 
 import pytest
 
-from doris_mcp_server.auth.token_manager import TokenManager
+from doris_mcp_server.auth.token_manager import DatabaseConfig, TokenManager
 from doris_mcp_server.utils.config import (
     AuthConfigError,
     DorisConfig,
@@ -94,6 +94,62 @@ async def 
test_created_token_is_returned_once_and_persisted_as_digest(
         reloaded.stop_hot_reload()
 
 
[email protected]
+async def test_token_database_fe_candidates_round_trip(
+    tmp_path,
+    monkeypatch,
+):
+    _clear_static_token_environment(monkeypatch)
+    config = _config(tmp_path)
+    manager = TokenManager(config)
+    try:
+        raw_token = await manager.create_token(
+            "multi-fe",
+            database_config=DatabaseConfig(
+                host="fe-1.internal",
+                hosts=["fe-1.internal", "fe-2.internal"],
+                user="tenant_reader",
+                password="tenant-password",
+                database="analytics",
+                fe_http_hosts=["fe-http-1.internal", "fe-http-2.internal"],
+                be_hosts=["be-1.internal", "be-2.internal"],
+            ),
+        )
+        stored = json.loads(
+            Path(config.security.token_file_path).read_text(encoding="utf-8")
+        )["tokens"][0]["database_config"]
+
+        assert stored["hosts"] == ["fe-1.internal", "fe-2.internal"]
+        assert stored["fe_http_hosts"] == [
+            "fe-http-1.internal",
+            "fe-http-2.internal",
+        ]
+        assert stored["be_hosts"] == ["be-1.internal", "be-2.internal"]
+
+        selected = manager.get_database_config_by_token(raw_token)
+        assert selected is not None
+        assert selected.hosts == ["fe-1.internal", "fe-2.internal"]
+        assert selected.fe_http_hosts == [
+            "fe-http-1.internal",
+            "fe-http-2.internal",
+        ]
+    finally:
+        manager.stop_hot_reload()
+
+    reloaded = TokenManager(config)
+    try:
+        result = await reloaded.validate_token(raw_token)
+        assert result.is_valid is True
+        assert result.token_info.token_id == "multi-fe"
+        assert result.token_info.database_config is not None
+        assert result.token_info.database_config.hosts == [
+            "fe-1.internal",
+            "fe-2.internal",
+        ]
+    finally:
+        reloaded.stop_hot_reload()
+
+
 @pytest.mark.asyncio
 async def 
test_legacy_plaintext_file_is_atomically_migrated_without_expiry_extension(
     tmp_path,
diff --git a/test/utils/test_multi_fe_routing.py 
b/test/utils/test_multi_fe_routing.py
new file mode 100644
index 0000000..7c0a7b1
--- /dev/null
+++ b/test/utils/test_multi_fe_routing.py
@@ -0,0 +1,354 @@
+# 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.
+"""Regression tests for multi-instance routing and multi-FE failover."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from doris_mcp_server.auth.token_manager import DatabaseConfig as 
TokenDatabaseConfig
+from doris_mcp_server.utils import db as db_module
+from doris_mcp_server.utils.adbc_query_tools import DorisADBCQueryTools
+from doris_mcp_server.utils.config import DorisConfig
+from doris_mcp_server.utils.db import DorisConnectionManager
+from doris_mcp_server.utils.doris_http_client import (
+    DorisHTTPClient,
+    DorisHTTPRequestError,
+    DorisHTTPResponse,
+    configured_fe_http_hosts,
+)
+from doris_mcp_server.utils.security import (
+    AuthContext,
+    reset_auth_context,
+    set_current_auth_context,
+)
+
+
+class _ProbeCursor:
+    def __init__(self, healthy: bool):
+        self.healthy = healthy
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, exc_type, exc, traceback):
+        return False
+
+    async def execute(self, sql: str) -> None:
+        assert sql == "SELECT 1"
+
+    async def fetchone(self):
+        return (1,) if self.healthy else None
+
+
+class _ProbeConnection:
+    def __init__(self, healthy: bool):
+        self.healthy = healthy
+        self.closed = False
+
+    def cursor(self):
+        return _ProbeCursor(self.healthy)
+
+
+class _ProbePool:
+    def __init__(self, healthy: bool):
+        self.healthy = healthy
+        self.closed = False
+        self.released = []
+        self.close_calls = 0
+
+    async def acquire(self):
+        return _ProbeConnection(self.healthy)
+
+    def release(self, connection):
+        self.released.append(connection)
+
+    def close(self):
+        self.closed = True
+        self.close_calls += 1
+
+    async def wait_closed(self):
+        return None
+
+
+class _BrokenAcquirePool(_ProbePool):
+    async def acquire(self):
+        raise OSError("active FE is unavailable")
+
+
+def _config(*hosts: str) -> DorisConfig:
+    config = DorisConfig()
+    config.database.host = hosts[0]
+    config.database.hosts = list(hosts)
+    config.database.user = "root"
+    config.database.password = "secret"
+    config.database.connection_timeout = 1
+    config.database.max_connections = 2
+    return config
+
+
+def test_environment_loads_ordered_sql_and_http_fe_hosts(monkeypatch):
+    monkeypatch.setenv("DORIS_HOST", "sql-primary")
+    monkeypatch.setenv("DORIS_HOSTS", "sql-secondary,sql-primary,sql-third")
+    monkeypatch.setenv("DORIS_FE_HTTP_HOST", "http-primary")
+    monkeypatch.setenv(
+        "DORIS_FE_HTTP_HOSTS",
+        "http-secondary,http-primary,http-third",
+    )
+
+    config = DorisConfig.from_env()
+
+    assert config.database.host == "sql-primary"
+    assert config.database.hosts == [
+        "sql-primary",
+        "sql-secondary",
+        "sql-third",
+    ]
+    assert config.database.fe_http_host == "http-primary"
+    assert config.database.fe_http_hosts == [
+        "http-primary",
+        "http-secondary",
+        "http-third",
+    ]
+    serialized = config.to_dict()["database"]
+    assert serialized["hosts"] == config.database.hosts
+    assert serialized["fe_http_hosts"] == config.database.fe_http_hosts
+
+
+def test_config_file_host_lists_set_backward_compatible_primary():
+    config = DorisConfig._from_dict(
+        {
+            "database": {
+                "hosts": ["fe-a", "fe-b"],
+                "fe_http_hosts": ["fe-http-a", "fe-http-b"],
+            }
+        }
+    )
+
+    assert config.database.host == "fe-a"
+    assert config.database.fe_http_host == "fe-http-a"
+
+
[email protected]
+async def test_global_pool_fails_over_to_second_fe(monkeypatch):
+    manager = DorisConnectionManager(_config("fe-down", "fe-up"))
+    failed_pool = _ProbePool(healthy=False)
+    healthy_pool = _ProbePool(healthy=True)
+    create_pool = AsyncMock(side_effect=[failed_pool, healthy_pool])
+    monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
+
+    await manager._create_global_pool()
+
+    assert [call.kwargs["host"] for call in create_pool.await_args_list] == [
+        "fe-down",
+        "fe-up",
+    ]
+    assert failed_pool.closed is True
+    assert manager.pool is healthy_pool
+    assert manager.host == "fe-up"
+    assert manager.active_db_config["host"] == "fe-up"
+
+
[email protected]
+async def test_doris_user_authentication_uses_same_fe_candidates(monkeypatch):
+    manager = DorisConnectionManager(_config("fe-down", "fe-up"))
+    good_connection = SimpleNamespace(close=lambda: None)
+    connect = AsyncMock(
+        side_effect=[OSError("unreachable"), good_connection],
+    )
+    monkeypatch.setattr(db_module.aiomysql, "connect", connect)
+
+    await manager.authenticate_doris_user("alice", "correct-password")
+
+    assert [call.kwargs["host"] for call in connect.await_args_list] == [
+        "fe-down",
+        "fe-up",
+    ]
+    assert manager.host == "fe-up"
+
+
+def test_token_selects_an_independent_cluster_and_http_allowlist():
+    manager = DorisConnectionManager(_config("global-fe"))
+    tenant_config = TokenDatabaseConfig(
+        host="tenant-a-fe-1",
+        hosts=["tenant-a-fe-1", "tenant-a-fe-2"],
+        user="tenant_a",
+        password="tenant-secret",
+        fe_http_hosts=["tenant-a-http-1", "tenant-a-http-2"],
+    )
+    manager.token_manager = SimpleNamespace(
+        get_database_config_by_token=lambda token: (
+            tenant_config if token == "tenant-a-token" else None
+        )
+    )
+    auth_context = AuthContext(
+        user_id="tenant-a",
+        auth_method="token",
+        token="tenant-a-token",
+    )
+
+    selected = manager.get_database_config_for_auth_context(auth_context)
+
+    assert selected is tenant_config
+    assert configured_fe_http_hosts(selected) == (
+        "tenant-a-http-1",
+        "tenant-a-http-2",
+    )
+    client = DorisHTTPClient.from_database_config(selected)
+    assert client.allowed_endpoints["fe"] == {
+        ("tenant-a-http-1", 8030),
+        ("tenant-a-http-2", 8030),
+    }
+
+
[email protected]
+async def test_token_bound_pool_fails_over_inside_its_own_cluster(monkeypatch):
+    manager = DorisConnectionManager(_config("global-fe"))
+    tenant_config = TokenDatabaseConfig(
+        host="tenant-fe-down",
+        hosts=["tenant-fe-down", "tenant-fe-up"],
+        user="tenant_user",
+        password="tenant-secret",
+        database="tenant_db",
+    )
+    manager.token_manager = SimpleNamespace(
+        get_database_config_by_token=lambda token: (
+            tenant_config if token == "tenant-token" else None
+        )
+    )
+    failed_pool = _ProbePool(healthy=False)
+    healthy_pool = _ProbePool(healthy=True)
+    create_pool = AsyncMock(side_effect=[failed_pool, healthy_pool])
+    monkeypatch.setattr(db_module.aiomysql, "create_pool", create_pool)
+
+    pool, selected_config = await manager.get_pool_for_token("tenant-token")
+
+    assert pool is healthy_pool
+    assert selected_config["hosts"] == ["tenant-fe-down", "tenant-fe-up"]
+    assert [call.kwargs["host"] for call in create_pool.await_args_list] == [
+        "tenant-fe-down",
+        "tenant-fe-up",
+    ]
+    assert failed_pool.closed is True
+
+
[email protected]
+async def test_token_bound_pool_recreates_after_active_fe_failure(monkeypatch):
+    manager = DorisConnectionManager(_config("global-fe"))
+    tenant_config = TokenDatabaseConfig(
+        host="tenant-fe-1",
+        hosts=["tenant-fe-1", "tenant-fe-2"],
+        user="tenant_user",
+        password="tenant-secret",
+        database="tenant_db",
+    )
+    manager.token_manager = SimpleNamespace(
+        get_database_config_by_token=lambda token: (
+            tenant_config if token == "tenant-token" else None
+        )
+    )
+    token_hash = manager._get_token_hash("tenant-token")
+    broken_pool = _BrokenAcquirePool(healthy=False)
+    recovered_pool = _ProbePool(healthy=True)
+    manager.token_pools[token_hash] = broken_pool
+    manager.token_configs[token_hash] = {
+        "host": "tenant-fe-1",
+        "hosts": ["tenant-fe-1", "tenant-fe-2"],
+        "port": 9030,
+        "user": "tenant_user",
+        "password": "tenant-secret",
+        "database": "tenant_db",
+        "charset": "UTF8",
+    }
+    manager._token_pool_owner_ids[token_hash] = "static_token:old"
+    manager._token_pool_generations[token_hash] = 3
+    create_pool = AsyncMock(return_value=recovered_pool)
+    monkeypatch.setattr(manager, "_create_pool_with_config", create_pool)
+
+    connection = await manager.get_connection_for_token(
+        "tenant-token",
+        "recovering-session",
+    )
+
+    assert broken_pool.closed is True
+    assert connection.owner_pool is recovered_pool
+    assert manager.token_pools[token_hash] is recovered_pool
+    assert connection.generation == 4
+    create_pool.assert_awaited_once()
+
+
[email protected]
+async def test_adbc_fails_closed_for_token_bound_cluster():
+    manager = DorisConnectionManager(_config("global-fe"))
+    tenant_config = TokenDatabaseConfig(
+        host="tenant-fe",
+        user="tenant_user",
+        password="tenant-secret",
+    )
+    manager.token_manager = SimpleNamespace(
+        get_database_config_by_token=lambda token: (
+            tenant_config if token == "tenant-token" else None
+        )
+    )
+    context_token = set_current_auth_context(
+        AuthContext(
+            user_id="tenant",
+            auth_method="token",
+            token="tenant-token",
+        )
+    )
+    try:
+        result = await DorisADBCQueryTools(manager).exec_adbc_query("SELECT 1")
+    finally:
+        reset_auth_context(context_token)
+
+    assert result["success"] is False
+    assert result["error_type"] == "token_bound_adbc_unsupported"
+
+
[email protected]
+async def test_http_request_fails_over_only_within_allowlisted_hosts():
+    client = DorisHTTPClient(
+        user="root",
+        password="secret",
+        allowed_endpoints={
+            "fe": {("fe-a", 8030), ("fe-b", 8030)},
+            "be": set(),
+        },
+    )
+    expected = DorisHTTPResponse(
+        status=200,
+        headers={},
+        body=b"ok",
+        url="http://fe-b:8030/metrics";,
+    )
+    client.get = AsyncMock(  # type: ignore[method-assign]
+        side_effect=[DorisHTTPRequestError("down"), expected]
+    )
+
+    response = await client.get_first_available(
+        role="fe",
+        hosts=["fe-a", "fe-b"],
+        port=8030,
+        path="/metrics",
+    )
+
+    assert response is expected
+    assert [call.kwargs["host"] for call in client.get.await_args_list] == [
+        "fe-a",
+        "fe-b",
+    ]


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

Reply via email to