You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iotdb.apache.org by yo...@apache.org on 2026/09/10 03:18:35 UTC

(iotdb-mcp-server) branch main updated: Refactor IoTDB MCP Server and migrate agent enhancements (#18)

This is an automated email from the ASF dual-hosted git repository.

CRZbulabula pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iotdb-mcp-server.git


The following commit(s) were added to refs/heads/main by this push:
     new 5851bc4  Refactor IoTDB MCP Server and migrate agent enhancements (#18)
5851bc4 is described below

commit 5851bc4b867b7491a9e001dc3338f796844bce9b
Author: Zeyu Zhang <11...@users.noreply.github.com>
AuthorDate: Thu Sep 10 11:18:30 2026 +0800

    Refactor IoTDB MCP Server and migrate agent enhancements (#18)
    
    * Refactor code and update server to current agent style
    
    * Mask password in startup config log output (#19)
    
    * Mask password in startup config log output
    
    The startup log printed the full IoTDB config dict including the password field. Log a copy of the config with the password masked instead, and exclude the password from the Config repr.
    
    * Remove outdated smithery badge links
    
    * Upgrade apache-iotdb to 2.0.10
    
    * fix: fix P1 review problem
    
    * fix: fix P2 review problem
    
    * fix: fix bug in config params
    
    * fix: fix broken TableSession clear bug
    
    * fix: fix broken TableSession discard path
    
    * fix: close broken pipe after `ConnectionError`
    
    ---------
    
    Co-authored-by: CritasWang <cr...@outlook.com>
---
 Dockerfile                                         |    2 +-
 README-zh.md                                       |  110 +-
 README.md                                          |  114 +-
 pyproject.toml                                     |    3 +-
 smithery.yaml                                      |    4 +-
 src/iotdb_mcp_server/__init__.py                   |  113 +-
 src/iotdb_mcp_server/config.py                     |  285 ++++-
 src/iotdb_mcp_server/result_store.py               |  845 ++++++++++++
 src/iotdb_mcp_server/runtime_policy.py             |  353 +++++
 src/iotdb_mcp_server/server.py                     |  577 +--------
 src/iotdb_mcp_server/services/README.md            |  394 ++++++
 src/iotdb_mcp_server/services/database.py          |  348 +++++
 src/iotdb_mcp_server/services/explain.py           |  168 +++
 src/iotdb_mcp_server/services/json_response.py     |  329 +++++
 src/iotdb_mcp_server/services/metadata.py          |  416 ++++++
 src/iotdb_mcp_server/services/model.py             |  585 +++++++++
 src/iotdb_mcp_server/services/query.py             |  434 +++++++
 src/iotdb_mcp_server/services/results.py           |  226 ++++
 src/iotdb_mcp_server/services/runtime_policy.py    |   77 ++
 src/iotdb_mcp_server/services/sql_driver.py        | 1348 ++++++++++++++++++++
 src/iotdb_mcp_server/services/table.py             |  178 +++
 src/iotdb_mcp_server/services/target_selection.py  |  492 +++++++
 src/iotdb_mcp_server/services/targets.py           |  533 ++++++++
 src/iotdb_mcp_server/services/timeseries.py        |  351 +++++
 .../services/tree_sql_guardrails.py                |  199 +++
 src/iotdb_mcp_server/services/ttl.py               |  185 +++
 src/iotdb_mcp_server/services/udf.py               |  534 ++++++++
 src/iotdb_mcp_server/services/write.py             |  163 +++
 src/iotdb_mcp_server/session_manager.py            |  463 +++++++
 src/iotdb_mcp_server/target_cli.py                 |  173 +++
 src/iotdb_mcp_server/target_lifecycle.py           |  213 ++++
 src/iotdb_mcp_server/target_registry.py            |  830 ++++++++++++
 tests/test_config_fallback.py                      |  187 +++
 tests/test_database_target_routing.py              |  175 +++
 tests/test_readonly_target_routing.py              |  220 ++++
 tests/test_result_store.py                         |  464 +++++++
 tests/test_runtime_policy.py                       |   83 ++
 tests/test_session_manager_auth_cache.py           |  258 ++++
 tests/test_sql_driver_batch.py                     |  406 ++++++
 tests/test_target_cli.py                           |  186 +++
 tests/test_target_lifecycle.py                     |  335 +++++
 tests/test_udf_results.py                          |   65 +
 42 files changed, 12780 insertions(+), 644 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 92570d5..44cbd62 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile
-FROM python:3.12-alpine
+FROM python:3.11-alpine
 
 # Install dependencies
 RUN apk add --no-cache gcc musl-dev linux-headers
diff --git a/README-zh.md b/README-zh.md
index c01ea90..112d2f5 100644
--- a/README-zh.md
+++ b/README-zh.md
@@ -16,10 +16,31 @@ IoTDB MCP Server 是一个基于模型上下文协议(Model Context Protocol,
 
 此服务器不提供任何提示。
 
+## 权限模型
+
+IoTDB MCP 权限默认是提示层。服务器为 SQL 操作返回所需权限、风险级别和确认参数,
+由宿主 agent system 负责用户审批。执行 DDL/DML 或破坏性 SQL 前,如果可用,应先使用
+`inspect_sql_permission`。
+
+长期托管 agent 可以通过环境变量提供 full permission 默认值,例如
+`IOTDB_SQL_DRIVER_MODE=full` 和 `TIMESEEK_MCP_PERMISSION_ENFORCEMENT=advisory`。
+只有需要 MCP server 自身硬阻断时,才设置
+`TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict`。
+
 ### 工具
 
 服务器为 IoTDB 的树模型(Tree Model)和表模型(Table Model)提供了不同的工具。您可以通过设置 "IOTDB_SQL_DIALECT" 配置为 "tree" 或 "table" 来选择使用哪种模型。
 
+方言相关的标识符语义:
+
+- 树模型:
+  - `FROM` 中使用显式的 `root...` 路径。
+  - `SELECT` 投影通常应使用测点名,而不是完整 `root...` 路径。
+  - `IOTDB_DATABASE` 只作为连接/会话范围提示,树模型 SQL 仍应显式写出 `root...` 路径。
+- 表模型:
+  - `FROM` 中使用当前数据库下的表名。
+  - `SELECT` 投影使用列名。
+
 #### 树模型 (Tree Model)
 
 - `metadata_query`
@@ -47,6 +68,32 @@ IoTDB MCP Server 是一个基于模型上下文协议(Model Context Protocol,
     - SUM, COUNT, MAX_VALUE, MIN_VALUE, AVG, VARIANCE, MAX_TIME, MIN_TIME 等
   - 返回: 查询结果作为对象数组
 
+- `sql_executor_batch`
+
+  - 并行执行多条只读 SQL,并将每个子结果写入 ResultStore
+  - 输入:
+    - `sqls` (数组): 显式单语句 SQL,或
+    - `sql_template` + `param_sets`: SQL 模板和参数对象列表
+    - `max_concurrency` (整数): 并发语句上限,默认 4
+    - `worker_pool_size` (整数): 线程 worker pool 大小,默认跟随并发度,并受 `IOTDB_SQL_EXECUTOR_BATCH_MAX_WORKER_POOL_SIZE` 限制(默认 16)
+    - `per_item_timeout_ms` (整数): 单条语句等待超时,默认 60000
+    - `batch_timeout_ms` (整数): 整个 batch 等待超时,默认 300000
+    - `max_result_rows_per_item` / `max_result_bytes_per_item`: 单条语句结果 quota,默认 10000 行和 16 MiB
+    - `max_batch_result_rows` / `max_batch_result_bytes`: 整个 batch 结果 quota,默认 100000 行和 64 MiB
+  - 模板占位符:
+    - `{{name}}` 表示 SQL 字面量,`{{name:path}}` 表示 IoTDB 路径,`{{name:identifier}}` 表示 SQL 标识符
+  - 返回: batch 汇总,以及每条 SQL 的 `result_id`、行数、预览行和分页信息
+
+- `read_result_pages`
+
+  - 一次 MCP 调用读取多个 ResultStore page
+  - 输入:
+    - `pages` (数组): page 请求对象,包含 `result_id`,可选 `cursor`、`offset`、`limit`、`owner_session_id`
+    - `default_limit` (整数): 未设置 `limit` 时的默认 page 大小
+    - `max_pages` / `max_total_rows`: 单次调用 quota,默认 32 个 page 和 10000 行
+    - `continue_on_error` (布尔): 是否以 per-item error 返回,默认 true
+  - 返回: 批量读页汇总,以及每个 page 的 rows、cursor 和错误信息
+
 - `export_query`
   - 执行查询并将结果导出为 CSV 或 Excel 文件
   - 输入:
@@ -54,6 +101,37 @@ IoTDB MCP Server 是一个基于模型上下文协议(Model Context Protocol,
     - `format` (字符串): 导出格式,可以是 "csv" 或 "excel"(默认: "csv")
     - `filename` (字符串): 导出文件的文件名(可选,如果未提供,将生成唯一文件名)
   - 返回: 有关导出文件的信息和数据预览(最多 10 行)
+- `model_inference`
+  - 执行 AINode `CALL INFERENCE(...)` SQL 并返回结果集
+  - 输入:
+    - `inference_sql` (字符串): 以 `CALL INFERENCE` 开头的单条树模型 SQL
+  - 执行前校验模型 ID、带引号的输入 SELECT SQL、显式非通配列,以及支持的参数
+    (`generateTime`, `outputLength`)
+  - 权限信息:模型管理能力通过 MCP advisory policy 层返回。仅在 strict 模式下才硬性执行
+    `IOTDB_ENABLE_MODEL_MANAGEMENT=true` 和 `IOTDB_MODEL_ALLOWED_USERS`。
+- `prepare_model_inference_request`
+  - 通过结构化字段构造并校验 AINode `CALL INFERENCE(...)` SQL
+  - 输入:
+    - `model_id` (字符串): 已注册的 AINode 模型 ID
+    - `input_sql` (字符串): 作为模型输入的有界树模型 SELECT 查询
+    - `output_length` (整数): 预测输出长度(默认: 96)
+    - `generate_time` (布尔): 是否请求 Time 列(默认: false)
+
+#### UDF 工具
+
+- `list_udf_functions`
+  - 对所选 IoTDB target 执行 `SHOW FUNCTIONS`。
+- `prepare_udf_query`
+  - 使用结构化参数生成只读 UDF `SELECT`。
+  - 树模型形式:`SELECT UDF(measurement, "k"="v") FROM root.sg.d1 ...`
+  - 表模型形式:`SELECT UDF(column, "k"="v") FROM table ...`
+- `execute_udf_query`
+  - 执行校验后的 UDF 查询,并返回 ResultStore 支持的预览结果。
+- `export_udf_query`
+  - 执行校验后的 UDF 查询,并导出为 CSV 或 Excel。
+
+UDF 工具会拒绝表达式和过滤条件中的分号、SQL 注释以及 DDL/DML 关键字。
+它们用于只读 UDF 调用,例如数据质量、画像、修复规划和异常评分。
 
 #### 表模型 (Table Model)
 
@@ -94,11 +172,37 @@ IoTDB MCP Server 支持以下配置选项,可以通过环境变量或命令行
 | --host        | IOTDB_HOST        | 127.0.0.1 | IoTDB 主机地址          |
 | --port        | IOTDB_PORT        | 6667      | IoTDB 端口              |
 | --user        | IOTDB_USER        | root      | IoTDB 用户名            |
-| --password    | IOTDB_PASSWORD    | root      | IoTDB 密码              |
-| --database    | IOTDB_DATABASE    | test      | IoTDB 数据库名称        |
+| --password    | IOTDB_PASSWORD    | 空        | IoTDB 密码              |
+| --database    | IOTDB_DATABASE    | test      | 表模型: 当前数据库名;树模型: 可选的连接/范围提示,查询仍需显式使用 `root...` 路径 |
 | --sql-dialect | IOTDB_SQL_DIALECT | table     | SQL 方言: tree 或 table |
 | --export-path | IOTDB_EXPORT_PATH | /tmp      | 查询结果导出路径        |
 
+target registry 只保存已经成功登录的连接。先用 `prepare_iotdb_target` 创建
+不含凭据、不可执行 SQL 的临时 candidate,再把用户明确提供的账号密码传给
+`connect_iotdb_target`,并且只认证一次。缺少凭据时必须询问用户,禁止探测空
+密码或默认密码;用户明确说明空密码时,空字符串才是有效凭据。
+
+登录成功后,MCP 会原子发布 target,并自动记录 per-target
+`last_known_good_credential`。任何连接层错误都会消费 candidate 或驱逐已发布
+target。只有用户明确要求重连后,才能创建新 candidate,并传入
+`user_confirmed_retry=true`。公开 target 响应会脱敏当前密码和
+last-known-good 密码。
+
+配置 `TIMESEEK_IOTDB_TARGETS_FILE` 后,成功连接默认会持久化。通过
+`iotdb-target-cli` 启动本地 Java CLI,即可复用完全相同的 target:
+
+```bash
+iotdb-target-cli --target-id cloud \
+  --cli /opt/iotdb/sbin/start-cli.sh -- -e "SHOW VERSION"
+```
+
+wrapper 每次启动都会重新加载已验证 target,并注入 host、port、方言、用户名和
+last-known-good 密码。Java CLI 不支持 `-db`,因此 wrapper 不会向
+`start-cli.sh` 传该参数;Table 数据库应通过 SQL `USE <database>` 选择。对于支持
+`-db` 的 `import-data.sh` 和 `import-data.bat`,wrapper 仍会自动注入 Table
+target 的数据库。已验证的空密码会省略 `-pw`,命令预览会脱敏非空密码。直接调用
+`start-cli.sh` 不会读取 target registry。
+
 ## 性能优化
 
 IoTDB MCP Server 包含以下性能优化特性:
@@ -164,7 +268,7 @@ uv sync
         "IOTDB_HOST": "127.0.0.1",
         "IOTDB_PORT": "6667",
         "IOTDB_USER": "root",
-        "IOTDB_PASSWORD": "root",
+        "IOTDB_PASSWORD": "",
         "IOTDB_DATABASE": "test",
         "IOTDB_SQL_DIALECT": "table",
         "IOTDB_EXPORT_PATH": "/path/to/export/folder"
diff --git a/README.md b/README.md
index b94317b..622e785 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,33 @@ The server doesn't expose any resources.
 
 The server doesn't provide any prompts.
 
+## Permission Model
+
+IoTDB MCP permissions are advisory by default. The server reports the required
+permission, risk level, and confirmation parameter for SQL actions, while the
+host agent system owns user approval. Use `inspect_sql_permission` before
+executing DDL/DML or destructive SQL when the tool is available.
+
+Long-running hosted agents can provide full-permission defaults with
+environment variables such as `IOTDB_SQL_DRIVER_MODE=full` and
+`TIMESEEK_MCP_PERMISSION_ENFORCEMENT=advisory`. Set
+`TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict` only when the MCP server itself
+should hard-block disallowed operations.
+
 ### Tools
 
 The server offers different tools for IoTDB Tree Model and Table Model. You can choose between them by setting the "IOTDB_SQL_DIALECT" configuration to either "tree" or "table".
 
+Dialect-specific identifier semantics:
+
+- Tree dialect:
+  - `FROM` targets use explicit `root...` paths.
+  - Projection expressions should usually use measurement names instead of full `root...` paths.
+  - `IOTDB_DATABASE` is only a connection/session hint; tree SQL still needs explicit root paths.
+- Table dialect:
+  - `FROM` targets use table names in the current database.
+  - Projection expressions use column names.
+
 #### Tree Model
 
 - `metadata_query`
@@ -43,6 +66,28 @@ The server offers different tools for IoTDB Tree Model and Table Model. You can
   - Supported functions:
     - SUM, COUNT, MAX_VALUE, MIN_VALUE, AVG, VARIANCE, MAX_TIME, MIN_TIME, etc.
   - Returns: Query results as array of objects
+- `sql_executor_batch`
+  - Execute multiple readonly SQL statements in parallel and store each result in ResultStore
+  - Input:
+    - `sqls` (array): Explicit single SQL statements, or
+    - `sql_template` + `param_sets`: Repeated SQL template with parameter objects
+    - `max_concurrency` (integer): Concurrent statement limit, default 4
+    - `worker_pool_size` (integer): Thread worker pool size, default follows concurrency and is capped by `IOTDB_SQL_EXECUTOR_BATCH_MAX_WORKER_POOL_SIZE` (default 16)
+    - `per_item_timeout_ms` (integer): Per-statement wait timeout, default 60000
+    - `batch_timeout_ms` (integer): Whole-batch wait timeout, default 300000
+    - `max_result_rows_per_item` / `max_result_bytes_per_item`: Per-statement result quota, defaults 10000 rows and 16 MiB
+    - `max_batch_result_rows` / `max_batch_result_bytes`: Whole-batch result quota, defaults 100000 rows and 64 MiB
+  - Template placeholders:
+    - `{{name}}` for SQL literals, `{{name:path}}` for IoTDB paths, `{{name:identifier}}` for SQL identifiers
+  - Returns: Batch summary plus per-statement `result_id`, row count, preview rows, and paging metadata
+- `read_result_pages`
+  - Read multiple ResultStore pages in one MCP call
+  - Input:
+    - `pages` (array): Page request objects with `result_id` plus optional `cursor`, `offset`, `limit`, and `owner_session_id`
+    - `default_limit` (integer): Default page size for items without `limit`
+    - `max_pages` / `max_total_rows`: Per-call quotas, defaults 32 pages and 10000 rows
+    - `continue_on_error` (boolean): Return per-item errors instead of aborting, default true
+  - Returns: Batch page summary plus per-page rows, cursors, and errors
 - `export_query`
   - Execute a query and export the results to a CSV or Excel file
   - Input:
@@ -50,6 +95,39 @@ The server offers different tools for IoTDB Tree Model and Table Model. You can
     - `format` (string): Export format, either "csv" or "excel" (default: "csv")
     - `filename` (string): Optional filename for the exported file. If not provided, a unique filename will be generated.
   - Returns: Information about the exported file and a preview of the data (max 10 rows)
+- `model_inference`
+  - Execute AINode `CALL INFERENCE(...)` SQL and return the result set
+  - Input:
+    - `inference_sql` (string): A single Tree-dialect SQL statement starting with `CALL INFERENCE`
+  - Validates model id, quoted input SELECT SQL, explicit non-wildcard columns,
+    and supported parameters (`generateTime`, `outputLength`) before execution
+  - Permission metadata: model management is reported through the advisory MCP
+    policy layer. In strict mode, `IOTDB_ENABLE_MODEL_MANAGEMENT=true` and
+    `IOTDB_MODEL_ALLOWED_USERS` are enforced.
+- `prepare_model_inference_request`
+  - Build and validate AINode `CALL INFERENCE(...)` SQL from structured fields
+  - Input:
+    - `model_id` (string): Registered AINode model id
+    - `input_sql` (string): Bounded Tree-dialect SELECT query used as model input
+    - `output_length` (int): Forecast output length (default: 96)
+    - `generate_time` (bool): Whether to request a Time column (default: false)
+
+#### UDF Tools
+
+- `list_udf_functions`
+  - Execute `SHOW FUNCTIONS` for the selected IoTDB target.
+- `prepare_udf_query`
+  - Build a read-only UDF `SELECT` from structured inputs.
+  - Tree form: `SELECT UDF(measurement, "k"="v") FROM root.sg.d1 ...`
+  - Table form: `SELECT UDF(column, "k"="v") FROM table ...`
+- `execute_udf_query`
+  - Execute the validated UDF query and return a ResultStore-backed preview.
+- `export_udf_query`
+  - Execute the validated UDF query and export the result set to CSV or Excel.
+
+UDF tools reject semicolons, SQL comments, and DDL/DML keywords in expressions
+and filter clauses. They are intended for read-only UDF calls such as data
+quality, profiling, repair planning, and anomaly scoring.
 
 #### Table Model
 
@@ -93,11 +171,41 @@ IoTDB MCP Server supports the following configuration options, which can be set
 | --host        | IOTDB_HOST           | 127.0.0.1     | IoTDB host address               |
 | --port        | IOTDB_PORT           | 6667          | IoTDB port                       |
 | --user        | IOTDB_USER           | root          | IoTDB username                   |
-| --password    | IOTDB_PASSWORD       | root          | IoTDB password                   |
-| --database    | IOTDB_DATABASE       | test          | IoTDB database name              |
+| --password    | IOTDB_PASSWORD       | empty         | IoTDB password                   |
+| --database    | IOTDB_DATABASE       | test          | Table dialect: current database name. Tree dialect: optional session/root scope hint; queries still use explicit `root...` paths. |
 | --sql-dialect | IOTDB_SQL_DIALECT    | table         | SQL dialect: tree or table       |
 | --export-path | IOTDB_EXPORT_PATH    | /tmp          | Path for exporting query results |
 
+The target registry contains only connections that have completed a successful
+login. Call `prepare_iotdb_target` with non-secret fields, then pass explicitly
+user-supplied credentials to `connect_iotdb_target` for one authentication
+attempt. If credentials are absent, ask the user; never probe empty or default
+passwords. An explicitly supplied empty password remains valid input.
+
+Successful login atomically publishes the target and records its per-target
+`last_known_good_credential`. Any connection-layer failure consumes the
+candidate or evicts the published target. A retry requires a new candidate and
+`user_confirmed_retry=true` after explicit user instruction. Public target
+responses redact both the active password and last-known-good password.
+
+When `TIMESEEK_IOTDB_TARGETS_FILE` is configured, successful connections are
+persisted by default. The local Java CLI can reuse exactly that target through
+`iotdb-target-cli`:
+
+```bash
+iotdb-target-cli --target-id cloud \
+  --cli /opt/iotdb/sbin/start-cli.sh -- -e "SHOW VERSION"
+```
+
+The wrapper reloads the verified target on every invocation and supplies its
+host, port, dialect, username, and last-known-good password. It does not pass
+`-db` to `start-cli.sh`, because the Java CLI does not support that option;
+select a table database with SQL `USE <database>`. For `import-data.sh` and
+`import-data.bat`, which do support `-db`, a table target's database is injected
+automatically. A verified empty password is represented by omitting `-pw`, and
+command previews redact non-empty passwords. Calling `start-cli.sh` directly
+does not read the target registry.
+
 ## Performance Optimizations
 
 IoTDB MCP Server includes the following performance optimization features:
@@ -163,7 +271,7 @@ Add the following configuration to Claude Desktop's configuration file:
         "IOTDB_HOST": "127.0.0.1",
         "IOTDB_PORT": "6667",
         "IOTDB_USER": "root",
-        "IOTDB_PASSWORD": "root",
+        "IOTDB_PASSWORD": "",
         "IOTDB_DATABASE": "test",
         "IOTDB_SQL_DIALECT": "table",
         "IOTDB_EXPORT_PATH": "/path/to/export/folder"
diff --git a/pyproject.toml b/pyproject.toml
index db52b61..ca73270 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ description = "A Model Context Protocol (MCP) server that enables secure interac
 readme = "README.md"
 license = "Apache-2.0"
 license-file="LICENSE"
-requires-python = ">=3.12"
+requires-python = ">=3.11"
 dependencies = [
     "fastmcp>=2.8.1",
     "apache-iotdb>=2.0.10",
@@ -33,3 +33,4 @@ dev-dependencies = [
 
 [project.scripts]
 iotdb-mcp-server = "iotdb_mcp_server:main"
+iotdb-target-cli = "iotdb_mcp_server.target_cli:main"
diff --git a/smithery.yaml b/smithery.yaml
index 9d8a9bb..a820101 100644
--- a/smithery.yaml
+++ b/smithery.yaml
@@ -26,7 +26,7 @@ startCommand:
         description: IoTDB username
       password:
         type: string
-        default: root
+        default: ""
         description: IoTDB password
       database:
         type: string
@@ -50,5 +50,5 @@ startCommand:
     host: 127.0.0.1
     port: 6667
     user: root
-    password: root
+    password: ""
     database: test
diff --git a/src/iotdb_mcp_server/__init__.py b/src/iotdb_mcp_server/__init__.py
index 184f75b..86dc83d 100644
--- a/src/iotdb_mcp_server/__init__.py
+++ b/src/iotdb_mcp_server/__init__.py
@@ -1,73 +1,40 @@
-# 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.
-#
-
-from iotdb_mcp_server.config import Config
-import sys
-import signal
-import asyncio
-import logging
-
-if not "-m" in sys.argv:
-    from . import server
-
-# Configure logging for the main module
-logger = logging.getLogger("iotdb_mcp_server")
-
-def main():
-    """Main entry point for the package."""
-    # Create an event loop with proper exception handling
-    loop = asyncio.new_event_loop()
-    asyncio.set_event_loop(loop)
-    
-    # Graceful shutdown handler
-    def handle_shutdown(sig, frame):
-        logger.info(f"Received shutdown signal {sig}, shutting down gracefully...")
-        # Stop the event loop
-        if loop.is_running():
-            loop.stop()
-    
-    # Register signal handlers
-    signal.signal(signal.SIGINT, handle_shutdown)
-    signal.signal(signal.SIGTERM, handle_shutdown)
-    
-    try:
-        logger.info("Starting IoTDB MCP Server...")
-        # Run the server in the event loop
-        loop.run_until_complete(server.main())
-        # Keep the loop running until stopped
-        loop.run_forever()
-    except Exception as e:
-        logger.error(f"Error in IoTDB MCP Server: {e}")
-    finally:
-        # Clean up tasks
-        pending = asyncio.all_tasks(loop)
-        if pending:
-            logger.info(f"Cancelling {len(pending)} pending tasks...")
-            for task in pending:
-                task.cancel()
-            # Wait for tasks to be cancelled
-            loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
-        # Close the loop
-        loop.close()
-        logger.info("IoTDB MCP Server shutdown complete")
-    
-    return 0
-
-
-# Expose important items at package level
-__all__ = ["main", "server"]
+# 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.
+#
+
+import logging
+
+logger = logging.getLogger("iotdb_mcp_server")
+
+
+def main() -> int:
+    """Console script entrypoint."""
+    from . import server
+
+    try:
+        logger.info("Starting IoTDB MCP Server...")
+        server.main()
+        return 0
+    except KeyboardInterrupt:
+        logger.info("IoTDB MCP Server interrupted")
+        return 0
+    except Exception as e:
+        logger.error(f"Error in IoTDB MCP Server: {e}")
+        return 1
+
+
+__all__ = ["main"]
diff --git a/src/iotdb_mcp_server/config.py b/src/iotdb_mcp_server/config.py
index 7e4ad78..f0b542e 100644
--- a/src/iotdb_mcp_server/config.py
+++ b/src/iotdb_mcp_server/config.py
@@ -19,6 +19,43 @@
 import argparse
 from dataclasses import dataclass, field
 import os
+from pathlib import Path
+from typing import Any
+
+from iotdb_mcp_server.target_registry import IoTDBTarget
+from iotdb_mcp_server.target_registry import IoTDBTargetRegistry
+from iotdb_mcp_server.target_registry import target_from_mapping
+
+
+def env_or_default(name: str, default: str) -> str:
+    value = os.getenv(name)
+    if value is None or value.strip() == "":
+        return default
+    return value
+
+
+def _argument_or_env(value: Any, name: str) -> Any:
+    """Return an explicit CLI value, otherwise the matching environment value."""
+    return value if value is not None else os.getenv(name)
+
+
+def default_export_path() -> str:
+    explicit = os.getenv("IOTDB_EXPORT_PATH")
+    if explicit:
+        return explicit
+
+    workspace = os.getenv("TIMESEEK_WORKSPACE_ROOT") or os.getenv(
+        "TIMESEEK_EXPERIMENT_WORKSPACE_ROOT"
+    )
+    if workspace:
+        return str(
+            Path(workspace).expanduser()
+            / "auto_rule"
+            / ".tmp"
+            / "iotdb_exports"
+        )
+
+    return "/tmp"
 
 
 @dataclass
@@ -49,19 +86,130 @@ class Config:
 
     database: str
     """
-    IoTDB database
+    IoTDB database/session scope.
+    Table dialect: current database name.
+    Tree dialect: optional root scope hint; tree SQL still uses explicit root paths in queries.
     """
 
     sql_dialect: str
     """
     SQL dialect: tree or table
     """
+
+    timezone: str
+    """
+    IoTDB session timezone.
+    """
     
     export_path: str
     """
     Path for exporting query results
     """
 
+    target_id: str = "default"
+    display_name: str = "Default IoTDB"
+    target_kind: str = "local"
+    node_urls: tuple[str, ...] = ()
+    iotdb_home: str = ""
+    use_ssl: bool = False
+    ca_certs: str = ""
+    connection_timeout_in_ms: int | None = None
+    enable_redirection: bool = True
+    enable_compression: bool = False
+    fetch_size: int = 1024
+    max_retry: int = 3
+    max_pool_size: int = 100
+    tree_wait_timeout_in_ms: int = 5000
+    table_wait_timeout_in_ms: int = 10000
+    target_fingerprint: str = ""
+    policy: dict[str, str] = field(default_factory=dict)
+    last_known_good_credential: dict[str, object] = field(
+        default_factory=dict,
+        repr=False,
+        compare=False,
+    )
+    verified_at: str = ""
+    target_registry: IoTDBTargetRegistry | None = field(default=None, repr=False, compare=False)
+    session_manager: Any | None = field(default=None, repr=False, compare=False)
+    targets_file: str = ""
+    targets_json: str = field(default="", repr=False, compare=False)
+    targets_file_mtime_ns: int | None = field(default=None, repr=False, compare=False)
+
+    @classmethod
+    def from_target(
+        cls,
+        target: IoTDBTarget,
+        target_registry: IoTDBTargetRegistry | None = None,
+        session_manager: Any | None = None,
+    ) -> "Config":
+        return cls(
+            host=target.host,
+            port=target.port,
+            user=target.user,
+            password=target.password,
+            database=target.database,
+            sql_dialect=target.sql_dialect,
+            timezone=target.timezone,
+            export_path=target.export_path,
+            target_id=target.target_id,
+            display_name=target.display_name,
+            target_kind=target.kind,
+            node_urls=target.node_urls,
+            iotdb_home=target.iotdb_home,
+            use_ssl=target.use_ssl,
+            ca_certs=target.ca_certs,
+            connection_timeout_in_ms=target.connection_timeout_in_ms,
+            enable_redirection=target.enable_redirection,
+            enable_compression=target.enable_compression,
+            fetch_size=target.fetch_size,
+            max_retry=target.max_retry,
+            max_pool_size=target.max_pool_size,
+            tree_wait_timeout_in_ms=target.tree_wait_timeout_in_ms,
+            table_wait_timeout_in_ms=target.table_wait_timeout_in_ms,
+            target_fingerprint=target.fingerprint(),
+            policy={str(key): str(value) for key, value in target.policy.items()},
+            last_known_good_credential=dict(target.last_known_good_credential),
+            verified_at=target.verified_at,
+            target_registry=target_registry,
+            session_manager=session_manager,
+        )
+
+    def to_target(self) -> IoTDBTarget:
+        return IoTDBTarget(
+            target_id=self.target_id,
+            display_name=self.display_name,
+            kind=self.target_kind,
+            host=self.host,
+            port=self.port,
+            node_urls=tuple(self.node_urls) or (f"{self.host}:{self.port}",),
+            user=self.user,
+            password=self.password,
+            database=self.database,
+            sql_dialect=self.sql_dialect,
+            timezone=self.timezone,
+            export_path=self.export_path,
+            iotdb_home=self.iotdb_home,
+            use_ssl=self.use_ssl,
+            ca_certs=self.ca_certs,
+            connection_timeout_in_ms=self.connection_timeout_in_ms,
+            enable_redirection=self.enable_redirection,
+            enable_compression=self.enable_compression,
+            fetch_size=self.fetch_size,
+            max_retry=self.max_retry,
+            max_pool_size=self.max_pool_size,
+            tree_wait_timeout_in_ms=self.tree_wait_timeout_in_ms,
+            table_wait_timeout_in_ms=self.table_wait_timeout_in_ms,
+            policy=dict(self.policy),
+            last_known_good_credential=dict(self.last_known_good_credential),
+            verified_at=self.verified_at,
+        )
+
+    def safe_dict(self) -> dict[str, object]:
+        target = self.to_target()
+        safe = target.as_dict(include_secret=False)
+        safe["password"] = "***" if self.password else ""
+        return safe
+
     @staticmethod
     def from_env_arguments() -> "Config":
         """
@@ -69,62 +217,161 @@ class Config:
         """
         parser = argparse.ArgumentParser(description="IoTDB MCP Server")
 
+        parser.add_argument("--target-id", type=str, default=None, help="Named IoTDB target id")
         parser.add_argument(
-            "--host",
+            "--targets-file",
             type=str,
-            help="IoTDB host",
-            default=os.getenv("IOTDB_HOST", "127.0.0.1"),
+            default=None,
+            help="JSON IoTDB target registry file",
         )
+        parser.add_argument("--host", type=str, help="IoTDB host", default=None)
 
         parser.add_argument(
             "--port",
             type=int,
             help="IoTDB MySQL protocol port",
-            default=os.getenv("IOTDB_PORT", 6667),
+            default=None,
         )
 
         parser.add_argument(
             "--user",
             type=str,
             help="IoTDB username",
-            default=os.getenv("IOTDB_USER", "root"),
+            default=None,
         )
 
         parser.add_argument(
             "--password",
             type=str,
             help="IoTDB password",
-            default=os.getenv("IOTDB_PASSWORD", "root"),
+            default=None,
         )
         
         parser.add_argument(
             "--database",
             type=str,
-            help="IoTDB connect database name",
-            default=os.getenv("IOTDB_DATABASE", "test"),
+            help="IoTDB database/session scope. Table dialect uses database name; tree dialect still requires explicit root paths in SQL.",
+            default=None,
         )
 
         parser.add_argument(
             "--sql-dialect",
             type=str,
             help="SQL dialect: tree or table",
-            default=os.getenv("IOTDB_SQL_DIALECT", "table"),
+            default=None,
+        )
+
+        parser.add_argument(
+            "--timezone",
+            type=str,
+            help="IoTDB session timezone",
+            default=None,
         )
         
         parser.add_argument(
             "--export-path",
             type=str,
             help="Path for exporting query results",
-            default=os.getenv("IOTDB_EXPORT_PATH", "/tmp"),
+            default=None,
         )
+        parser.add_argument("--node-urls", type=str, default=None, help="Comma-separated IoTDB node URLs")
+        parser.add_argument("--use-ssl", choices=("true", "false"), default=None)
+        parser.add_argument("--ca-certs", type=str, default=None)
+        parser.add_argument("--connection-timeout-ms", type=int, default=None)
+        parser.add_argument("--enable-redirection", choices=("true", "false"), default=None)
+        parser.add_argument("--enable-compression", choices=("true", "false"), default=None)
+        parser.add_argument("--fetch-size", type=int, default=None)
+        parser.add_argument("--max-retry", type=int, default=None)
+        parser.add_argument("--max-pool-size", type=int, default=None)
+        parser.add_argument("--wait-timeout-ms", type=int, default=None)
 
         args = parser.parse_args()
-        return Config(
-            host=args.host,
-            port=args.port,
-            user=args.user,
-            password=args.password,
-            database=args.database,
-            sql_dialect=args.sql_dialect,
-            export_path=args.export_path,
+        targets_file = args.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")
+        targets_json = os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "")
+        registry = IoTDBTargetRegistry.from_env(
+            targets_file=targets_file or None,
+            targets_json=targets_json or None,
+        )
+        if registry.list_targets(include_secret=False):
+            target = registry.resolve(
+                args.target_id or os.getenv("TIMESEEK_IOTDB_TARGET_ID")
+            )
+        else:
+            wait_timeout = (
+                args.wait_timeout_ms
+                if args.wait_timeout_ms is not None
+                else os.getenv("IOTDB_WAIT_TIMEOUT_MS")
+            )
+            target = target_from_mapping(
+                {
+                    "target_id": "unverified-template",
+                    "display_name": "Unverified IoTDB template",
+                    "host": _argument_or_env(args.host, "IOTDB_HOST"),
+                    "port": _argument_or_env(args.port, "IOTDB_PORT"),
+                    "user": _argument_or_env(args.user, "IOTDB_USER"),
+                    "password": _argument_or_env(args.password, "IOTDB_PASSWORD"),
+                    "database": _argument_or_env(args.database, "IOTDB_DATABASE"),
+                    "sql_dialect": args.sql_dialect
+                    or os.getenv("IOTDB_SQL_DIALECT")
+                    or "table",
+                    "timezone": _argument_or_env(args.timezone, "IOTDB_TIMEZONE"),
+                    "export_path": (
+                        args.export_path
+                        if args.export_path is not None
+                        else default_export_path()
+                    ),
+                    "node_urls": _argument_or_env(args.node_urls, "IOTDB_NODE_URLS"),
+                    "iotdb_home": os.getenv("TIMESEEK_IOTDB_HOME"),
+                    "use_ssl": _argument_or_env(args.use_ssl, "IOTDB_USE_SSL"),
+                    "ca_certs": _argument_or_env(args.ca_certs, "IOTDB_CA_CERTS"),
+                    "connection_timeout_in_ms": _argument_or_env(
+                        args.connection_timeout_ms,
+                        "IOTDB_CONNECTION_TIMEOUT_MS",
+                    ),
+                    "enable_redirection": _argument_or_env(
+                        args.enable_redirection,
+                        "IOTDB_ENABLE_REDIRECTION",
+                    ),
+                    "enable_compression": _argument_or_env(
+                        args.enable_compression,
+                        "IOTDB_ENABLE_COMPRESSION",
+                    ),
+                    "fetch_size": _argument_or_env(args.fetch_size, "IOTDB_FETCH_SIZE"),
+                    "max_retry": _argument_or_env(args.max_retry, "IOTDB_MAX_RETRY"),
+                    "max_pool_size": _argument_or_env(
+                        args.max_pool_size,
+                        "IOTDB_MAX_POOL_SIZE",
+                    ),
+                    "tree_wait_timeout_in_ms": (
+                        args.wait_timeout_ms
+                        if args.wait_timeout_ms is not None
+                        else os.getenv("IOTDB_TREE_WAIT_TIMEOUT_MS")
+                        or wait_timeout
+                    ),
+                    "table_wait_timeout_in_ms": (
+                        args.wait_timeout_ms
+                        if args.wait_timeout_ms is not None
+                        else os.getenv("IOTDB_TABLE_WAIT_TIMEOUT_MS")
+                        or wait_timeout
+                    ),
+                }
+            )
+            registry = registry.with_target(
+                target,
+                default_target_id=target.target_id,
+            )
+        from iotdb_mcp_server.session_manager import IoTDBSessionManager
+
+        config = Config.from_target(
+            target,
+            target_registry=registry,
+            session_manager=IoTDBSessionManager(registry),
         )
+        config.targets_file = targets_file
+        config.targets_json = targets_json
+        if targets_file:
+            try:
+                config.targets_file_mtime_ns = Path(targets_file).expanduser().stat().st_mtime_ns
+            except OSError:
+                config.targets_file_mtime_ns = None
+        return config
diff --git a/src/iotdb_mcp_server/result_store.py b/src/iotdb_mcp_server/result_store.py
new file mode 100644
index 0000000..782318a
--- /dev/null
+++ b/src/iotdb_mcp_server/result_store.py
@@ -0,0 +1,845 @@
+#
+# 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.
+#
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+from datetime import datetime, timezone
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+from typing import Any, Iterable
+import uuid
+
+_RESULT_ID_PATTERN = re.compile(r"^res_[0-9]{8}T[0-9]{6}Z_[a-f0-9]{16}$")
+_OWNER_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]{1,180}$")
+_MANIFEST_NAME = "manifest.json"
+_STANDALONE_OWNER = "standalone"
+
+
+def _env_int(name: str, default: int, minimum: int = 1) -> int:
+    raw = os.getenv(name, "").strip()
+    if not raw:
+        return default
+    try:
+        return max(minimum, int(raw))
+    except ValueError:
+        return default
+
+
+def _bounded_int(value: Any, default: int, minimum: int = 1, maximum: int | None = None) -> int:
+    if value is None or str(value).strip() == "":
+        resolved = default
+    else:
+        resolved = int(value)
+    resolved = max(minimum, resolved)
+    if maximum is not None:
+        resolved = min(maximum, resolved)
+    return resolved
+
+
+def _safe_owner_session_id(value: str | None) -> str:
+    raw = (
+        value
+        or os.getenv("TIMESEEK_MCP_SESSION_ID", "").strip()
+        or os.getenv("TIMESEEK_CODEX_SESSION_ID", "").strip()
+        or _STANDALONE_OWNER
+    )
+    cleaned = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(raw)).strip("_")
+    if not cleaned:
+        cleaned = _STANDALONE_OWNER
+    cleaned = cleaned[:180]
+    if not _OWNER_PATTERN.fullmatch(cleaned):
+        raise ValueError("Invalid owner_session_id.")
+    return cleaned
+
+
+@dataclass(frozen=True)
+class ResultStoreSettings:
+    preview_rows: int = 50
+    page_size_rows: int = 500
+    max_page_rows: int = 5000
+    shard_rows: int = 10000
+    shard_bytes: int = 8 * 1024 * 1024
+    ttl_seconds: int = 24 * 60 * 60
+    max_session_results: int = 100
+    max_session_bytes: int = 512 * 1024 * 1024
+    max_global_results: int = 1000
+    max_global_bytes: int = 2 * 1024 * 1024 * 1024
+
+    @classmethod
+    def from_env(cls) -> "ResultStoreSettings":
+        return cls(
+            preview_rows=_env_int("TIMESEEK_RESULT_PREVIEW_ROWS", 50, minimum=0),
+            page_size_rows=_env_int("TIMESEEK_RESULT_PAGE_SIZE_ROWS", 500),
+            max_page_rows=_env_int("TIMESEEK_RESULT_MAX_PAGE_ROWS", 5000),
+            shard_rows=_env_int("TIMESEEK_RESULT_SHARD_ROWS", 10000),
+            shard_bytes=_env_int("TIMESEEK_RESULT_SHARD_BYTES", 8 * 1024 * 1024),
+            ttl_seconds=_env_int(
+                "TIMESEEK_RESULT_TTL_SECONDS", 24 * 60 * 60, minimum=0
+            ),
+            max_session_results=_env_int(
+                "TIMESEEK_RESULT_MAX_SESSION_RESULTS", 100, minimum=0
+            ),
+            max_session_bytes=_env_int(
+                "TIMESEEK_RESULT_MAX_SESSION_BYTES", 512 * 1024 * 1024, minimum=0
+            ),
+            max_global_results=_env_int(
+                "TIMESEEK_RESULT_MAX_GLOBAL_RESULTS", 1000, minimum=0
+            ),
+            max_global_bytes=_env_int(
+                "TIMESEEK_RESULT_MAX_GLOBAL_BYTES",
+                2 * 1024 * 1024 * 1024,
+                minimum=0,
+            ),
+        )
+
+
+@dataclass(frozen=True)
+class StoredCsvResult:
+    result_id: str
+    owner_session_id: str
+    columns: list[str]
+    preview_rows: list[str]
+    row_count: int
+    byte_count: int
+    page_size_rows: int
+    manifest_path: str
+    shard_count: int
+
+
+@dataclass(frozen=True)
+class ResultStoreEntry:
+    result_id: str
+    owner_session_id: str
+    tool: str
+    row_count: int
+    byte_count: int
+    storage_bytes: int
+    shard_count: int
+    created_at: datetime
+    last_accessed_at: datetime
+    manifest_path: Path
+    result_dir: Path
+    legacy: bool = False
+
+    def to_payload(self) -> dict[str, Any]:
+        return {
+            "result_id": self.result_id,
+            "owner_session_id": self.owner_session_id,
+            "tool": self.tool,
+            "row_count": self.row_count,
+            "byte_count": self.byte_count,
+            "storage_bytes": self.storage_bytes,
+            "shard_count": self.shard_count,
+            "created_at": self.created_at.isoformat(),
+            "last_accessed_at": self.last_accessed_at.isoformat(),
+            "manifest_path": str(self.manifest_path),
+            "legacy": self.legacy,
+        }
+
+
+class FileResultStore:
+    """File-backed store for large MCP result sets.
+
+    Rows are stored as JSON lines so callers can fetch a page without parsing a
+    whole CSV file. The public row representation intentionally remains the
+    existing CSV row string used by the IoTDB MCP response schema.
+    """
+
+    def __init__(
+        self,
+        root_dir: str | os.PathLike[str],
+        settings: ResultStoreSettings | None = None,
+    ) -> None:
+        self.root_dir = Path(root_dir).expanduser().resolve()
+        self.settings = settings or ResultStoreSettings.from_env()
+
+    def _ensure_root(self) -> None:
+        self.root_dir.mkdir(parents=True, exist_ok=True)
+
+    def _new_result_id(self) -> str:
+        timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+        return f"res_{timestamp}_{uuid.uuid4().hex[:16]}"
+
+    def _session_dir(self, owner_session_id: str | None = None) -> Path:
+        owner = _safe_owner_session_id(owner_session_id)
+        session_dir = (self.root_dir / "sessions" / owner).resolve()
+        sessions_root = (self.root_dir / "sessions").resolve()
+        if not str(session_dir).startswith(str(sessions_root) + os.sep):
+            raise ValueError("Invalid owner_session_id path.")
+        return session_dir
+
+    def _legacy_result_dir(self, result_id: str) -> Path:
+        if not _RESULT_ID_PATTERN.fullmatch(result_id):
+            raise ValueError("Invalid result_id.")
+        result_dir = (self.root_dir / result_id).resolve()
+        if not str(result_dir).startswith(str(self.root_dir) + os.sep):
+            raise ValueError("Invalid result_id path.")
+        return result_dir
+
+    def _result_dir(self, result_id: str, owner_session_id: str | None = None) -> Path:
+        if not _RESULT_ID_PATTERN.fullmatch(result_id):
+            raise ValueError("Invalid result_id.")
+        session_dir = self._session_dir(owner_session_id)
+        result_dir = (session_dir / result_id).resolve()
+        if not str(result_dir).startswith(str(session_dir) + os.sep):
+            raise ValueError("Invalid result_id path.")
+        return result_dir
+
+    def _existing_result_dir(
+        self, result_id: str, owner_session_id: str | None = None
+    ) -> Path:
+        result_dir = self._result_dir(result_id, owner_session_id)
+        if (result_dir / _MANIFEST_NAME).is_file():
+            return result_dir
+        legacy_dir = self._legacy_result_dir(result_id)
+        if (legacy_dir / _MANIFEST_NAME).is_file():
+            return legacy_dir
+        raise FileNotFoundError(f"Result '{result_id}' was not found.")
+
+    def write_csv_result(
+        self,
+        *,
+        tool: str,
+        columns: list[str],
+        rows: Iterable[str],
+        source: dict[str, Any] | None = None,
+        owner_session_id: str | None = None,
+        preview_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> StoredCsvResult:
+        self._ensure_root()
+        owner = _safe_owner_session_id(owner_session_id)
+        result_id = self._new_result_id()
+        result_dir = self._result_dir(result_id, owner)
+        result_dir.mkdir(parents=True, exist_ok=False)
+
+        preview_limit = (
+            self.settings.preview_rows if preview_rows is None else max(0, preview_rows)
+        )
+        page_size = (
+            self.settings.page_size_rows
+            if page_size_rows is None
+            else max(1, page_size_rows)
+        )
+
+        preview: list[str] = []
+        shards: list[dict[str, Any]] = []
+        row_count = 0
+        byte_count = 0
+        shard_index = -1
+        shard_row_count = 0
+        shard_byte_count = 0
+        shard_start = 0
+        shard_file = None
+        shard_path: Path | None = None
+
+        def open_shard() -> None:
+            nonlocal shard_index, shard_row_count, shard_byte_count
+            nonlocal shard_start, shard_file, shard_path
+            shard_index += 1
+            shard_row_count = 0
+            shard_byte_count = 0
+            shard_start = row_count
+            shard_path = result_dir / f"rows-{shard_index:05d}.jsonl"
+            shard_file = shard_path.open("w", encoding="utf-8")
+
+        def close_shard() -> None:
+            nonlocal shard_file
+            if shard_file is None or shard_path is None:
+                return
+            shard_file.close()
+            shards.append(
+                {
+                    "index": shard_index,
+                    "file": shard_path.name,
+                    "row_start": shard_start,
+                    "row_count": shard_row_count,
+                    "bytes": shard_byte_count,
+                }
+            )
+            shard_file = None
+
+        try:
+            open_shard()
+            for row in rows:
+                row_text = str(row)
+                encoded = json.dumps(row_text, ensure_ascii=False) + "\n"
+                encoded_bytes = len(encoded.encode("utf-8"))
+                if shard_row_count > 0 and (
+                    shard_row_count >= self.settings.shard_rows
+                    or shard_byte_count + encoded_bytes > self.settings.shard_bytes
+                ):
+                    close_shard()
+                    open_shard()
+
+                assert shard_file is not None
+                shard_file.write(encoded)
+                if len(preview) < preview_limit:
+                    preview.append(row_text)
+                row_count += 1
+                byte_count += encoded_bytes
+                shard_row_count += 1
+                shard_byte_count += encoded_bytes
+            close_shard()
+        except Exception:
+            if shard_file is not None:
+                try:
+                    shard_file.close()
+                except OSError:
+                    pass
+            shutil.rmtree(result_dir, ignore_errors=True)
+            raise
+
+        manifest = {
+            "schema_version": "iotdb_result_store_v1",
+            "result_id": result_id,
+            "owner_session_id": owner,
+            "created_at": datetime.now(timezone.utc).isoformat(),
+            "last_accessed_at": datetime.now(timezone.utc).isoformat(),
+            "tool": tool,
+            "format": "csv",
+            "columns": columns,
+            "row_count": row_count,
+            "byte_count": byte_count,
+            "preview_rows": preview,
+            "preview_row_count": len(preview),
+            "page_size_rows": page_size,
+            "source": source or {},
+            "shards": shards,
+        }
+        manifest_path = result_dir / _MANIFEST_NAME
+        try:
+            manifest_path.write_text(
+                json.dumps(manifest, ensure_ascii=False, indent=2),
+                encoding="utf-8",
+            )
+        except Exception:
+            shutil.rmtree(result_dir, ignore_errors=True)
+            raise
+        self.cleanup(owner_session_id=owner)
+        return StoredCsvResult(
+            result_id=result_id,
+            owner_session_id=owner,
+            columns=columns,
+            preview_rows=preview,
+            row_count=row_count,
+            byte_count=byte_count,
+            page_size_rows=page_size,
+            manifest_path=str(manifest_path),
+            shard_count=len(shards),
+        )
+
+    def _parse_time(self, value: Any, fallback: datetime | None = None) -> datetime:
+        if isinstance(value, str) and value.strip():
+            try:
+                parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+                if parsed.tzinfo is None:
+                    return parsed.replace(tzinfo=timezone.utc)
+                return parsed.astimezone(timezone.utc)
+            except ValueError:
+                pass
+        return fallback or datetime.fromtimestamp(0, timezone.utc)
+
+    def _dir_size(self, result_dir: Path) -> int:
+        total = 0
+        try:
+            for path in result_dir.rglob("*"):
+                if path.is_file():
+                    total += path.stat().st_size
+        except OSError:
+            return total
+        return total
+
+    def _entry_from_manifest(
+        self,
+        manifest_path: Path,
+        *,
+        legacy: bool = False,
+    ) -> ResultStoreEntry | None:
+        try:
+            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+        except (OSError, json.JSONDecodeError):
+            return None
+        result_id = str(manifest.get("result_id") or manifest_path.parent.name)
+        if not _RESULT_ID_PATTERN.fullmatch(result_id):
+            return None
+        if legacy:
+            owner = str(manifest.get("owner_session_id") or _STANDALONE_OWNER)
+        else:
+            owner = _safe_owner_session_id(
+                manifest.get("owner_session_id") or manifest_path.parent.parent.name
+            )
+        created_at = self._parse_time(manifest.get("created_at"))
+        last_accessed_at = self._parse_time(
+            manifest.get("last_accessed_at"), fallback=created_at
+        )
+        return ResultStoreEntry(
+            result_id=result_id,
+            owner_session_id=owner,
+            tool=str(manifest.get("tool") or ""),
+            row_count=int(manifest.get("row_count") or 0),
+            byte_count=int(manifest.get("byte_count") or 0),
+            storage_bytes=self._dir_size(manifest_path.parent),
+            shard_count=len(manifest.get("shards") or []),
+            created_at=created_at,
+            last_accessed_at=last_accessed_at,
+            manifest_path=manifest_path,
+            result_dir=manifest_path.parent,
+            legacy=legacy,
+        )
+
+    def _iter_entries(
+        self, owner_session_id: str | None = None
+    ) -> list[ResultStoreEntry]:
+        entries: list[ResultStoreEntry] = []
+        sessions_root = self.root_dir / "sessions"
+        owners: list[str]
+        if owner_session_id is None:
+            owners = []
+            if sessions_root.is_dir():
+                owners = [
+                    path.name
+                    for path in sessions_root.iterdir()
+                    if path.is_dir() and _OWNER_PATTERN.fullmatch(path.name)
+                ]
+        else:
+            owners = [_safe_owner_session_id(owner_session_id)]
+
+        for owner in owners:
+            owner_dir = sessions_root / owner
+            if not owner_dir.is_dir():
+                continue
+            for manifest_path in owner_dir.glob(f"res_*/{_MANIFEST_NAME}"):
+                entry = self._entry_from_manifest(manifest_path)
+                if entry is not None:
+                    entries.append(entry)
+
+        if owner_session_id in (None, _STANDALONE_OWNER):
+            for manifest_path in self.root_dir.glob(f"res_*/{_MANIFEST_NAME}"):
+                entry = self._entry_from_manifest(manifest_path, legacy=True)
+                if entry is not None:
+                    entries.append(entry)
+
+        return entries
+
+    def list_results(
+        self,
+        owner_session_id: str | None = None,
+        *,
+        all_sessions: bool = False,
+        limit: int = 50,
+    ) -> dict[str, Any]:
+        owner = None if all_sessions else _safe_owner_session_id(owner_session_id)
+        entries = self._iter_entries(owner)
+        entries.sort(key=lambda entry: entry.last_accessed_at, reverse=True)
+        bounded_limit = max(1, min(int(limit), 500))
+        selected = entries[:bounded_limit]
+        return {
+            "owner_session_id": owner,
+            "all_sessions": all_sessions,
+            "result_count": len(entries),
+            "returned_results": len(selected),
+            "storage_bytes": sum(entry.storage_bytes for entry in entries),
+            "results": [entry.to_payload() for entry in selected],
+        }
+
+    def _delete_entry(
+        self,
+        entry: ResultStoreEntry,
+        *,
+        reason: str,
+        deleted_dirs: set[Path],
+    ) -> dict[str, Any] | None:
+        if entry.result_dir in deleted_dirs:
+            return None
+        try:
+            shutil.rmtree(entry.result_dir)
+        except FileNotFoundError:
+            pass
+        deleted_dirs.add(entry.result_dir)
+        return {
+            "result_id": entry.result_id,
+            "owner_session_id": entry.owner_session_id,
+            "storage_bytes": entry.storage_bytes,
+            "reason": reason,
+        }
+
+    def _enforce_lru_quota(
+        self,
+        entries: list[ResultStoreEntry],
+        *,
+        max_results: int,
+        max_bytes: int,
+        reason: str,
+        deleted_dirs: set[Path],
+    ) -> list[dict[str, Any]]:
+        active = [
+            entry
+            for entry in entries
+            if entry.result_dir not in deleted_dirs and entry.manifest_path.is_file()
+        ]
+        active.sort(key=lambda entry: entry.last_accessed_at)
+        total_bytes = sum(entry.storage_bytes for entry in active)
+        deleted: list[dict[str, Any]] = []
+
+        def over_quota() -> bool:
+            return (max_results > 0 and len(active) > max_results) or (
+                max_bytes > 0 and total_bytes > max_bytes
+            )
+
+        while len(active) > 1 and over_quota():
+            entry = active.pop(0)
+            total_bytes -= entry.storage_bytes
+            info = self._delete_entry(entry, reason=reason, deleted_dirs=deleted_dirs)
+            if info is not None:
+                deleted.append(info)
+        return deleted
+
+    def delete_result(
+        self,
+        result_id: str,
+        owner_session_id: str | None = None,
+        *,
+        all_sessions: bool = False,
+    ) -> dict[str, Any]:
+        owner = None if all_sessions else _safe_owner_session_id(owner_session_id)
+        entries = self._iter_entries(owner)
+        for entry in entries:
+            if entry.result_id != result_id:
+                continue
+            deleted_dirs: set[Path] = set()
+            info = self._delete_entry(
+                entry, reason="explicit_delete", deleted_dirs=deleted_dirs
+            )
+            return {
+                "deleted": info is not None,
+                "result": info or entry.to_payload(),
+            }
+        raise FileNotFoundError(f"Result '{result_id}' was not found.")
+
+    def cleanup(
+        self,
+        owner_session_id: str | None = None,
+        *,
+        all_sessions: bool = False,
+        ttl_seconds: int | None = None,
+        max_session_results: int | None = None,
+        max_session_bytes: int | None = None,
+        max_global_results: int | None = None,
+        max_global_bytes: int | None = None,
+    ) -> dict[str, Any]:
+        self._ensure_root()
+        owner = None if all_sessions else _safe_owner_session_id(owner_session_id)
+        deleted_dirs: set[Path] = set()
+        deleted: list[dict[str, Any]] = []
+        now = datetime.now(timezone.utc)
+        effective_ttl = (
+            self.settings.ttl_seconds
+            if ttl_seconds is None
+            else max(0, int(ttl_seconds))
+        )
+
+        ttl_candidates = self._iter_entries(owner)
+        if effective_ttl > 0:
+            cutoff = now - timedelta(seconds=effective_ttl)
+            for entry in ttl_candidates:
+                if entry.last_accessed_at >= cutoff:
+                    continue
+                info = self._delete_entry(
+                    entry, reason="ttl_expired", deleted_dirs=deleted_dirs
+                )
+                if info is not None:
+                    deleted.append(info)
+
+        session_result_limit = (
+            self.settings.max_session_results
+            if max_session_results is None
+            else max(0, int(max_session_results))
+        )
+        session_byte_limit = (
+            self.settings.max_session_bytes
+            if max_session_bytes is None
+            else max(0, int(max_session_bytes))
+        )
+        owners = (
+            sorted({entry.owner_session_id for entry in self._iter_entries(None)})
+            if all_sessions
+            else [owner]
+        )
+        for current_owner in owners:
+            if current_owner is None:
+                continue
+            deleted.extend(
+                self._enforce_lru_quota(
+                    self._iter_entries(current_owner),
+                    max_results=session_result_limit,
+                    max_bytes=session_byte_limit,
+                    reason="session_lru_quota",
+                    deleted_dirs=deleted_dirs,
+                )
+            )
+
+        global_result_limit = (
+            self.settings.max_global_results
+            if max_global_results is None
+            else max(0, int(max_global_results))
+        )
+        global_byte_limit = (
+            self.settings.max_global_bytes
+            if max_global_bytes is None
+            else max(0, int(max_global_bytes))
+        )
+        deleted.extend(
+            self._enforce_lru_quota(
+                self._iter_entries(None),
+                max_results=global_result_limit,
+                max_bytes=global_byte_limit,
+                reason="global_lru_quota",
+                deleted_dirs=deleted_dirs,
+            )
+        )
+
+        remaining = self._iter_entries(None)
+        return {
+            "owner_session_id": owner,
+            "all_sessions": all_sessions,
+            "ttl_seconds": effective_ttl,
+            "limits": {
+                "max_session_results": session_result_limit,
+                "max_session_bytes": session_byte_limit,
+                "max_global_results": global_result_limit,
+                "max_global_bytes": global_byte_limit,
+            },
+            "deleted_results": len(deleted),
+            "deleted_storage_bytes": sum(item["storage_bytes"] for item in deleted),
+            "remaining_results": len(remaining),
+            "remaining_storage_bytes": sum(entry.storage_bytes for entry in remaining),
+            "deleted": deleted,
+        }
+
+    def read_manifest(
+        self, result_id: str, owner_session_id: str | None = None
+    ) -> dict[str, Any]:
+        manifest_path = (
+            self._existing_result_dir(result_id, owner_session_id) / _MANIFEST_NAME
+        )
+        try:
+            return json.loads(manifest_path.read_text(encoding="utf-8"))
+        except FileNotFoundError as exc:
+            raise FileNotFoundError(f"Result '{result_id}' was not found.") from exc
+
+    def _touch_manifest(self, result_dir: Path, manifest: dict[str, Any]) -> None:
+        manifest["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
+        (result_dir / _MANIFEST_NAME).write_text(
+            json.dumps(manifest, ensure_ascii=False, indent=2),
+            encoding="utf-8",
+        )
+
+    def read_page(
+        self,
+        result_id: str,
+        *,
+        offset: int = 0,
+        limit: int | None = None,
+        owner_session_id: str | None = None,
+    ) -> dict[str, Any]:
+        result_dir = self._existing_result_dir(result_id, owner_session_id)
+        manifest = json.loads((result_dir / _MANIFEST_NAME).read_text(encoding="utf-8"))
+        row_count = int(manifest.get("row_count", 0))
+        page_size = int(manifest.get("page_size_rows") or self.settings.page_size_rows)
+        requested_limit = page_size if limit is None else max(1, int(limit))
+        effective_limit = min(requested_limit, self.settings.max_page_rows)
+        effective_offset = min(max(0, int(offset)), row_count)
+        rows: list[str] = []
+
+        for shard in manifest.get("shards", []):
+            shard_start = int(shard["row_start"])
+            shard_count = int(shard["row_count"])
+            shard_end = shard_start + shard_count
+            if shard_end <= effective_offset:
+                continue
+            if shard_start >= effective_offset + effective_limit:
+                break
+
+            skip = max(0, effective_offset - shard_start)
+            shard_path = result_dir / str(shard["file"])
+            with shard_path.open("r", encoding="utf-8") as handle:
+                for index, line in enumerate(handle):
+                    if index < skip:
+                        continue
+                    if len(rows) >= effective_limit:
+                        break
+                    rows.append(str(json.loads(line)))
+            if len(rows) >= effective_limit:
+                break
+
+        next_offset = effective_offset + len(rows)
+        has_more = next_offset < row_count
+        self._touch_manifest(result_dir, manifest)
+        return {
+            "format": "csv",
+            "result_id": result_id,
+            "owner_session_id": manifest.get("owner_session_id", _STANDALONE_OWNER),
+            "columns": list(manifest.get("columns", [])),
+            "rows": rows,
+            "row_count": row_count,
+            "offset": effective_offset,
+            "limit": effective_limit,
+            "returned_rows": len(rows),
+            "next_cursor": str(next_offset) if has_more else None,
+            "has_more": has_more,
+            "manifest_path": str(result_dir / _MANIFEST_NAME),
+        }
+
+    def _page_request_offset(self, request: dict[str, Any]) -> int:
+        if request.get("offset") is not None:
+            return max(0, int(request["offset"]))
+        cursor = request.get("cursor")
+        if cursor is None or str(cursor).strip() == "":
+            return 0
+        cleaned = str(cursor).strip()
+        if not cleaned.isdigit():
+            raise ValueError("cursor must be a non-negative integer row offset.")
+        return int(cleaned)
+
+    def read_pages(
+        self,
+        requests: list[dict[str, Any]],
+        *,
+        owner_session_id: str | None = None,
+        default_limit: int | None = None,
+        max_pages: int | None = None,
+        max_total_rows: int | None = None,
+        continue_on_error: bool = True,
+    ) -> dict[str, Any]:
+        if not isinstance(requests, list) or not requests:
+            raise ValueError("pages must be a non-empty list.")
+        effective_max_pages = _bounded_int(
+            max_pages if max_pages is not None else os.getenv("TIMESEEK_RESULT_MAX_BATCH_PAGES"),
+            32,
+            minimum=1,
+            maximum=256,
+        )
+        if len(requests) > effective_max_pages:
+            raise ValueError(
+                f"read_result_pages accepts at most {effective_max_pages} page requests."
+            )
+        effective_max_total_rows = _bounded_int(
+            max_total_rows
+            if max_total_rows is not None
+            else os.getenv("TIMESEEK_RESULT_MAX_BATCH_READ_ROWS"),
+            10000,
+            minimum=1,
+            maximum=1_000_000,
+        )
+
+        results: list[dict[str, Any]] = []
+        total_rows = 0
+        failed = 0
+        skipped = 0
+        stop = False
+
+        for index, request in enumerate(requests):
+            if stop:
+                skipped += 1
+                results.append(
+                    {
+                        "index": index,
+                        "ok": False,
+                        "status": "skipped",
+                        "error_type": "BatchAborted",
+                        "error": "Skipped because continue_on_error is false and an earlier page failed.",
+                    }
+                )
+                continue
+
+            try:
+                if not isinstance(request, dict):
+                    raise ValueError("Each page request must be an object.")
+                result_id = str(request.get("result_id") or "").strip()
+                if not result_id:
+                    raise ValueError("Each page request requires result_id.")
+                page_owner = request.get("owner_session_id")
+                if page_owner is not None:
+                    page_owner = str(page_owner)
+                else:
+                    page_owner = owner_session_id
+                page_limit = (
+                    request.get("limit")
+                    if request.get("limit") is not None
+                    else default_limit
+                )
+                page = self.read_page(
+                    result_id,
+                    offset=self._page_request_offset(request),
+                    limit=None if page_limit is None else int(page_limit),
+                    owner_session_id=page_owner,
+                )
+                next_total_rows = total_rows + int(page.get("returned_rows") or 0)
+                if next_total_rows > effective_max_total_rows:
+                    raise ValueError(
+                        "read_result_pages total row quota exceeded: "
+                        f"{next_total_rows}>{effective_max_total_rows}"
+                    )
+                total_rows = next_total_rows
+                page["index"] = index
+                page["ok"] = True
+                page["status"] = "success"
+                results.append(page)
+            except Exception as exc:
+                failed += 1
+                if not continue_on_error:
+                    stop = True
+                results.append(
+                    {
+                        "index": index,
+                        "ok": False,
+                        "status": "error",
+                        "result_id": request.get("result_id") if isinstance(request, dict) else None,
+                        "error_type": type(exc).__name__,
+                        "error": str(exc),
+                    }
+                )
+
+        succeeded = sum(1 for item in results if item.get("status") == "success")
+        return {
+            "format": "csv",
+            "page_request_count": len(requests),
+            "returned_pages": succeeded,
+            "succeeded": succeeded,
+            "failed": failed,
+            "skipped": skipped,
+            "continue_on_error": continue_on_error,
+            "limits": {
+                "max_pages": effective_max_pages,
+                "max_total_rows": effective_max_total_rows,
+                "default_limit": default_limit,
+            },
+            "returned_rows": total_rows,
+            "pages": results,
+        }
+
+
+def result_store_from_export_path(export_path: str) -> FileResultStore:
+    return FileResultStore(Path(export_path).expanduser() / "result_store")
diff --git a/src/iotdb_mcp_server/runtime_policy.py b/src/iotdb_mcp_server/runtime_policy.py
new file mode 100644
index 0000000..4b3f418
--- /dev/null
+++ b/src/iotdb_mcp_server/runtime_policy.py
@@ -0,0 +1,353 @@
+# 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.
+#
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import json
+import os
+from pathlib import Path
+import re
+from threading import RLock
+from typing import Any
+
+
+_DEFAULT_SERVER_NAME = "iotdb"
+_SHELL_DEFAULT_RE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}$")
+_SENSITIVE_KEY_RE = re.compile(r"(PASSWORD|SECRET|TOKEN|API_KEY|ACCESS_KEY)", re.IGNORECASE)
+_WITHHELD_ENV_KEYS = frozenset({"TIMESEEK_IOTDB_TARGETS_JSON"})
+_SESSION_POLICY_LOCK = RLock()
+_SESSION_POLICY: dict[str, str] = {}
+
+_ENFORCEMENT_POLICY_KEYS = frozenset(
+    {
+        "TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
+        "IOTDB_STRICT_PERMISSION_ENFORCEMENT",
+    }
+)
+
+_POLICY_KEYS = frozenset(
+    {
+        "IOTDB_ENABLE_METADATA_QUERY",
+        "IOTDB_METADATA_ALLOWED_USERS",
+        "IOTDB_ENABLE_DATABASE_DDL",
+        "IOTDB_DATABASE_DDL_ALLOWED_USERS",
+        "IOTDB_REQUIRE_DROP_CONFIRM",
+        "IOTDB_ENABLE_TABLE_DDL",
+        "IOTDB_TABLE_DDL_ALLOWED_USERS",
+        "IOTDB_REQUIRE_TABLE_DROP_CONFIRM",
+        "IOTDB_ENABLE_TIMESERIES_DDL",
+        "IOTDB_TIMESERIES_DDL_ALLOWED_USERS",
+        "IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM",
+        "IOTDB_ENABLE_TTL_SQL",
+        "IOTDB_TTL_ALLOWED_USERS",
+        "IOTDB_REQUIRE_TTL_UNSET_CONFIRM",
+        "IOTDB_ENABLE_WRITE_DML",
+        "IOTDB_WRITE_ALLOWED_USERS",
+        "IOTDB_REQUIRE_DELETE_CONFIRM",
+        "IOTDB_ENABLE_SQL_DRIVER",
+        "IOTDB_SQL_DRIVER_ALLOWED_USERS",
+        "IOTDB_SQL_DRIVER_MODE",
+        "IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM",
+        "IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES",
+        "IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES",
+        "IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES",
+        "IOTDB_ENABLE_MODEL_MANAGEMENT",
+        "IOTDB_MODEL_ALLOWED_USERS",
+        "IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM",
+        "TIMESEEK_MCP_PERMISSION_ENFORCEMENT",
+        "IOTDB_STRICT_PERMISSION_ENFORCEMENT",
+    }
+)
+
+_FULL_PERMISSION_DEFAULTS = {
+    "IOTDB_ENABLE_METADATA_QUERY": "true",
+    "IOTDB_METADATA_ALLOWED_USERS": "*",
+    "IOTDB_ENABLE_DATABASE_DDL": "true",
+    "IOTDB_DATABASE_DDL_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_DROP_CONFIRM": "true",
+    "IOTDB_ENABLE_TABLE_DDL": "true",
+    "IOTDB_TABLE_DDL_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_TABLE_DROP_CONFIRM": "true",
+    "IOTDB_ENABLE_TIMESERIES_DDL": "true",
+    "IOTDB_TIMESERIES_DDL_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM": "true",
+    "IOTDB_ENABLE_TTL_SQL": "true",
+    "IOTDB_TTL_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_TTL_UNSET_CONFIRM": "true",
+    "IOTDB_ENABLE_WRITE_DML": "true",
+    "IOTDB_WRITE_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_DELETE_CONFIRM": "true",
+    "IOTDB_ENABLE_SQL_DRIVER": "true",
+    "IOTDB_SQL_DRIVER_ALLOWED_USERS": "*",
+    "IOTDB_SQL_DRIVER_MODE": "full",
+    "IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM": "true",
+    "IOTDB_ENABLE_MODEL_MANAGEMENT": "true",
+    "IOTDB_MODEL_ALLOWED_USERS": "*",
+    "IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM": "true",
+}
+
+_ENFORCEMENT_DEFAULTS = {
+    "TIMESEEK_MCP_PERMISSION_ENFORCEMENT": "advisory",
+    "IOTDB_STRICT_PERMISSION_ENFORCEMENT": "false",
+}
+
+_PRESET_POLICIES = {
+    "full": dict(_FULL_PERMISSION_DEFAULTS),
+    "ddl": {
+        **_FULL_PERMISSION_DEFAULTS,
+        "IOTDB_SQL_DRIVER_MODE": "ddl",
+        "IOTDB_ENABLE_WRITE_DML": "false",
+    },
+    "readonly": {
+        **_FULL_PERMISSION_DEFAULTS,
+        "IOTDB_ENABLE_DATABASE_DDL": "false",
+        "IOTDB_ENABLE_TABLE_DDL": "false",
+        "IOTDB_ENABLE_TIMESERIES_DDL": "false",
+        "IOTDB_ENABLE_TTL_SQL": "false",
+        "IOTDB_ENABLE_WRITE_DML": "false",
+        "IOTDB_SQL_DRIVER_MODE": "readonly",
+    },
+}
+
+
+def _candidate_mcp_config_paths() -> list[Path]:
+    candidates: list[Path] = []
+    explicit = os.getenv("TIMESEEK_MCP_CONFIG_PATH")
+    if explicit:
+        candidates.append(Path(explicit).expanduser())
+
+    timeseek_root = os.getenv("TIMESEEK_ROOT")
+    if timeseek_root:
+        root = Path(timeseek_root).expanduser()
+        candidates.extend(
+            [
+                root / ".mcp.json",
+                root / "timeseek-codex-multi-plugin" / ".mcp.json",
+                root / "timeseek-codex-single-plugin" / ".mcp.json",
+            ]
+        )
+
+    repo_root = os.getenv("REPO_ROOT") or os.getenv("TIMESEEK_REPO_ROOT")
+    if repo_root:
+        root = Path(repo_root).expanduser()
+        candidates.extend(
+            [
+                root / ".mcp.json",
+                root / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
+                root / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
+                root / "timeseek_cc" / ".mcp.json",
+            ]
+        )
+
+    cwd = Path.cwd()
+    candidates.extend(
+        [
+            cwd / ".mcp.json",
+            cwd / "timeseek_codex" / "timeseek-codex-multi-plugin" / ".mcp.json",
+            cwd / "timeseek_codex" / "timeseek-codex-single-plugin" / ".mcp.json",
+            cwd / "timeseek_cc" / ".mcp.json",
+        ]
+    )
+
+    deduped: list[Path] = []
+    seen: set[str] = set()
+    for candidate in candidates:
+        key = str(candidate)
+        if key not in seen:
+            seen.add(key)
+            deduped.append(candidate)
+    return deduped
+
+
+def _expand_config_value(value: Any) -> str:
+    text = str(value)
+    match = _SHELL_DEFAULT_RE.fullmatch(text)
+    if not match:
+        return text
+    name, default = match.groups()
+    current = os.getenv(name)
+    return current if current not in (None, "") else default
+
+
+def _load_mcp_env() -> tuple[dict[str, str], str]:
+    server_name = os.getenv("TIMESEEK_MCP_SERVER_NAME", _DEFAULT_SERVER_NAME)
+    for path in _candidate_mcp_config_paths():
+        if not path.exists():
+            continue
+        try:
+            data = json.loads(path.read_text(encoding="utf-8"))
+        except Exception:
+            continue
+        server = data.get("mcpServers", {}).get(server_name, {})
+        env = server.get("env", {})
+        if isinstance(env, dict):
+            return {str(key): _expand_config_value(value) for key, value in env.items()}, str(path)
+    return {}, ""
+
+
+def dynamic_getenv(name: str, default: str | None = None) -> str | None:
+    with _SESSION_POLICY_LOCK:
+        value = _SESSION_POLICY.get(name)
+    if value not in (None, ""):
+        return value
+
+    env, _ = _load_mcp_env()
+    value = env.get(name)
+    if value not in (None, ""):
+        return value
+
+    value = os.getenv(name)
+    if value not in (None, ""):
+        return value
+
+    return _FULL_PERMISSION_DEFAULTS.get(
+        name,
+        _ENFORCEMENT_DEFAULTS.get(name, default),
+    )
+
+
+def dynamic_env_bool(name: str, default: bool) -> bool:
+    value = dynamic_getenv(name)
+    if value is None:
+        return default
+    normalized = value.strip().lower()
+    if normalized in {"1", "true", "yes", "on"}:
+        return True
+    if normalized in {"0", "false", "no", "off", ""}:
+        return False
+    return default
+
+
+def permission_enforcement_mode() -> str:
+    if dynamic_env_bool("IOTDB_STRICT_PERMISSION_ENFORCEMENT", False):
+        return "strict"
+    mode = (dynamic_getenv("TIMESEEK_MCP_PERMISSION_ENFORCEMENT", "advisory") or "advisory").strip().lower()
+    if mode not in {"advisory", "strict"}:
+        return "advisory"
+    return mode
+
+
+def strict_permission_enforcement() -> bool:
+    return permission_enforcement_mode() == "strict"
+
+
+def _redact_env(env: dict[str, str]) -> dict[str, str]:
+    return {
+        key: "***"
+        if key in _WITHHELD_ENV_KEYS or _SENSITIVE_KEY_RE.search(key)
+        else value
+        for key, value in env.items()
+    }
+
+
+def _session_policy_copy() -> dict[str, str]:
+    with _SESSION_POLICY_LOCK:
+        return dict(_SESSION_POLICY)
+
+
+def _coerce_policy_value(value: Any) -> str:
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    return str(value)
+
+
+def _validate_policy_keys(policy: Mapping[str, Any]) -> None:
+    unknown = sorted(str(key) for key in policy if str(key) not in _POLICY_KEYS)
+    if unknown:
+        raise ValueError(
+            "Unsupported session policy key(s): "
+            + ", ".join(unknown)
+            + ". Use get_iotdb_session_policy to inspect supported keys."
+        )
+
+
+def session_policy_for_preset(preset: str) -> dict[str, str]:
+    normalized = preset.strip().lower()
+    try:
+        return dict(_PRESET_POLICIES[normalized])
+    except KeyError as exc:
+        raise ValueError("Unsupported policy preset. Expected one of: full, ddl, readonly.") from exc
+
+
+def supported_policy_keys() -> list[str]:
+    return sorted(_POLICY_KEYS)
+
+
+def set_session_policy(
+    policy: Mapping[str, Any] | None = None,
+    *,
+    preset: str | None = None,
+    replace: bool = False,
+) -> dict[str, Any]:
+    updates: dict[str, str] = {}
+    if preset:
+        updates.update(session_policy_for_preset(preset))
+    if policy:
+        _validate_policy_keys(policy)
+        updates.update({str(key): _coerce_policy_value(value) for key, value in policy.items()})
+
+    with _SESSION_POLICY_LOCK:
+        preserved_enforcement = (
+            {
+                key: value
+                for key, value in _SESSION_POLICY.items()
+                if key in _ENFORCEMENT_POLICY_KEYS
+            }
+            if replace and preset
+            else {}
+        )
+        if replace:
+            _SESSION_POLICY.clear()
+            _SESSION_POLICY.update(preserved_enforcement)
+        _SESSION_POLICY.update(updates)
+
+    return dynamic_policy_snapshot()
+
+
+def reset_session_policy(keys: list[str] | None = None) -> dict[str, Any]:
+    if keys is not None:
+        _validate_policy_keys({key: "" for key in keys})
+
+    with _SESSION_POLICY_LOCK:
+        if keys is None:
+            _SESSION_POLICY.clear()
+        else:
+            for key in keys:
+                _SESSION_POLICY.pop(key, None)
+
+    return dynamic_policy_snapshot()
+
+
+def dynamic_policy_snapshot() -> dict[str, Any]:
+    env, path = _load_mcp_env()
+    session_policy = _session_policy_copy()
+    effective: dict[str, str] = {}
+    for key in supported_policy_keys():
+        value = dynamic_getenv(key, "")
+        if value not in (None, ""):
+            effective[key] = value
+    return {
+        "mcp_config_path": path,
+        "session_policy": _redact_env(session_policy),
+        "mcp_env": _redact_env(env),
+        "defaults": {**_FULL_PERMISSION_DEFAULTS, **_ENFORCEMENT_DEFAULTS},
+        "effective_policy": _redact_env(effective),
+        "supported_keys": supported_policy_keys(),
+        "presets": sorted(_PRESET_POLICIES),
+    }
diff --git a/src/iotdb_mcp_server/server.py b/src/iotdb_mcp_server/server.py
index 489687e..b0935e9 100644
--- a/src/iotdb_mcp_server/server.py
+++ b/src/iotdb_mcp_server/server.py
@@ -15,566 +15,59 @@
 # specific language governing permissions and limitations
 # under the License.
 #
-# Security patch for CVE-2026-XXXXX (path traversal vulnerability leading to RCE)
-# Author: Mohammed Tanveer (threatpointer)
-# Date: 2026-01-12
-#
 
 import logging
-import datetime
-import asyncio
-import os
-import uuid
-import re
-import pandas as pd
-from typing import Dict, Any, List, Union
 
-from iotdb.Session import Session
-from iotdb.SessionPool import SessionPool, PoolConfig
-from iotdb.utils.SessionDataSet import SessionDataSet
-from iotdb.table_session import TableSession
-from iotdb.table_session_pool import TableSessionPool, TableSessionPoolConfig
 from fastmcp import FastMCP
-from mcp.types import TextContent
 
 from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.services.database import register_database_tools
+from iotdb_mcp_server.services.explain import register_explain_tools
+from iotdb_mcp_server.services.metadata import register_metadata_tools
+from iotdb_mcp_server.services.model import register_model_tools
+from iotdb_mcp_server.services.query import register_query_tools
+from iotdb_mcp_server.services.results import register_result_store_tools
+from iotdb_mcp_server.services.runtime_policy import register_runtime_policy_tools
+from iotdb_mcp_server.services.sql_driver import register_sql_driver_tools
+from iotdb_mcp_server.services.table import register_table_tools
+from iotdb_mcp_server.services.targets import register_target_tools
+from iotdb_mcp_server.services.timeseries import register_timeseries_tools
+from iotdb_mcp_server.services.ttl import register_ttl_tools
+from iotdb_mcp_server.services.udf import register_udf_tools
+from iotdb_mcp_server.services.write import register_write_tools
 
-# Initialize FastMCP server
 mcp = FastMCP("iotdb_mcp_server")
 
-# Configure logging
 logging.basicConfig(
     level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
 )
-
 logger = logging.getLogger("iotdb_mcp_server")
 
 config = Config.from_env_arguments()
 
-db_config = {
-    "host": config.host,
-    "port": config.port,
-    "user": config.user,
-    "password": config.password,
-    "database": config.database,
-    "sql_dialect": config.sql_dialect,
-    "export_path": config.export_path,
-}
-
-max_pool_size = 100  # Increased from 100 for better concurrency
-
-# Never print credentials: log a copy of the config with the password masked.
-logged_config = {**db_config, "password": "***"}
-logger.info(f"IoTDB Config: {logged_config}")
-
-# Ensure export directory exists
-if not os.path.exists(config.export_path):
-    try:
-        os.makedirs(config.export_path)
-        logger.info(f"Created export directory: {config.export_path}")
-    except Exception as e:
-        logger.warning(
-            f"Failed to create export directory {config.export_path}: {str(e)}"
-        )
-
-
-def sanitize_filename(filename: str, base_dir: str) -> str:
-    """
-    Sanitize and validate filename to prevent path traversal attacks.
-
-    Security patch for CVE-2026-XXXXX
-    Author: Mohammed Tanveer (threatpointer)
-    Date: 2026-01-12
-
-    Args:
-        filename: The user-provided filename
-        base_dir: The base directory for exports (must be absolute path)
-
-    Returns:
-        The sanitized absolute filepath
-
-    Raises:
-        ValueError: If the filename contains invalid characters or attempts path traversal
-
-    Security measures:
-    - Rejects any path separators or traversal sequences before processing
-    - Validates allowed characters (alphanumeric, underscore, hyphen, dot)
-    - Resolves absolute path and verifies it stays within base_dir boundary
-    - Prevents directory traversal, symlink attacks, and path manipulation
-    """
-    if not filename:
-        raise ValueError("Filename cannot be empty")
-
-    # First, reject any path separators or traversal patterns before processing
-    # This catches attacks before os.path.basename can process them
-    if "/" in filename or "\\" in filename or ".." in filename:
-        raise ValueError(
-            "Invalid filename: path separators and directory traversal sequences are not allowed"
-        )
-
-    # Remove any directory components - only keep the base filename
-    # (This is now a safety check since we already blocked separators)
-    filename = os.path.basename(filename)
-
-    # Validate characters - only allow alphanumeric, underscore, hyphen, and dot
-    if not re.match(r"^[a-zA-Z0-9_\-\.]+$", filename):
-        raise ValueError(
-            "Invalid filename: only alphanumeric characters, underscore, hyphen, and dot are allowed"
-        )
-
-    # Prevent filenames that are just dots or empty after sanitization
-    if not filename or filename in (".", ".."):
-        raise ValueError("Invalid filename")
-
-    # Prevent filenames that start with a dot followed by a dot (like ..something)
-    if filename.startswith(".."):
-        raise ValueError("Invalid filename: cannot start with '..'")
-
-    # Construct the full path
-    filepath = os.path.join(base_dir, filename)
-
-    # Resolve to absolute path (resolves symlinks and normalizes path)
-    filepath_real = os.path.realpath(filepath)
-    basedir_real = os.path.realpath(base_dir)
-
-    # Ensure the resolved path is within the base directory boundary
-    # This prevents path traversal even with symlinks or complex path manipulation
-    if (
-        not filepath_real.startswith(basedir_real + os.sep)
-        and filepath_real != basedir_real
-    ):
-        raise ValueError(
-            f"Path traversal detected: file must be within export directory"
-        )
-
-    return filepath_real
-
-
-if config.sql_dialect == "tree":
-    # Configure connection pool with optimized settings
-    pool_config = PoolConfig(
-        node_urls=[str(config.host) + ":" + str(config.port)],
-        user_name=config.user,
-        password=config.password,
-        fetch_size=1024,  # Fetch size for queries
-        time_zone="UTC+8",  # Consistent timezone
-        max_retry=3,  # Connection retry attempts
-    )
-    # Optimize pool size based on expected concurrent queries
-    wait_timeout_in_ms = 5000  # Increased from 3000 for better reliability
-    session_pool = SessionPool(pool_config, max_pool_size, wait_timeout_in_ms)
-
-    @mcp.tool()
-    async def metadata_query(query_sql: str) -> list[TextContent]:
-        """Execute metadata queries on IoTDB to explore database structure and statistics.
-
-        Args:
-            query_sql: The metadata query to execute. Supported queries:
-                - SHOW DATABASES [path]: List all databases or databases under a specific path
-                - SHOW TIMESERIES [path]: List all time series or time series under a specific path
-                - SHOW CHILD PATHS [path]: List child paths under a specific path
-                - SHOW CHILD NODES [path]: List child nodes under a specific path
-                - SHOW DEVICES [path]: List all devices or devices under a specific path
-                - COUNT TIMESERIES [path]: Count time series under a specific path
-                - COUNT NODES [path]: Count nodes under a specific path
-                - COUNT DEVICES [path]: Count devices under a specific path
-                - if path is not provided, the query will be applied to root.**
-
-        Examples:
-            SHOW DATABASES root.**
-            SHOW TIMESERIES root.ln.**
-            SHOW CHILD PATHS root.ln
-            SHOW CHILD PATHS root.ln.*.*
-            SHOW CHILD NODES root.ln
-            SHOW DEVICES root.ln.**
-            COUNT TIMESERIES root.ln.**
-            COUNT NODES root.ln
-            COUNT DEVICES root.ln
-        """
-        session = None
-        try:
-            session = session_pool.get_session()
-            stmt = query_sql.strip().upper()
-
-            # Process SHOW DATABASES
-            if (
-                stmt.startswith("SHOW DATABASES")
-                or stmt.startswith("SHOW TIMESERIES")
-                or stmt.startswith("SHOW CHILD PATHS")
-                or stmt.startswith("SHOW CHILD NODES")
-                or stmt.startswith("SHOW DEVICES")
-                or stmt.startswith("COUNT TIMESERIES")
-                or stmt.startswith("COUNT NODES")
-                or stmt.startswith("COUNT DEVICES")
-            ):
-                res = session.execute_query_statement(query_sql)
-                return prepare_res(res, session)
-            else:
-                session.close()
-                raise ValueError(
-                    "Unsupported metadata query. Please use one of the supported query types."
-                )
-        except Exception as e:
-            if session:
-                session.close()
-            logger.error(f"Failed to execute metadata query: {str(e)}")
-            raise
-
-    @mcp.tool()
-    async def select_query(query_sql: str) -> list[TextContent]:
-        """Execute a SELECT query on the IoTDB tree SQL dialect.
-
-        Args:
-            query_sql: The SQL query to execute (using TREE dialect, time using ISO 8601 format, e.g. 2017-11-01T00:08:00.000).
-
-        SQL Syntax:
-            SELECT [LAST] selectExpr [, selectExpr] ...
-                [INTO intoItem [, intoItem] ...]
-                FROM prefixPath [, prefixPath] ...
-                [WHERE whereCondition]
-                [GROUP BY {
-                    ([startTime, endTime), interval [, slidingStep]) |
-                    LEVEL = levelNum [, levelNum] ... |
-                    TAGS(tagKey [, tagKey] ... |
-                    VARIATION(expression[,delta][,ignoreNull=true/false]) |
-                    CONDITION(expression,[keep>/>=/=/</<=]threshold[,ignoreNull=true/false]) |
-                    SESSION(timeInterval) |
-                    COUNT(expression, size[,ignoreNull=true/false])
-                }]
-                [HAVING havingCondition]
-                [ORDER BY sortKey {ASC | DESC}]
-                [FILL ({PREVIOUS | LINEAR | constant}) (, interval=DURATION_LITERAL)?)]
-                [SLIMIT seriesLimit] [SOFFSET seriesOffset]
-                [LIMIT rowLimit] [OFFSET rowOffset]
-                [ALIGN BY {TIME | DEVICE}]
-
-        Examples:
-            select temperature from root.ln.wf01.wt01 where time < 2017-11-01T00:08:00.000
-            select status, temperature from root.ln.wf01.wt01 where (time > 2017-11-01T00:05:00.000 and time < 2017-11-01T00:12:00.000) or (time >= 2017-11-01T16:35:00.000 and time <= 2017-11-01T16:37:00.000)
-            select * from root.ln.** where time > 1 order by time desc limit 10;
-
-        Supported Aggregate Functions:
-            SUM
-            COUNT
-            MAX_VALUE
-            MIN_VALUE
-            AVG
-            VARIANCE
-            MAX_TIME
-            MIN_TIME
-            ...
-        """
-        session = None
-        try:
-            session = session_pool.get_session()
-            stmt = query_sql.strip().upper()
-
-            # Regular SELECT queries
-            if stmt.startswith("SELECT"):
-                res = session.execute_query_statement(query_sql)
-                return prepare_res(res, session)
-            else:
-                session.close()
-                raise ValueError("Only SELECT queries are allowed for select_query")
-        except Exception as e:
-            if session:
-                session.close()
-            logger.error(f"Failed to execute select query: {str(e)}")
-            raise
-
-    @mcp.tool()
-    async def export_query(
-        query_sql: str, format: str = "csv", filename: str = None
-    ) -> list[TextContent]:
-        """Execute a query and export the results to a CSV or Excel file.
-
-        Args:
-            query_sql: The SQL query to execute (using TREE dialect, time using ISO 8601 format, e.g. 2017-11-01T00:08:00.000)
-            format: Export format, either "csv" or "excel" (default: "csv")
-            filename: Optional filename for the exported file. If not provided, a unique filename will be generated.
-
-        SQL Syntax:
-            SELECT ⟨select_list⟩
-              FROM ⟨tables⟩
-              [WHERE ⟨condition⟩]
-              [GROUP BY ⟨groups⟩]
-              [HAVING ⟨group_filter⟩]
-              [FILL ⟨fill_methods⟩]
-              [ORDER BY ⟨order_expression⟩]
-              [OFFSET ⟨n⟩]
-              [LIMIT ⟨n⟩];
-
-        Returns:
-            Information about the exported file and a preview of the data (max 10 rows)
-        """
-        session = None
-        try:
-            session = session_pool.get_session()
-            stmt = query_sql.strip().upper()
-
-            if stmt.startswith("SELECT") or stmt.startswith("SHOW"):
-                # Execute the query
-                res = session.execute_query_statement(query_sql)
-
-                # Create a pandas DataFrame
-                df = res.todf()
-                # Close the session
-                session.close()
-
-                # Generate unique filename with timestamp
-                timestamp = int(datetime.datetime.now().timestamp())
-                if filename is None:
-                    # Generate a unique filename if not provided
-                    filename = f"dump_{uuid.uuid4().hex[:4]}_{timestamp}"
-
-                if format.lower() == "csv":
-                    if filename.lower().endswith(".csv"):
-                        filename = filename[:-4]
-                    # Sanitize filename to prevent path traversal attacks
-                    filepath = sanitize_filename(f"{filename}.csv", config.export_path)
-                    df.to_csv(filepath, index=False)
-                elif format.lower() == "excel":
-                    if filename.lower().endswith(".xlsx"):
-                        filename = filename[:-5]
-                    # Sanitize filename to prevent path traversal attacks
-                    filepath = sanitize_filename(f"{filename}.xlsx", config.export_path)
-                    df.to_excel(filepath, index=False)
-                else:
-                    raise ValueError("Format must be either 'csv' or 'excel'")
-
-                # Generate preview (first 10 rows)
-                preview_rows = min(10, len(df))
-                preview_data = []
-                preview_data.append(",".join(df.columns))  # Header
-
-                for i in range(preview_rows):
-                    preview_data.append(",".join(map(str, df.iloc[i])))
-
-                # Return information
-                return [
-                    TextContent(
-                        type="text",
-                        text=f"Query results exported to {filepath}\n\nPreview (first {preview_rows} rows):\n"
-                        + "\n".join(preview_data),
-                    )
-                ]
-            else:
-                raise ValueError("Only SELECT or SHOW queries are allowed for export")
-        except Exception as e:
-            if session:
-                session.close()
-            logger.error(f"Failed to export query: {str(e)}")
-            raise
-
-    def prepare_res(_res: SessionDataSet, _session: Session) -> list[TextContent]:
-        columns = _res.get_column_names()
-        result = []
-        while _res.has_next():
-            record = _res.next()
-            if columns[0] == "Time":
-                timestamp = record.get_timestamp()
-                # formatted_time = datetime.datetime.fromtimestamp(timestamp/1000).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3]
-                row = record.get_fields()
-                result.append(str(timestamp) + "," + ",".join(map(str, row)))
-            else:
-                row = record.get_fields()
-                result.append(",".join(map(str, row)))
-        _session.close()
-        return [
-            TextContent(
-                type="text",
-                text="\n".join([",".join(columns)] + result),
-            )
-        ]
-
-elif config.sql_dialect == "table":
-    session_pool_config = TableSessionPoolConfig(
-        node_urls=[str(config.host) + ":" + str(config.port)],
-        username=config.user,
-        password=config.password,
-        max_pool_size=max_pool_size,  # Increased from 5 for better concurrency
-        database=None if len(config.database) == 0 else config.database,
-    )
-    session_pool = TableSessionPool(session_pool_config)
-
-    @mcp.tool()
-    async def read_query(query_sql: str) -> list[TextContent]:
-        """Execute a SELECT query on the IoTDB. Please use table sql_dialect when generating SQL queries.
-
-        Args:
-            query_sql: The SQL query to execute (using TABLE dialect, time using ISO 8601 format, e.g. 2017-11-01T00:08:00.000)
-        """
-        table_session = None
-        try:
-            table_session = session_pool.get_session()
-            stmt = query_sql.strip().upper()
-
-            # Regular SELECT queries
-            if (
-                stmt.startswith("SELECT")
-                or stmt.startswith("DESCRIBE")
-                or stmt.startswith("SHOW")
-            ):
-                res = table_session.execute_query_statement(query_sql)
-                return prepare_res(res, table_session)
-            else:
-                table_session.close()
-                raise ValueError("Only SELECT queries are allowed for read_query")
-        except Exception as e:
-            if table_session:
-                table_session.close()
-            logger.error(f"Failed to execute query: {str(e)}")
-            raise
-
-    @mcp.tool()
-    async def list_tables() -> list[TextContent]:
-        """List all tables in the IoTDB database."""
-        table_session = None
-        try:
-            table_session = session_pool.get_session()
-            res = table_session.execute_query_statement("SHOW TABLES")
-
-            result = ["Tables_in_" + db_config["database"]]  # Header
-            while res.has_next():
-                result.append(str(res.next().get_fields()[0]))
-            table_session.close()
-            return [TextContent(type="text", text="\n".join(result))]
-        except Exception as e:
-            if table_session:
-                table_session.close()
-            logger.error(f"Failed to list tables: {str(e)}")
-            raise
-
-    @mcp.tool()
-    async def describe_table(table_name: str) -> list[TextContent]:
-        """Get the schema information for a specific table
-        Args:
-            table_name: name of the table to describe
-        """
-        table_session = None
-        try:
-            table_session = session_pool.get_session()
-            res = table_session.execute_query_statement(
-                "DESC " + table_name + " details"
-            )
-            return prepare_res(res, table_session)
-        except Exception as e:
-            if table_session:
-                table_session.close()
-            logger.error(f"Failed to describe table {table_name}: {str(e)}")
-            raise
-
-    @mcp.tool()
-    async def export_table_query(
-        query_sql: str, format: str = "csv", filename: str = None
-    ) -> list[TextContent]:
-        """Execute a query and export the results to a CSV or Excel file.
-
-        Args:
-            query_sql: The SQL query to execute (using TABLE dialect, time using ISO 8601 format, e.g. 2017-11-01T00:08:00.000)
-            format: Export format, either "csv" or "excel" (default: "csv")
-            filename: Optional filename for the exported file. If not provided, a unique filename will be generated.
-
-        SQL Syntax:
-            SELECT ⟨select_list⟩
-              FROM ⟨tables⟩
-              [WHERE ⟨condition⟩]
-              [GROUP BY ⟨groups⟩]
-              [HAVING ⟨group_filter⟩]
-              [FILL ⟨fill_methods⟩]
-              [ORDER BY ⟨order_expression⟩]
-              [OFFSET ⟨n⟩]
-              [LIMIT ⟨n⟩];
-
-        Returns:
-            Information about the exported file and a preview of the data (max 10 rows)
-        """
-        table_session = None
-        try:
-            table_session = session_pool.get_session()
-            stmt = query_sql.strip().upper()
-
-            if (
-                stmt.startswith("SELECT")
-                or stmt.startswith("SHOW")
-                or stmt.startswith("DESCRIBE")
-                or stmt.startswith("DESC")
-            ):
-                # Execute the query
-                res = table_session.execute_query_statement(query_sql)
-
-                # Create a pandas DataFrame
-                df = res.todf()
-
-                # Close the session
-                table_session.close()
-
-                # Generate unique filename with timestamp
-                timestamp = int(datetime.datetime.now().timestamp())
-                if filename is None:
-                    filename = f"dump_{uuid.uuid4().hex[:4]}_{timestamp}"
-
-                if format.lower() == "csv":
-                    if filename.lower().endswith(".csv"):
-                        filename = filename[:-4]
-                    # Sanitize filename to prevent path traversal attacks
-                    filepath = sanitize_filename(f"{filename}.csv", config.export_path)
-                    df.to_csv(filepath, index=False)
-                elif format.lower() == "excel":
-                    if filename.lower().endswith(".xlsx"):
-                        filename = filename[:-5]
-                    # Sanitize filename to prevent path traversal attacks
-                    filepath = sanitize_filename(f"{filename}.xlsx", config.export_path)
-                    df.to_excel(filepath, index=False)
-                else:
-                    raise ValueError("Format must be either 'csv' or 'excel'")
-
-                # Generate preview (first 10 rows)
-                preview_rows = min(10, len(df))
-                preview_data = []
-                preview_data.append(",".join(df.columns))  # Header
-
-                for i in range(preview_rows):
-                    preview_data.append(",".join(map(str, df.iloc[i])))
-
-                # Return information
-                return [
-                    TextContent(
-                        type="text",
-                        text=f"Query results exported to {filepath}\n\nPreview (first {preview_rows} rows):\n"
-                        + "\n".join(preview_data),
-                    )
-                ]
-            else:
-                raise ValueError(
-                    "Only SELECT, SHOW or DESCRIBE queries are allowed for export"
-                )
-        except Exception as e:
-            if table_session:
-                table_session.close()
-            logger.error(f"Failed to export table query: {str(e)}")
-            raise
-
-    def prepare_res(
-        _res: SessionDataSet, _table_session: TableSession
-    ) -> list[TextContent]:
-        columns = _res.get_column_names()
-        result = []
-        while _res.has_next():
-            row = _res.next().get_fields()
-            result.append(",".join(map(str, row)))
-        _table_session.close()
-        return [
-            TextContent(
-                type="text",
-                text="\n".join([",".join(columns)] + result),
-            )
-        ]
-
+logger.info(
+    "IoTDB Config: %s",
+    config.safe_dict(),
+)
 
-def main():
+register_target_tools(mcp, config, logger)
+register_runtime_policy_tools(mcp, config, logger)
+register_result_store_tools(mcp, config, logger)
+register_query_tools(mcp, config, logger)
+register_sql_driver_tools(mcp, config, logger)
+register_metadata_tools(mcp, config, logger)
+register_explain_tools(mcp, config, logger)
+register_database_tools(mcp, config, logger)
+register_timeseries_tools(mcp, config, logger)
+register_ttl_tools(mcp, config, logger)
+register_table_tools(mcp, config, logger)
+register_udf_tools(mcp, config, logger)
+register_write_tools(mcp, config, logger)
+register_model_tools(mcp, config, logger)
+
+
+def main() -> None:
     logger.info("iotdb_mcp_server running with stdio transport")
-    # Initialize and run the server
     mcp.run(transport="stdio")
 
 
diff --git a/src/iotdb_mcp_server/services/README.md b/src/iotdb_mcp_server/services/README.md
new file mode 100644
index 0000000..27d1dad
--- /dev/null
+++ b/src/iotdb_mcp_server/services/README.md
@@ -0,0 +1,394 @@
+# IoTDB MCP Services
+
+This directory contains tool modules registered by `src/iotdb_mcp_server/server.py`.
+
+## Registration Overview
+
+Current registration order:
+
+1. `register_target_tools`
+2. `register_runtime_policy_tools`
+3. `register_result_store_tools`
+4. `register_query_tools`
+5. `register_sql_driver_tools`
+6. `register_metadata_tools`
+7. `register_explain_tools`
+8. `register_database_tools`
+9. `register_timeseries_tools`
+10. `register_ttl_tools`
+11. `register_table_tools`
+12. `register_udf_tools`
+13. `register_write_tools`
+14. `register_model_tools`
+
+## Tool Matrix
+
+### `query.py`
+
+- Tree dialect:
+  - `select_query(query_sql, max_inline_rows=None, page_size_rows=None)`
+  - `export_query(query_sql, format="csv", filename=None)`
+- Table dialect:
+  - `read_query(query_sql, max_inline_rows=None, page_size_rows=None)`
+  - `export_table_query(query_sql, format="csv", filename=None)`
+
+Read-query tools stream rows into ResultStore and return only a preview by
+default. Use the returned `result_id` and `next_cursor` with
+`read_result_page`.
+
+### `results.py`
+
+- `read_result_page(result_id, cursor=None, offset=None, limit=None, owner_session_id=None, target_id=None)`
+- `read_result_pages(pages, owner_session_id=None, default_limit=None, max_pages=None, max_total_rows=None, continue_on_error=True, target_id=None)`
+- `list_result_store(owner_session_id=None, all_sessions=False, limit=50, target_id=None)`
+- `cleanup_result_store(owner_session_id=None, all_sessions=False, ttl_seconds=None, target_id=None)`
+- `delete_result(result_id, owner_session_id=None, all_sessions=False, target_id=None)`
+
+Loads a bounded page from a file-backed ResultStore result. Pass the same
+`target_id` as the original query when multiple IoTDB targets are registered.
+When the TimeSeek Codex plugin hook is active, `owner_session_id` is injected
+from the Codex session. Standalone MCP usage can pass `owner_session_id`
+explicitly or set `TIMESEEK_MCP_SESSION_ID`; otherwise the ResultStore owner is
+`standalone`.
+
+ResultStore cleanup is finite and session-aware. Each write runs lightweight
+cleanup for the current owner and then enforces global limits. Entries expire by
+`last_accessed_at`, and quota pressure evicts least-recently-accessed results.
+If a single new result is larger than the byte quota, it is retained so the
+caller can still page through the result that was just returned.
+
+`read_result_pages` batches multiple page requests in one MCP call. Each item
+accepts `result_id` plus optional `cursor`, `offset`, `limit`, and
+`owner_session_id`. The default per-call limits are 32 pages and 10000 returned
+rows; override them with tool arguments or `TIMESEEK_RESULT_MAX_BATCH_PAGES`
+and `TIMESEEK_RESULT_MAX_BATCH_READ_ROWS`.
+
+### `sql_driver.py`
+
+- Both dialects:
+  - `sql_driver_policy()`
+  - `inspect_sql_permission(sql)`
+  - `sql_executor_batch(sqls=None, sql_template=None, param_sets=None, max_concurrency=4, worker_pool_size=None, per_item_timeout_ms=None, batch_timeout_ms=None, max_result_rows_per_item=None, max_result_bytes_per_item=None, max_batch_result_rows=None, max_batch_result_bytes=None, continue_on_error=True, max_inline_rows=5, page_size_rows=None)`
+  - `sql_execute(sql, confirm_destructive=False, max_inline_rows=None, page_size_rows=None)`
+
+Features:
+
+- Statement whitelist by category: `readonly` / `ddl` / `full`
+- Advisory permission metadata via `IOTDB_SQL_DRIVER_MODE`
+- SQL inspection returns `required_permission`, `destructive`, `risk_level`,
+  `approval_required`, and the confirmation parameter without executing SQL
+- Destructive SQL confirmation policy
+- `sql_executor_batch` executes only readonly single statements. It accepts
+  either explicit `sqls=[...]` or a repeated `sql_template` plus `param_sets`.
+  Template placeholders are `{{name}}` for SQL literals, `{{name:path}}` for
+  IoTDB paths, and `{{name:identifier}}` for SQL identifiers.
+- Batch results are per-statement ResultStore entries. The batch response
+  returns status, `result_id`, row counts, a small preview, and paging metadata;
+  use `read_result_page` to fetch full result pages.
+- Batch execution controls include a bounded thread worker pool, per-item and
+  whole-batch wait timeouts, and per-item / whole-batch result row and byte
+  quotas. Defaults: 4 concurrency, 60000 ms per item, 300000 ms per batch,
+  10000 rows and 16 MiB per item, 100000 rows and 64 MiB per batch.
+- Tree CQ enabled in whitelist:
+  - Readonly: `SHOW CONTINUOUS QUERIES`, `SHOW CQS`
+  - DDL: `CREATE CONTINUOUS QUERY` / `CREATE CQ`, `DROP CONTINUOUS QUERY` / `DROP CQ`
+- Downsampling execution path:
+  - ad-hoc: `SELECT ... GROUP BY ([start, end), interval[, slidingStep])`
+  - scheduled: CQ with `INTO` + optional `RESAMPLE` clause
+
+### `runtime_policy.py`
+
+- Session scoped permission controls:
+  - `get_iotdb_session_policy()`
+  - `set_iotdb_session_policy(policy=None, preset=None, replace=False)`
+  - `reset_iotdb_session_policy(keys=None)`
+- Presets:
+  - `full`: advisory signal for read, DDL, and DML-capable workflows
+  - `ddl`: advisory signal for read and DDL-capable workflows
+  - `readonly`: advisory signal for read-only workflows
+  - In `strict` mode only, these presets become hard execution gates
+- Effective policy priority:
+  - session overlay set by MCP tool
+  - dynamically read `.mcp.json`
+  - process environment
+  - built-in full-permission defaults
+
+### `metadata.py`
+
+- Tree dialect:
+  - `metadata_query(query_sql)`
+  - `list_timeseries(path="root.**")`
+  - `list_devices(path="root.**")`
+  - `list_child_paths(path)`
+  - `list_child_nodes(path)`
+  - `count_timeseries(path="root.**")`
+  - `count_devices(path="root.**")`
+  - `count_nodes(path="root")`
+- Table dialect:
+  - `metadata_query(query_sql)`
+  - `list_tables()`
+  - `describe_table(table_name, details=True)`
+
+Metadata result sets also use ResultStore, which keeps large `SHOW TIMESERIES`
+or `SHOW DEVICES` responses bounded.
+
+### `explain.py`
+
+- Both dialects:
+  - `explain_query(query_sql, analyze=False)`
+
+### `database.py`
+
+- Tree dialect:
+  - `list_databases(details=False)`
+  - `create_database(database)`
+  - `drop_database(database, confirm=False)`
+- Table dialect:
+  - `list_databases(details=False)`
+  - `create_database(database, if_not_exists=True)`
+  - `drop_database(database, if_exists=True, confirm=False)`
+  - `use_database(database)`
+
+### `timeseries.py` (tree only)
+
+- `create_timeseries_ddl(ddl_sql)`
+- `create_timeseries_batch_ddl(ddl_list, continue_on_error=True, skip_if_exists=True, max_statements=1000)`
+- `alter_timeseries_ddl(ddl_sql)`
+- `drop_timeseries_ddl(ddl_sql, confirm=False)`
+
+### `ttl.py` (tree only)
+
+- `ttl_command(ttl_sql, confirm_unset=False)`
+- `ttl_query(ttl_sql)`
+
+### `table.py` (table only)
+
+- `create_table_ddl(ddl_sql)`
+- `alter_table_ddl(ddl_sql)`
+- `drop_table_ddl(ddl_sql, confirm=False)`
+
+### `udf.py`
+
+- Both dialects:
+  - `list_udf_functions()`
+  - `prepare_udf_query(function_name, expressions, table_name=None, from_path=None, attributes=None, where_clause=None, limit=None, alias=None, align_by_device=False)`
+  - `execute_udf_query(function_name, expressions, table_name=None, from_path=None, attributes=None, where_clause=None, limit=None, alias=None, align_by_device=False, max_inline_rows=None, page_size_rows=None)`
+  - `export_udf_query(function_name, expressions, table_name=None, from_path=None, attributes=None, where_clause=None, limit=None, alias=None, align_by_device=False, format="csv", filename=None)`
+
+Features:
+
+- `SHOW FUNCTIONS` discovery through `list_udf_functions`
+- Conservative SQL generation for UDF calls instead of accepting raw SQL
+- Table dialect shape: `SELECT UDF(column, "k"="v") FROM table ...`
+- Tree dialect shape: `SELECT UDF(measurement, "k"="v") FROM root.sg.d1 ...`
+- Read-only execution only; DDL/DML keywords, semicolons, and comments are rejected in expressions and `where_clause`
+- ResultStore-backed previews for inline execution and export support for large UDF outputs
+
+### `write.py`
+
+- Tree dialect:
+  - `write_query(write_sql, confirm_delete=False)` (INSERT/DELETE)
+- Table dialect:
+  - `write_query(write_sql, confirm_delete=False)` (INSERT/UPDATE/DELETE)
+
+### `model.py`
+
+- Both dialects:
+  - `model_query(model_sql)`
+  - `model_command(model_sql, confirm_destructive=False)`
+- Tree dialect:
+  - `prepare_model_inference_request(model_id, input_sql, output_length=96, generate_time=False)`
+  - `model_inference(inference_sql)`
+
+### `json_response.py`
+
+- Shared response wrapper and parser:
+  - `JsonParser.check_format(obj)`
+  - `JsonParser.parse(text)`
+  - `payload_response(tool, payload, message=None)`
+  - `text_payload_response(tool, text, message=None)`
+  - `csv_payload_response(tool, columns, rows, message=None)`
+  - `sql_success_response(tool, sql)`
+
+## Tool Response JSON Envelope
+
+All tools now return `TextContent.text` as a JSON envelope instead of plain text.
+
+Envelope format:
+
+```json
+{
+  "version": "1.0",
+  "tool": "tool_name",
+  "ok": true,
+  "timestamp": "2026-02-26T00:00:00+00:00",
+  "payload": {},
+  "message": "optional"
+}
+```
+
+Payload conventions:
+
+- Query-like tools: `payload.format="csv"` with `columns`, preview `rows`,
+  preview `text`, `row_count`, `inline_row_count`, `inline_truncated`, and
+  ResultStore fields when persisted
+- Text-like tools: `payload.format="text"` with `text`
+- SQL success tools: `payload.sql` + `payload.result="success"`
+
+For ResultStore-backed responses:
+
+```json
+{
+  "format": "csv",
+  "columns": ["Time", "root.sg.d.s0"],
+  "rows": ["0,1.0"],
+  "text": "Time,root.sg.d.s0\n0,1.0",
+  "row_count": 100000,
+  "inline_row_count": 1,
+  "inline_truncated": true,
+  "result_id": "res_20260704T000000Z_0123456789abcdef",
+  "owner_session_id": "ses_abc123",
+  "next_cursor": "1",
+  "result_store": {
+    "type": "file",
+    "page_tool": "read_result_page",
+    "batch_page_tool": "read_result_pages",
+    "owner_session_id": "ses_abc123",
+    "page_size_rows": 500,
+    "shard_count": 10
+  }
+}
+```
+
+Environment knobs:
+
+- `TIMESEEK_RESULT_PREVIEW_ROWS` default `50`
+- `TIMESEEK_RESULT_PAGE_SIZE_ROWS` default `500`
+- `TIMESEEK_RESULT_MAX_PAGE_ROWS` default `5000`
+- `TIMESEEK_RESULT_SHARD_ROWS` default `10000`
+- `TIMESEEK_RESULT_SHARD_BYTES` default `8388608`
+- `TIMESEEK_RESULT_TTL_SECONDS` default `86400`; set `0` to disable TTL expiry
+- `TIMESEEK_RESULT_MAX_SESSION_RESULTS` default `100`; set `0` to disable
+- `TIMESEEK_RESULT_MAX_SESSION_BYTES` default `536870912`; set `0` to disable
+- `TIMESEEK_RESULT_MAX_GLOBAL_RESULTS` default `1000`; set `0` to disable
+- `TIMESEEK_RESULT_MAX_GLOBAL_BYTES` default `2147483648`; set `0` to disable
+- `TIMESEEK_MCP_SESSION_ID` optional standalone ResultStore owner fallback
+- `IOTDB_SQL_EXECUTOR_BATCH_MAX_STATEMENTS` default `64`
+- `IOTDB_SQL_EXECUTOR_BATCH_MAX_CONCURRENCY` default `16`
+
+Parser check:
+
+- Wrapper output is validated by `JsonParser.check_format(...)`
+- Serialized text is parsed again by `JsonParser.parse(...)` before returning
+- Invalid envelope shape raises `ValueError`
+
+## Security and Permission Model
+
+IoTDB MCP permissions default to host-agent approval flow. The MCP server reports
+required permission and risk; Codex, Claude Code, or OpenCode owns user approval
+through its normal question/approval mechanism. This keeps MCP policy aligned
+with the host agent system instead of turning `.mcp.json` into a hard blocker.
+
+Default mode is `advisory`. In advisory mode:
+
+- `IOTDB_SQL_DRIVER_MODE` is a prompt-layer signal, not an execution blocker.
+- `IOTDB_ENABLE_*` and `*_ALLOWED_USERS` are prompt-layer/session settings, not
+  hard gates.
+- Destructive operations still require explicit tool confirmation flags, such
+  as `confirm_destructive=true`, after the host agent has obtained approval.
+
+Strict hard gating is available only when explicitly enabled:
+
+```bash
+TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict
+# or
+IOTDB_STRICT_PERMISSION_ENFORCEMENT=true
+```
+
+Policy can be changed at runtime without restarting the MCP server:
+
+```json
+{
+  "preset": "readonly",
+  "replace": true
+}
+```
+
+or:
+
+```json
+{
+  "policy": {
+    "IOTDB_SQL_DRIVER_MODE": "full",
+    "IOTDB_ENABLE_WRITE_DML": "true"
+  }
+}
+```
+
+### Per-module advisory keys
+
+- `database.py`
+  - `IOTDB_ENABLE_DATABASE_DDL`
+  - `IOTDB_DATABASE_DDL_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_DROP_CONFIRM`
+- `timeseries.py`
+  - `IOTDB_ENABLE_TIMESERIES_DDL`
+  - `IOTDB_TIMESERIES_DDL_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM`
+- `table.py`
+  - `IOTDB_ENABLE_TABLE_DDL`
+  - `IOTDB_TABLE_DDL_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_TABLE_DROP_CONFIRM`
+- `ttl.py`
+  - `IOTDB_ENABLE_TTL_SQL`
+  - `IOTDB_TTL_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_TTL_UNSET_CONFIRM`
+- `write.py`
+  - `IOTDB_ENABLE_WRITE_DML`
+  - `IOTDB_WRITE_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_DELETE_CONFIRM`
+- `model.py`
+  - `IOTDB_ENABLE_MODEL_MANAGEMENT`
+  - `IOTDB_MODEL_ALLOWED_USERS`
+  - `IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM`
+  - `model_inference` is registered only for tree dialect and accepts
+    `CALL INFERENCE(...)` SQL.
+- `metadata.py`
+  - `IOTDB_ENABLE_METADATA_QUERY`
+  - `IOTDB_METADATA_ALLOWED_USERS`
+- `sql_driver.py`
+  - `IOTDB_ENABLE_SQL_DRIVER`
+  - `IOTDB_SQL_DRIVER_ALLOWED_USERS`
+  - `IOTDB_SQL_DRIVER_MODE` (`readonly` / `ddl` / `full`)
+  - `IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM`
+  - `IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES`
+  - `IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES`
+  - `IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES`
+
+## Minimal Enablement Example
+
+```bash
+# Dialect
+IOTDB_SQL_DIALECT=tree
+
+# Keep generic driver available in full mode
+IOTDB_ENABLE_SQL_DRIVER=true
+IOTDB_SQL_DRIVER_MODE=full
+IOTDB_SQL_DRIVER_ALLOWED_USERS=*
+
+# Enable specialized tools
+IOTDB_ENABLE_METADATA_QUERY=true
+IOTDB_ENABLE_DATABASE_DDL=true
+IOTDB_ENABLE_TIMESERIES_DDL=true
+IOTDB_ENABLE_TTL_SQL=true
+IOTDB_ENABLE_WRITE_DML=true
+IOTDB_ENABLE_MODEL_MANAGEMENT=true
+```
+
+## Notes
+
+- All tools enforce single-statement execution (`;` separated multi-statement is rejected).
+- Tool availability depends on `sql_dialect` (`tree` or `table`).
+- For production hard-block behavior, set `TIMESEEK_MCP_PERMISSION_ENFORCEMENT=strict`.
+- Keep destructive confirm flags enabled unless an external approval layer
+  provides equivalent per-action confirmation.
diff --git a/src/iotdb_mcp_server/services/database.py b/src/iotdb_mcp_server/services/database.py
new file mode 100644
index 0000000..5578c1e
--- /dev/null
+++ b/src/iotdb_mcp_server/services/database.py
@@ -0,0 +1,348 @@
+#
+# 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.
+#
+
+import logging
+import re
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import (
+    csv_payload_response,
+    sql_success_response,
+)
+from iotdb_mcp_server.services.target_selection import (
+    apply_target_registry,
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+from iotdb_mcp_server.target_registry import merge_target
+
+_TABLE_DB_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+_TREE_DB_PATTERN = re.compile(r"^root(?:\.[A-Za-z_][A-Za-z0-9_]*)+$")
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_database_ddl_permission(
+    config: Config, action: str, confirm: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_DATABASE_DDL", False):
+            raise PermissionError(
+                "Database DDL tool is disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_DATABASE_DDL=true to enable."
+            )
+
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_DATABASE_DDL_ALLOWED_USERS", "root") or "root")
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_DATABASE_DDL_ALLOWED_USERS."
+            )
+
+    if action == "DROP" and _env_bool("IOTDB_REQUIRE_DROP_CONFIRM", True) and not confirm:
+        raise PermissionError(
+            "DROP DATABASE requires confirm=True by server policy."
+        )
+
+
+def _validate_database_name(sql_dialect: str, database: str) -> str:
+    raw = database.strip()
+    if not raw:
+        raise ValueError("Database name/path cannot be empty.")
+
+    if sql_dialect == "tree":
+        if not _TREE_DB_PATTERN.fullmatch(raw):
+            raise ValueError(
+                "Invalid tree database path. Expected pattern like root.sg or root.sg1.dev."
+            )
+    elif sql_dialect == "table":
+        if not _TABLE_DB_PATTERN.fullmatch(raw):
+            raise ValueError(
+                "Invalid table database name. Use letters, numbers, and underscore only."
+            )
+    else:
+        raise ValueError(
+            f"Unsupported sql_dialect '{sql_dialect}'. Expected 'tree' or 'table'."
+        )
+    return raw
+
+
+def _format_result(
+    res: SessionDataSet,
+    session_or_table_session: Session | TableSession,
+    tool_name: str,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+    rows: list[str] = []
+    while res.has_next():
+        row = res.next().get_fields()
+        rows.append(",".join(map(str, row)))
+    session_or_table_session.close()
+    return csv_payload_response(tool_name, columns, rows)
+
+
+def register_database_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register database management tools with per-call IoTDB target selection."""
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def list_databases(
+        details: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """List databases in the selected IoTDB target."""
+        selected_config = select_target_config(config, target_id=target_id, target=target)
+        with iotdb_target_response_context(selected_config):
+            sql = "SHOW DATABASES DETAILS" if details else "SHOW DATABASES"
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="list_databases",
+                )
+                session = None
+                try:
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(sql)
+                    return _format_result(res, session, "list_databases")
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to list databases: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="list_databases",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(sql)
+                return _format_result(res, table_session, "list_databases")
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to list databases: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def create_database(
+        database: str,
+        if_not_exists: bool = True,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Create a database/storage group in the selected IoTDB target."""
+        selected_config = select_target_config(config, target_id=target_id, target=target)
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="create_database",
+                )
+                session = None
+                try:
+                    _assert_database_ddl_permission(selected_config, action="CREATE")
+                    database_path = _validate_database_name(
+                        selected_config.sql_dialect, database
+                    )
+                    sql = f"CREATE DATABASE {database_path}"
+                    session = session_pool.get_session()
+                    session.execute_non_query_statement(sql)
+                    session.close()
+                    return sql_success_response("create_database", sql)
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to create database: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="create_database",
+            )
+            table_session = None
+            try:
+                _assert_database_ddl_permission(selected_config, action="CREATE")
+                database_name = _validate_database_name(
+                    selected_config.sql_dialect, database
+                )
+                sql = (
+                    f"CREATE DATABASE IF NOT EXISTS {database_name}"
+                    if if_not_exists
+                    else f"CREATE DATABASE {database_name}"
+                )
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("create_database", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to create database: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def drop_database(
+        database: str,
+        if_exists: bool = True,
+        confirm: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Drop a database/storage group in the selected IoTDB target."""
+        selected_config = select_target_config(config, target_id=target_id, target=target)
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    selected_config,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="drop_database",
+                )
+                session = None
+                try:
+                    _assert_database_ddl_permission(
+                        selected_config, action="DROP", confirm=confirm
+                    )
+                    database_path = _validate_database_name(
+                        selected_config.sql_dialect, database
+                    )
+                    sql = f"DROP DATABASE {database_path}"
+                    session = session_pool.get_session()
+                    session.execute_non_query_statement(sql)
+                    session.close()
+                    return sql_success_response("drop_database", sql)
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to drop database: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                selected_config,
+                max_pool_size=max_pool_size,
+                tool_name="drop_database",
+            )
+            table_session = None
+            try:
+                _assert_database_ddl_permission(
+                    selected_config, action="DROP", confirm=confirm
+                )
+                database_name = _validate_database_name(
+                    selected_config.sql_dialect, database
+                )
+                sql = (
+                    f"DROP DATABASE IF EXISTS {database_name}"
+                    if if_exists
+                    else f"DROP DATABASE {database_name}"
+                )
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("drop_database", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to drop database: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def use_database(
+        database: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Switch current database in a table-model IoTDB target."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            tool_name="use_database",
+        )
+        table_session = None
+        try:
+            database_name = _validate_database_name(
+                selected_config.sql_dialect, database
+            )
+            sql = f"USE {database_name}"
+            table_session = session_pool.get_session()
+            table_session.execute_non_query_statement(sql)
+            table_session.close()
+            table_session = None
+
+            updated_target = merge_target(
+                selected_config.to_target(),
+                {"database": database_name},
+            )
+            registry = config.target_registry or selected_config.target_registry
+            if registry is not None:
+                updated_registry = registry.with_target(updated_target)
+                apply_target_registry(
+                    config,
+                    updated_registry,
+                    active_target_id=config.target_id or updated_target.target_id,
+                    close_pools=False,
+                )
+                if config.session_manager is not None:
+                    config.session_manager.close_target_pools(updated_target.target_id)
+                updated_config = select_target_config(
+                    config,
+                    target_id=updated_target.target_id,
+                )
+            else:
+                selected_config.database = database_name
+                if config.target_id == selected_config.target_id:
+                    config.database = database_name
+                updated_config = selected_config
+
+            with iotdb_target_response_context(updated_config):
+                return sql_success_response("use_database", sql)
+        except Exception as e:
+            if table_session:
+                table_session.close()
+            logger.error(f"Failed to use database: {str(e)}")
+            raise
diff --git a/src/iotdb_mcp_server/services/explain.py b/src/iotdb_mcp_server/services/explain.py
new file mode 100644
index 0000000..3c67e1d
--- /dev/null
+++ b/src/iotdb_mcp_server/services/explain.py
@@ -0,0 +1,168 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+import logging
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.services.json_response import error_response, payload_response
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+from iotdb_mcp_server.services.tree_sql_guardrails import assert_tree_query_shape
+
+
+def _collect_result(
+    res: SessionDataSet, session_or_table_session: Session | TableSession
+) -> tuple[list[str], list[str]]:
+    columns = res.get_column_names()
+    rows: list[str] = []
+    while res.has_next():
+        row = res.next().get_fields()
+        rows.append(",".join(map(str, row)))
+    session_or_table_session.close()
+    return columns, rows
+
+
+def _normalize_and_validate_sql(
+    sql_dialect: str, sql: str, analyze: bool
+) -> tuple[str, str]:
+    raw_sql = sql.strip()
+    if not raw_sql:
+        raise ValueError("SQL cannot be empty")
+
+    explain_sql = raw_sql
+    upper = raw_sql.upper()
+
+    if not upper.startswith("EXPLAIN"):
+        explain_prefix = "EXPLAIN ANALYZE " if analyze else "EXPLAIN "
+        explain_sql = explain_prefix + raw_sql
+        target_sql = raw_sql.strip()
+    else:
+        # Accept user-provided EXPLAIN; do not override existing options.
+        target_sql = raw_sql[len("EXPLAIN") :].strip()
+        if target_sql.upper().startswith("ANALYZE"):
+            target_sql = target_sql[len("ANALYZE") :].strip()
+
+    target_upper = target_sql.upper()
+    allowed_prefixes = (
+        ("SELECT", "SHOW", "COUNT", "WITH")
+        if sql_dialect == "tree"
+        else ("SELECT", "SHOW", "DESC", "DESCRIBE", "WITH")
+    )
+
+    if not target_upper.startswith(allowed_prefixes):
+        raise ValueError(
+            f"explain_query only supports query-like SQL for {sql_dialect} dialect. "
+            f"Allowed prefixes: {', '.join(allowed_prefixes)}"
+        )
+    if sql_dialect == "tree" and target_upper.startswith("SELECT"):
+        assert_tree_query_shape(target_sql)
+
+    return explain_sql, target_sql
+
+
+def register_explain_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register EXPLAIN tool with per-call IoTDB target selection."""
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def explain_query(
+        query_sql: str,
+        analyze: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute EXPLAIN for one statement against the selected IoTDB target."""
+        selected_config = select_target_config(config, target_id=target_id, target=target)
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="explain_query",
+                )
+                session = None
+                try:
+                    explain_sql, _ = _normalize_and_validate_sql(
+                        selected_config.sql_dialect, query_sql, analyze
+                    )
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(explain_sql)
+                    columns, rows = _collect_result(res, session)
+                    return payload_response(
+                        "explain_query",
+                        {
+                            "explain_sql": explain_sql,
+                            "plan": {
+                                "format": "csv",
+                                "columns": columns,
+                                "rows": rows,
+                                "text": "\n".join([",".join(columns)] + rows),
+                            },
+                        },
+                        message="Explain executed.",
+                    )
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute explain query: {str(e)}")
+                    return error_response("explain_query", str(e))
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="explain_query",
+            )
+            table_session = None
+            try:
+                explain_sql, _ = _normalize_and_validate_sql(
+                    selected_config.sql_dialect, query_sql, analyze
+                )
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(explain_sql)
+                columns, rows = _collect_result(res, table_session)
+                return payload_response(
+                    "explain_query",
+                    {
+                        "explain_sql": explain_sql,
+                        "plan": {
+                            "format": "csv",
+                            "columns": columns,
+                            "rows": rows,
+                            "text": "\n".join([",".join(columns)] + rows),
+                        },
+                    },
+                    message="Explain executed.",
+                )
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute explain query: {str(e)}")
+                return error_response("explain_query", str(e))
diff --git a/src/iotdb_mcp_server/services/json_response.py b/src/iotdb_mcp_server/services/json_response.py
new file mode 100644
index 0000000..b4c138d
--- /dev/null
+++ b/src/iotdb_mcp_server/services/json_response.py
@@ -0,0 +1,329 @@
+#
+# 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.
+#
+
+import json
+from contextvars import ContextVar
+from datetime import datetime, timezone
+from typing import Any, Callable, Iterable
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.result_store import FileResultStore
+
+_RESPONSE_VERSION = "1.0"
+_target_context: ContextVar[dict[str, Any] | None] = ContextVar(
+    "iotdb_target_context", default=None
+)
+
+_TRUE_VALUES = {"1", "true", "yes", "on", "available", "running", "enabled"}
+_FALSE_VALUES = {"0", "false", "no", "off", "unavailable", "stopped", "disabled"}
+
+
+def set_iotdb_target_context(target: dict[str, Any] | None):
+    return _target_context.set(target)
+
+
+def reset_iotdb_target_context(token) -> None:
+    _target_context.reset(token)
+
+
+def _coerce_availability(value: Any) -> str | None:
+    if value is None:
+        return None
+    if isinstance(value, bool):
+        return "yes" if value else "no"
+    normalized = str(value).strip().lower()
+    if normalized in _TRUE_VALUES:
+        return "yes"
+    if normalized in _FALSE_VALUES:
+        return "no"
+    if normalized in {"unknown", "probe_failed", "not_probed"}:
+        return "unknown"
+    return None
+
+
+def _ainode_availability(target: dict[str, Any]) -> tuple[str, Any]:
+    availability = _coerce_availability(target.get("ainode_available")) or "unknown"
+    source = target.get("ainode_availability_source")
+    if source is None:
+        source = {
+            "method": "SHOW AINODES",
+            "ok": False,
+            "error": "not_probed",
+        }
+    return availability, source
+
+
+def _cli_endpoint(target: dict[str, Any]) -> str:
+    node_urls = target.get("node_urls")
+    if isinstance(node_urls, list) and node_urls:
+        first = str(node_urls[0]).strip()
+        if first:
+            return first
+    host = str(target.get("host") or "127.0.0.1").strip()
+    port = str(target.get("port") or "6667").strip()
+    return f"{host}:{port}"
+
+
+def _row_count(payload: Any) -> int:
+    if not isinstance(payload, dict):
+        return 0
+    rows = payload.get("rows")
+    if isinstance(rows, list):
+        return len(rows)
+    for key in ("row_count", "rows_count", "count"):
+        value = payload.get(key)
+        if isinstance(value, int):
+            return value
+        if isinstance(value, str) and value.strip().isdigit():
+            return int(value.strip())
+    return 0
+
+
+def _iotdb_context(tool: str, payload: Any, target: dict[str, Any]) -> dict[str, Any]:
+    ainode_available, ainode_source = _ainode_availability(target)
+    return {
+        "target_id": target.get("target_id"),
+        "cli_endpoint": _cli_endpoint(target),
+        "ainode_available": ainode_available,
+        "ainode_availability_source": ainode_source,
+        "dialect": target.get("sql_dialect"),
+        "database": target.get("database"),
+        "tool": tool,
+        "rows": _row_count(payload),
+    }
+
+
+def _render_iotdb_context(context: dict[str, Any]) -> str:
+    lines = [
+        "TimeSeek IoTDB context:",
+        f"- target_id: {context.get('target_id') or ''}",
+        f"- cli_endpoint: {context.get('cli_endpoint') or ''}",
+        f"- ainode_available: {context.get('ainode_available') or 'unknown'}",
+        f"- dialect: {context.get('dialect') or ''}",
+        f"- database: {context.get('database') or ''}",
+        f"- tool: {context.get('tool') or ''}",
+        f"- rows: {context.get('rows', 0)}",
+    ]
+    return "\n".join(lines)
+
+
+class JsonParser:
+    """Simple parser/checker for MCP tool JSON envelopes."""
+
+    _required_keys = ("version", "tool", "ok", "timestamp", "payload")
+
+    @staticmethod
+    def check_format(obj: Any) -> bool:
+        if not isinstance(obj, dict):
+            return False
+        for key in JsonParser._required_keys:
+            if key not in obj:
+                return False
+        if not isinstance(obj["version"], str):
+            return False
+        if not isinstance(obj["tool"], str) or not obj["tool"].strip():
+            return False
+        if not isinstance(obj["ok"], bool):
+            return False
+        if not isinstance(obj["timestamp"], str) or not obj["timestamp"].strip():
+            return False
+        return True
+
+    @staticmethod
+    def parse(text: str) -> dict[str, Any]:
+        try:
+            obj = json.loads(text)
+        except json.JSONDecodeError as e:
+            raise ValueError(f"Invalid JSON text: {e.msg}") from e
+
+        if not JsonParser.check_format(obj):
+            raise ValueError(
+                "Invalid wrapped response format. Expected keys: "
+                "version, tool, ok, timestamp, payload."
+            )
+        return obj
+
+
+def _envelope(
+    tool: str, payload: Any, ok: bool = True, message: str | None = None
+) -> dict[str, Any]:
+    target = _target_context.get()
+    context = _iotdb_context(tool, payload, target) if target else None
+    if target:
+        if isinstance(payload, dict):
+            payload = dict(payload)
+            payload.setdefault("iotdb_target", target)
+            if context:
+                payload.setdefault("iotdb_context", context)
+        else:
+            payload = {
+                "value": payload,
+                "iotdb_target": target,
+            }
+            if context:
+                payload["iotdb_context"] = context
+    obj: dict[str, Any] = {
+        "version": _RESPONSE_VERSION,
+        "tool": tool,
+        "ok": ok,
+        "timestamp": datetime.now(timezone.utc).isoformat(),
+        "payload": payload,
+    }
+    if context:
+        obj["context"] = {"iotdb": context}
+    if message is not None:
+        obj["message"] = message
+    return obj
+
+
+def _to_text_content(obj: dict[str, Any]) -> list[TextContent]:
+    if not JsonParser.check_format(obj):
+        raise ValueError("JsonParser format check failed before output.")
+    text = json.dumps(obj, ensure_ascii=False)
+    JsonParser.parse(text)
+    context = obj.get("context")
+    iotdb_context = context.get("iotdb") if isinstance(context, dict) else None
+    if isinstance(iotdb_context, dict):
+        return [
+            TextContent(type="text", text=_render_iotdb_context(iotdb_context)),
+            TextContent(type="text", text=text),
+        ]
+    return [TextContent(type="text", text=text)]
+
+
+def payload_response(
+    tool: str, payload: Any, message: str | None = None
+) -> list[TextContent]:
+    return _to_text_content(_envelope(tool=tool, payload=payload, message=message))
+
+
+def error_response(tool: str, error: str) -> list[TextContent]:
+    return _to_text_content(
+        _envelope(
+            tool=tool,
+            payload={"error": error},
+            ok=False,
+            message="Tool execution failed.",
+        )
+    )
+
+
+def text_payload_response(
+    tool: str, text: str, message: str | None = None
+) -> list[TextContent]:
+    return payload_response(
+        tool=tool,
+        payload={"format": "text", "text": text},
+        message=message,
+    )
+
+
+def _csv_text(columns: list[str], rows: list[str]) -> str:
+    return "\n".join([",".join(columns)] + rows)
+
+
+def csv_payload_response(
+    tool: str,
+    columns: list[str],
+    rows: list[str],
+    message: str | None = None,
+    diagnostics: list[dict[str, Any]] | None = None,
+) -> list[TextContent]:
+    csv_text = _csv_text(columns, rows)
+    payload: dict[str, Any] = {
+        "format": "csv",
+        "columns": columns,
+        "rows": rows,
+        "text": csv_text,
+        "row_count": len(rows),
+        "inline_row_count": len(rows),
+        "inline_truncated": False,
+    }
+    if diagnostics:
+        payload["diagnostics"] = diagnostics
+    return payload_response(
+        tool=tool,
+        payload=payload,
+        message=message,
+    )
+
+
+def csv_result_payload_response(
+    tool: str,
+    columns: list[str],
+    rows: Iterable[str],
+    *,
+    result_store: FileResultStore,
+    source: dict[str, Any] | None = None,
+    message: str | None = None,
+    diagnostics: list[dict[str, Any]] | None = None,
+    diagnostics_factory: Callable[[int], list[dict[str, Any]] | None] | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    stored = result_store.write_csv_result(
+        tool=tool,
+        columns=columns,
+        rows=rows,
+        source=source,
+        owner_session_id=owner_session_id,
+        preview_rows=max_inline_rows,
+        page_size_rows=page_size_rows,
+    )
+    preview_rows = stored.preview_rows
+    inline_truncated = stored.row_count > len(preview_rows)
+    payload: dict[str, Any] = {
+        "format": "csv",
+        "columns": columns,
+        "rows": preview_rows,
+        "text": _csv_text(columns, preview_rows),
+        "row_count": stored.row_count,
+        "inline_row_count": len(preview_rows),
+        "inline_truncated": inline_truncated,
+        "result_id": stored.result_id,
+        "owner_session_id": stored.owner_session_id,
+        "next_cursor": str(len(preview_rows)) if inline_truncated else None,
+        "result_store": {
+            "type": "file",
+            "result_id": stored.result_id,
+            "owner_session_id": stored.owner_session_id,
+            "page_tool": "read_result_page",
+            "batch_page_tool": "read_result_pages",
+            "page_size_rows": stored.page_size_rows,
+            "manifest_path": stored.manifest_path,
+            "shard_count": stored.shard_count,
+            "byte_count": stored.byte_count,
+        },
+    }
+    combined_diagnostics = list(diagnostics or [])
+    if diagnostics_factory is not None:
+        combined_diagnostics.extend(diagnostics_factory(stored.row_count) or [])
+    if combined_diagnostics:
+        payload["diagnostics"] = combined_diagnostics
+    return payload_response(tool=tool, payload=payload, message=message)
+
+
+def sql_success_response(tool: str, sql: str) -> list[TextContent]:
+    return payload_response(
+        tool=tool,
+        payload={"sql": sql, "result": "success"},
+        message="SQL executed successfully.",
+    )
diff --git a/src/iotdb_mcp_server/services/metadata.py b/src/iotdb_mcp_server/services/metadata.py
new file mode 100644
index 0000000..b7a8398
--- /dev/null
+++ b/src/iotdb_mcp_server/services/metadata.py
@@ -0,0 +1,416 @@
+#
+# 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.
+#
+
+import logging
+import re
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import csv_result_payload_response
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+
+_TREE_PATH_PATTERN = re.compile(r"^root(?:\.[A-Za-z_][A-Za-z0-9_]*|\.\*|\.\*\*)*$")
+_TABLE_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_metadata_permission(config: Config) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_METADATA_QUERY", True):
+            raise PermissionError(
+                "Metadata tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_METADATA_QUERY=true to enable."
+            )
+
+        allowed_users = _csv_set(
+            dynamic_getenv("IOTDB_METADATA_ALLOWED_USERS", "*") or "*"
+        )
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_METADATA_ALLOWED_USERS."
+            )
+
+
+def _normalize_sql(sql: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError("Metadata SQL cannot be empty.")
+    if cleaned.endswith(";"):
+        cleaned = cleaned[:-1].strip()
+    if ";" in cleaned:
+        raise ValueError("Only a single SQL statement is allowed.")
+    return cleaned
+
+
+def _validate_tree_path(path: str) -> str:
+    cleaned = path.strip()
+    if not cleaned:
+        raise ValueError("Tree path cannot be empty.")
+    if not _TREE_PATH_PATTERN.fullmatch(cleaned):
+        raise ValueError(
+            "Invalid tree path. Use path like root.sg, root.sg.dev, root.**, root.sg.*"
+        )
+    return cleaned
+
+
+def _validate_table_identifier(identifier: str) -> str:
+    cleaned = identifier.strip()
+    if not _TABLE_IDENTIFIER_PATTERN.fullmatch(cleaned):
+        raise ValueError(
+            "Invalid table identifier. Use letters, numbers, and underscore only."
+        )
+    return cleaned
+
+
+def _format_result(
+    res: SessionDataSet,
+    session_or_table_session: Session | TableSession,
+    tool_name: str,
+    export_path: str,
+    query_sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+
+    def rows():
+        while res.has_next():
+            row = res.next().get_fields()
+            yield ",".join(map(str, row))
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"query_sql": query_sql} if query_sql else None,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        session_or_table_session.close()
+
+
+def _ensure_prefix(
+    sql: str, allowed_prefixes: tuple[str, ...], action_name: str
+) -> None:
+    upper = sql.upper()
+    if not upper.startswith(allowed_prefixes):
+        raise ValueError(
+            f"{action_name} only supports SQL starting with: {', '.join(allowed_prefixes)}"
+        )
+
+
+def register_metadata_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register metadata tools with per-call IoTDB target selection."""
+    max_pool_size = 100
+    tree_prefixes = (
+        "SHOW DATABASES",
+        "SHOW TIMESERIES",
+        "SHOW DEVICES",
+        "SHOW CHILD PATHS",
+        "SHOW CHILD NODES",
+        "SHOW FUNCTIONS",
+        "COUNT TIMESERIES",
+        "COUNT NODES",
+        "COUNT DEVICES",
+    )
+    table_prefixes = ("SHOW", "DESC", "DESCRIBE")
+
+    def _require_target_dialect(
+        required_sql_dialect: str,
+        tool_name: str,
+        target_id: str | None,
+        target: dict[str, object] | None,
+    ) -> None:
+        select_target_config(
+            config,
+            target_id=target_id,
+            target=target,
+            required_sql_dialect=required_sql_dialect,
+            tool_name=tool_name,
+        )
+
+    async def _metadata_query_impl(
+        query_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute metadata SQL against the selected IoTDB target."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="metadata_query",
+                )
+                session = None
+                try:
+                    _assert_metadata_permission(selected_config)
+                    sql = _normalize_sql(query_sql)
+                    _ensure_prefix(sql, tree_prefixes, "metadata_query")
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(sql)
+                    return _format_result(
+                        res,
+                        session,
+                        "metadata_query",
+                        selected_config.export_path,
+                        sql,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute metadata_query: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="metadata_query",
+            )
+            table_session = None
+            try:
+                _assert_metadata_permission(selected_config)
+                sql = _normalize_sql(query_sql)
+                _ensure_prefix(sql, table_prefixes, "metadata_query")
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(sql)
+                return _format_result(
+                    res,
+                    table_session,
+                    "metadata_query",
+                    selected_config.export_path,
+                    sql,
+                    owner_session_id=owner_session_id,
+                    max_inline_rows=max_inline_rows,
+                    page_size_rows=page_size_rows,
+                )
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute metadata_query: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def metadata_query(
+        query_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute metadata SQL against the selected IoTDB target."""
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+
+    @mcp.tool()
+    async def list_timeseries(
+        path: str = "root.**",
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """List timeseries under a tree path pattern."""
+        _require_target_dialect("tree", "list_timeseries", target_id, target)
+        query_sql = f"SHOW TIMESERIES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def list_devices(
+        path: str = "root.**",
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """List devices under a tree path pattern."""
+        _require_target_dialect("tree", "list_devices", target_id, target)
+        query_sql = f"SHOW DEVICES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def list_child_paths(
+        path: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """List child paths under a tree path."""
+        _require_target_dialect("tree", "list_child_paths", target_id, target)
+        query_sql = f"SHOW CHILD PATHS {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def list_child_nodes(
+        path: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """List child nodes under a tree path."""
+        _require_target_dialect("tree", "list_child_nodes", target_id, target)
+        query_sql = f"SHOW CHILD NODES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def count_timeseries(
+        path: str = "root.**",
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """Count timeseries under a tree path pattern."""
+        _require_target_dialect("tree", "count_timeseries", target_id, target)
+        query_sql = f"COUNT TIMESERIES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def count_devices(
+        path: str = "root.**",
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """Count devices under a tree path pattern."""
+        _require_target_dialect("tree", "count_devices", target_id, target)
+        query_sql = f"COUNT DEVICES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def count_nodes(
+        path: str = "root",
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """Count nodes under a tree path."""
+        _require_target_dialect("tree", "count_nodes", target_id, target)
+        query_sql = f"COUNT NODES {_validate_tree_path(path)}"
+        return await _metadata_query_impl(
+            query_sql,
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def list_tables(
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """List all tables in current table-model database."""
+        _require_target_dialect("table", "list_tables", target_id, target)
+        return await _metadata_query_impl(
+            "SHOW TABLES",
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
+
+    @mcp.tool()
+    async def describe_table(
+        table_name: str,
+        details: bool = True,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+    ) -> list[TextContent]:
+        """Describe schema for a table in current table-model database."""
+        _require_target_dialect("table", "describe_table", target_id, target)
+        safe_table = _validate_table_identifier(table_name)
+        details_suffix = " details" if details else ""
+        return await _metadata_query_impl(
+            f"DESC {safe_table}{details_suffix}",
+            target_id=target_id,
+            target=target,
+            owner_session_id=owner_session_id,
+        )
diff --git a/src/iotdb_mcp_server/services/model.py b/src/iotdb_mcp_server/services/model.py
new file mode 100644
index 0000000..577c172
--- /dev/null
+++ b/src/iotdb_mcp_server/services/model.py
@@ -0,0 +1,585 @@
+#
+# 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.
+#
+
+import logging
+import re
+from typing import Any
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import (
+    csv_result_payload_response,
+    payload_response,
+    sql_success_response,
+)
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+
+_MODEL_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{1,63}$")
+_INFERENCE_PATTERN = re.compile(
+    r"^\s*CALL\s+INFERENCE\s*\((.*)\)\s*$", re.IGNORECASE | re.DOTALL
+)
+_ALLOWED_INFERENCE_PARAMS = {"generateTime", "outputLength"}
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_model_permission(
+    config: Config, action: str, confirm_destructive: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_MODEL_MANAGEMENT", False):
+            raise PermissionError(
+                "AINode model tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_MODEL_MANAGEMENT=true to enable."
+            )
+
+        allowed_users = _csv_set(
+            dynamic_getenv("IOTDB_MODEL_ALLOWED_USERS", "root") or "root"
+        )
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_MODEL_ALLOWED_USERS."
+            )
+
+    if (
+        action == "DESTRUCTIVE"
+        and _env_bool("IOTDB_REQUIRE_MODEL_DESTRUCTIVE_CONFIRM", True)
+        and not confirm_destructive
+    ):
+        raise PermissionError(
+            "Destructive model command requires confirm_destructive=True by server policy."
+        )
+
+
+def _strip_single_trailing_semicolon(sql: str, empty_message: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError(empty_message)
+
+    quote: str | None = None
+    escaped = False
+    semicolon_indexes: list[int] = []
+    index = 0
+    while index < len(cleaned):
+        char = cleaned[index]
+        if escaped:
+            escaped = False
+            index += 1
+            continue
+        if char == "\\" and quote:
+            escaped = True
+            index += 1
+            continue
+        if quote:
+            if char == quote:
+                if index + 1 < len(cleaned) and cleaned[index + 1] == quote:
+                    index += 2
+                    continue
+                quote = None
+            index += 1
+            continue
+        if char in {"'", '"'}:
+            quote = char
+        elif char == ";":
+            semicolon_indexes.append(index)
+        index += 1
+
+    if not semicolon_indexes:
+        return cleaned
+    if semicolon_indexes == [len(cleaned) - 1]:
+        return cleaned[:-1].strip()
+    raise ValueError("Only a single SQL statement is allowed.")
+
+
+def _normalize_sql(sql: str) -> str:
+    return _strip_single_trailing_semicolon(sql, "Model SQL cannot be empty.")
+
+
+def _normalize_for_prefix(sql: str) -> str:
+    return re.sub(r"\s+", " ", sql).strip().upper()
+
+
+def _matched_prefix(sql: str, prefixes: tuple[str, ...]) -> str | None:
+    upper = _normalize_for_prefix(sql)
+    for prefix in prefixes:
+        if not upper.startswith(prefix):
+            continue
+        if (
+            len(upper) == len(prefix)
+            or upper[len(prefix)].isspace()
+            or upper[len(prefix)] == "("
+        ):
+            return prefix
+    return None
+
+
+def _assert_prefix(sql: str, prefixes: tuple[str, ...], tool_name: str) -> None:
+    if _matched_prefix(sql, prefixes):
+        return
+    raise ValueError(
+        f"{tool_name} only supports SQL starting with: {', '.join(prefixes)}"
+    )
+
+
+def _split_top_level_args(value: str) -> list[str]:
+    args: list[str] = []
+    start = 0
+    quote: str | None = None
+    escaped = False
+    paren_depth = 0
+
+    for index, char in enumerate(value):
+        if escaped:
+            escaped = False
+            continue
+        if char == "\\" and quote:
+            escaped = True
+            continue
+        if quote:
+            if char == quote:
+                if index + 1 < len(value) and value[index + 1] == quote:
+                    continue
+                quote = None
+            continue
+        if char in {"'", '"'}:
+            quote = char
+        elif char == "(":
+            paren_depth += 1
+        elif char == ")":
+            if paren_depth > 0:
+                paren_depth -= 1
+        elif char == "," and paren_depth == 0:
+            args.append(value[start:index].strip())
+            start = index + 1
+
+    if quote:
+        raise ValueError("CALL INFERENCE input has an unterminated string literal.")
+    if paren_depth:
+        raise ValueError("CALL INFERENCE input has unbalanced parentheses.")
+
+    args.append(value[start:].strip())
+    return args
+
+
+def _decode_sql_string(value: str) -> str:
+    if len(value) < 2 or value[0] not in {"'", '"'} or value[-1] != value[0]:
+        raise ValueError(
+            "CALL INFERENCE second argument must be a quoted SELECT SQL string, "
+            'for example: "SELECT s0 FROM root.AI LIMIT 256".'
+        )
+    quote = value[0]
+    body = value[1:-1]
+    return body.replace(quote * 2, quote)
+
+
+def _quote_sql_string(value: str) -> str:
+    return '"' + value.replace('"', '""') + '"'
+
+
+def _assert_model_id(model_id: str) -> None:
+    if not _MODEL_ID_PATTERN.match(model_id):
+        raise ValueError(
+            "model_id must be 2-64 characters, start with a letter, and contain "
+            "only letters, numbers, or underscores."
+        )
+
+
+def _validate_inference_input_sql(input_sql: str) -> list[dict[str, Any]]:
+    cleaned = _strip_single_trailing_semicolon(
+        input_sql, "CALL INFERENCE input SELECT SQL cannot be empty."
+    )
+    normalized = _normalize_for_prefix(cleaned)
+    diagnostics: list[dict[str, Any]] = []
+
+    if not normalized.startswith("SELECT "):
+        raise ValueError("CALL INFERENCE input SQL must be a SELECT query.")
+    if re.match(r"^\s*SELECT\s+\*", cleaned, re.IGNORECASE):
+        raise ValueError(
+            "CALL INFERENCE input SQL must use an explicit ordered column list; "
+            "do not use SELECT * because wildcard column order is undefined."
+        )
+    if " LIMIT " not in f" {normalized} " and " WHERE " not in f" {normalized} ":
+        diagnostics.append(
+            {
+                "severity": "warning",
+                "code": "unbounded_input_sql",
+                "message": "Prefer LIMIT or a concrete time predicate for inference input SQL.",
+            }
+        )
+    return diagnostics
+
+
+def _parse_inference_params(raw_params: list[str]) -> dict[str, Any]:
+    params: dict[str, Any] = {}
+    for raw in raw_params:
+        if "=" not in raw:
+            raise ValueError(
+                "CALL INFERENCE optional arguments must use name=value syntax. "
+                "Supported names: generateTime, outputLength."
+            )
+        name, raw_value = [part.strip() for part in raw.split("=", 1)]
+        if name not in _ALLOWED_INFERENCE_PARAMS:
+            raise ValueError(
+                f"Unsupported CALL INFERENCE parameter '{name}'. "
+                "Supported parameters: generateTime, outputLength."
+            )
+        if name in params:
+            raise ValueError(f"Duplicate CALL INFERENCE parameter '{name}'.")
+
+        if name == "generateTime":
+            lowered = raw_value.lower()
+            if lowered not in {"true", "false"}:
+                raise ValueError("generateTime must be a boolean: true or false.")
+            params[name] = lowered == "true"
+        elif name == "outputLength":
+            if not re.match(r"^[1-9][0-9]*$", raw_value):
+                raise ValueError("outputLength must be a positive integer.")
+            params[name] = int(raw_value)
+    return params
+
+
+def _validate_inference_sql(sql: str) -> dict[str, Any]:
+    match = _INFERENCE_PATTERN.match(sql)
+    if not match:
+        raise ValueError(
+            "model_inference expects CALL INFERENCE(model_id, "
+            '"SELECT ...", generateTime=true|false, outputLength=<positive int>).'
+        )
+
+    args = _split_top_level_args(match.group(1))
+    if len(args) < 2:
+        raise ValueError(
+            "CALL INFERENCE requires at least model_id and input SELECT SQL."
+        )
+
+    model_id = args[0].strip()
+    _assert_model_id(model_id)
+
+    input_sql = _decode_sql_string(args[1])
+    diagnostics = _validate_inference_input_sql(input_sql)
+    params = _parse_inference_params(args[2:])
+
+    return {
+        "model_id": model_id,
+        "input_sql": input_sql,
+        "parameters": params,
+        "diagnostics": diagnostics,
+    }
+
+
+def _build_inference_sql(
+    model_id: str,
+    input_sql: str,
+    output_length: int = 96,
+    generate_time: bool = False,
+) -> tuple[str, dict[str, Any]]:
+    _assert_model_id(model_id)
+    diagnostics = _validate_inference_input_sql(input_sql)
+    if output_length <= 0:
+        raise ValueError("output_length must be a positive integer.")
+    inference_sql = (
+        f"CALL INFERENCE({model_id}, {_quote_sql_string(input_sql)}, "
+        f"generateTime={str(generate_time).lower()}, outputLength={output_length})"
+    )
+    return inference_sql, {
+        "model_id": model_id,
+        "input_sql": input_sql,
+        "parameters": {
+            "generateTime": generate_time,
+            "outputLength": output_length,
+        },
+        "diagnostics": diagnostics,
+    }
+
+
+def _format_result(
+    res: SessionDataSet,
+    session_or_table_session: Session | TableSession,
+    tool_name: str,
+    export_path: str,
+    sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+
+    def rows():
+        while res.has_next():
+            row = res.next().get_fields()
+            yield ",".join(map(str, row))
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"sql": sql} if sql else None,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        session_or_table_session.close()
+
+
+def register_model_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register AINode model-management SQL tools with target selection."""
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def model_query(
+        model_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute AINode model-query SQL against the selected IoTDB target."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        with iotdb_target_response_context(selected_config):
+            sql = _normalize_sql(model_sql)
+            _assert_prefix(
+                sql,
+                (
+                    "SHOW MODELS",
+                    "SHOW LOADED MODELS",
+                    "SHOW AI_DEVICES",
+                    "SHOW AINODES",
+                ),
+                "model_query",
+            )
+            _assert_model_permission(selected_config, action="QUERY")
+
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="model_query",
+                )
+                session = None
+                try:
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(sql)
+                    return _format_result(
+                        res,
+                        session,
+                        "model_query",
+                        selected_config.export_path,
+                        sql,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute model_query: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="model_query",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(sql)
+                return _format_result(
+                    res,
+                    table_session,
+                    "model_query",
+                    selected_config.export_path,
+                    sql,
+                    owner_session_id=owner_session_id,
+                    max_inline_rows=max_inline_rows,
+                    page_size_rows=page_size_rows,
+                )
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute model_query: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def prepare_model_inference_request(
+        model_id: str,
+        input_sql: str,
+        output_length: int = 96,
+        generate_time: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Build and validate Tree-dialect AINode CALL INFERENCE SQL."""
+        selected_config = select_target_config(
+            config,
+            target_id=target_id,
+            target=target,
+            required_sql_dialect="tree",
+            tool_name="prepare_model_inference_request",
+        )
+        with iotdb_target_response_context(selected_config):
+            inference_sql, validation = _build_inference_sql(
+                model_id=model_id,
+                input_sql=input_sql,
+                output_length=output_length,
+                generate_time=generate_time,
+            )
+            return payload_response(
+                "prepare_model_inference_request",
+                {
+                    "inference_sql": inference_sql,
+                    "validation": validation,
+                    "recommended_next_tool": "model_inference",
+                },
+            )
+
+    @mcp.tool()
+    async def model_inference(
+        inference_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute Tree-dialect AINode inference SQL."""
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            wait_timeout_in_ms=5000,
+            tool_name="model_inference",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                _assert_model_permission(selected_config, action="QUERY")
+                sql = _normalize_sql(inference_sql)
+                _assert_prefix(sql, ("CALL INFERENCE",), "model_inference")
+                _validate_inference_sql(sql)
+                session = session_pool.get_session()
+                res = session.execute_query_statement(sql)
+                return _format_result(
+                    res,
+                    session,
+                    "model_inference",
+                    selected_config.export_path,
+                    sql,
+                    owner_session_id=owner_session_id,
+                    max_inline_rows=max_inline_rows,
+                    page_size_rows=page_size_rows,
+                )
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute model_inference: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def model_command(
+        model_sql: str,
+        confirm_destructive: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute AINode model-management command SQL."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        with iotdb_target_response_context(selected_config):
+            sql = _normalize_sql(model_sql)
+
+            if _matched_prefix(sql, ("DROP MODEL", "UNLOAD MODEL", "REMOVE AINODE")):
+                _assert_model_permission(
+                    selected_config,
+                    action="DESTRUCTIVE",
+                    confirm_destructive=confirm_destructive,
+                )
+            elif _matched_prefix(sql, ("CREATE MODEL", "LOAD MODEL")):
+                _assert_model_permission(selected_config, action="MANAGE")
+            else:
+                raise ValueError(
+                    "model_command only supports SQL starting with: "
+                    "CREATE MODEL, DROP MODEL, LOAD MODEL, UNLOAD MODEL, REMOVE AINODE"
+                )
+
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    selected_config,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="model_command",
+                )
+                session = None
+                try:
+                    session = session_pool.get_session()
+                    session.execute_non_query_statement(sql)
+                    session.close()
+                    return sql_success_response("model_command", sql)
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute model_command: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                selected_config,
+                max_pool_size=max_pool_size,
+                tool_name="model_command",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("model_command", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute model_command: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/query.py b/src/iotdb_mcp_server/services/query.py
new file mode 100644
index 0000000..096a36b
--- /dev/null
+++ b/src/iotdb_mcp_server/services/query.py
@@ -0,0 +1,434 @@
+import datetime
+import logging
+import os
+import re
+import uuid
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.services.json_response import (
+    csv_result_payload_response,
+    text_payload_response,
+)
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    table_session_pool,
+    tree_session_pool,
+)
+from iotdb_mcp_server.services.tree_sql_guardrails import assert_tree_query_shape
+from iotdb_mcp_server.services.tree_sql_guardrails import (
+    tree_from_wildcard_runtime_hint,
+)
+
+
+def sanitize_filename(filename: str, base_dir: str) -> str:
+    """
+    Sanitize and validate filename to prevent path traversal attacks.
+
+    Security patch for CVE-2026-XXXXX
+    Author: Mohammed Tanveer (threatpointer)
+    Date: 2026-01-12
+
+    Args:
+        filename: The user-provided filename
+        base_dir: The base directory for exports (must be absolute path)
+
+    Returns:
+        The sanitized absolute filepath
+
+    Raises:
+        ValueError: If the filename contains invalid characters or attempts path traversal
+
+    Security measures:
+    - Accepts path-like inputs but strips directories and uses only the basename
+      under base_dir
+    - Rejects directory traversal segments before processing
+    - Validates allowed characters (alphanumeric, underscore, hyphen, dot)
+    - Resolves absolute path and verifies it stays within base_dir boundary
+    - Prevents directory traversal, symlink attacks, and path manipulation
+    """
+    if not filename:
+        raise ValueError("Filename cannot be empty")
+
+    normalized = filename.replace("\\", "/").strip()
+    parts = [part for part in normalized.split("/") if part]
+    if any(part == ".." for part in parts):
+        raise ValueError(
+            "Invalid filename: directory traversal sequences are not allowed"
+        )
+
+    filename = os.path.basename(parts[-1] if parts else normalized)
+
+    if not re.match(r"^[a-zA-Z0-9_\-\.]+$", filename):
+        raise ValueError(
+            "Invalid filename: only alphanumeric characters, underscore, hyphen, and dot are allowed"
+        )
+
+    if not filename or filename in (".", ".."):
+        raise ValueError("Invalid filename")
+
+    if filename.startswith(".."):
+        raise ValueError("Invalid filename: cannot start with '..'")
+
+    filepath = os.path.join(base_dir, filename)
+    filepath_real = os.path.realpath(filepath)
+    basedir_real = os.path.realpath(base_dir)
+
+    if (
+        not filepath_real.startswith(basedir_real + os.sep)
+        and filepath_real != basedir_real
+    ):
+        raise ValueError(
+            "Path traversal detected: file must be within export directory"
+        )
+
+    return filepath_real
+
+
+def _ensure_export_directory(export_path: str, logger: logging.Logger) -> None:
+    if os.path.exists(export_path):
+        return
+    try:
+        os.makedirs(export_path)
+        logger.info(f"Created export directory: {export_path}")
+    except Exception as e:
+        logger.warning(f"Failed to create export directory {export_path}: {str(e)}")
+
+
+def _prepare_tree_res(
+    _res: SessionDataSet,
+    _session: Session,
+    tool_name: str,
+    export_path: str,
+    query_sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = _res.get_column_names()
+
+    def rows():
+        while _res.has_next():
+            record = _res.next()
+            if columns and columns[0] == "Time":
+                timestamp = record.get_timestamp()
+                row = record.get_fields()
+                yield str(timestamp) + "," + ",".join(map(str, row))
+            else:
+                row = record.get_fields()
+                yield ",".join(map(str, row))
+
+    def diagnostics_factory(row_count: int):
+        diagnostics = []
+        if query_sql:
+            hint = tree_from_wildcard_runtime_hint(query_sql, row_count=row_count)
+            if hint:
+                diagnostics.append(hint)
+        return diagnostics
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"query_sql": query_sql} if query_sql else None,
+            diagnostics_factory=diagnostics_factory,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        _session.close()
+
+
+def _prepare_table_res(
+    _res: SessionDataSet,
+    _table_session: TableSession,
+    tool_name: str,
+    export_path: str,
+    query_sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = _res.get_column_names()
+
+    def rows():
+        while _res.has_next():
+            row = _res.next().get_fields()
+            yield ",".join(map(str, row))
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"query_sql": query_sql} if query_sql else None,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        _table_session.close()
+
+
+def register_query_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register query tools with per-call IoTDB target selection."""
+    _ensure_export_directory(config.export_path, logger)
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def select_query(
+        query_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute one tree-dialect read query and return rows.
+
+        Use target_id or target selector fields to choose a registered IoTDB target.
+        """
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            wait_timeout_in_ms=5000,
+            tool_name="select_query",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                stmt = query_sql.strip().upper()
+                if stmt.startswith("SELECT"):
+                    assert_tree_query_shape(query_sql)
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(query_sql)
+                    return _prepare_tree_res(
+                        res,
+                        session,
+                        "select_query",
+                        selected_config.export_path,
+                        query_sql,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                raise ValueError("Only SELECT queries are allowed for select_query")
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute select query: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def export_query(
+        query_sql: str,
+        format: str = "csv",
+        filename: str = None,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute one tree-dialect read query and export its result set."""
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            wait_timeout_in_ms=5000,
+            tool_name="export_query",
+        )
+        with iotdb_target_response_context(selected_config):
+            _ensure_export_directory(selected_config.export_path, logger)
+            session = None
+            try:
+                stmt = query_sql.strip().upper()
+                if not (stmt.startswith("SELECT") or stmt.startswith("SHOW")):
+                    raise ValueError(
+                        "Only SELECT or SHOW queries are allowed for export"
+                    )
+                if stmt.startswith("SELECT"):
+                    assert_tree_query_shape(query_sql)
+
+                session = session_pool.get_session()
+                res = session.execute_query_statement(query_sql)
+                df = res.todf()
+                session.close()
+
+                timestamp = int(datetime.datetime.now().timestamp())
+                if filename is None:
+                    filename = f"dump_{uuid.uuid4().hex[:4]}_{timestamp}"
+
+                if format.lower() == "csv":
+                    if filename.lower().endswith(".csv"):
+                        filename = filename[:-4]
+                    filepath = sanitize_filename(
+                        f"{filename}.csv", selected_config.export_path
+                    )
+                    df.to_csv(filepath, index=False)
+                elif format.lower() == "excel":
+                    if filename.lower().endswith(".xlsx"):
+                        filename = filename[:-5]
+                    filepath = sanitize_filename(
+                        f"{filename}.xlsx", selected_config.export_path
+                    )
+                    df.to_excel(filepath, index=False)
+                else:
+                    raise ValueError("Format must be either 'csv' or 'excel'")
+
+                preview_rows = min(10, len(df))
+                preview_data = [",".join(df.columns)]
+                for i in range(preview_rows):
+                    preview_data.append(",".join(map(str, df.iloc[i])))
+
+                wildcard_hint = tree_from_wildcard_runtime_hint(
+                    query_sql, row_count=len(df)
+                )
+                diagnostic_text = ""
+                if wildcard_hint:
+                    diagnostic_text = (
+                        "\n\nDiagnostic: "
+                        + wildcard_hint["message"]
+                        + " "
+                        + wildcard_hint["rewrite_hint"]
+                    )
+
+                return text_payload_response(
+                    "export_query",
+                    f"Query results exported to {filepath}\n\nPreview (first {preview_rows} rows):\n"
+                    + "\n".join(preview_data)
+                    + diagnostic_text,
+                )
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to export query: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def read_query(
+        query_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute one table-dialect read query and return rows."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            tool_name="read_query",
+        )
+        with iotdb_target_response_context(selected_config):
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                stmt = query_sql.strip().upper()
+                if (
+                    stmt.startswith("SELECT")
+                    or stmt.startswith("DESCRIBE")
+                    or stmt.startswith("SHOW")
+                ):
+                    res = table_session.execute_query_statement(query_sql)
+                    return _prepare_table_res(
+                        res,
+                        table_session,
+                        "read_query",
+                        selected_config.export_path,
+                        query_sql,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                table_session.close()
+                raise ValueError("Only SELECT queries are allowed for read_query")
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute query: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def export_table_query(
+        query_sql: str,
+        format: str = "csv",
+        filename: str = None,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute one table-dialect read query and export its result set."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=max_pool_size,
+            tool_name="export_table_query",
+        )
+        with iotdb_target_response_context(selected_config):
+            _ensure_export_directory(selected_config.export_path, logger)
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                stmt = query_sql.strip().upper()
+                if not (
+                    stmt.startswith("SELECT")
+                    or stmt.startswith("SHOW")
+                    or stmt.startswith("DESCRIBE")
+                    or stmt.startswith("DESC")
+                ):
+                    raise ValueError(
+                        "Only SELECT, SHOW or DESCRIBE queries are allowed for export"
+                    )
+
+                res = table_session.execute_query_statement(query_sql)
+                df = res.todf()
+                table_session.close()
+
+                timestamp = int(datetime.datetime.now().timestamp())
+                if filename is None:
+                    filename = f"dump_{uuid.uuid4().hex[:4]}_{timestamp}"
+
+                if format.lower() == "csv":
+                    if filename.lower().endswith(".csv"):
+                        filename = filename[:-4]
+                    filepath = sanitize_filename(
+                        f"{filename}.csv", selected_config.export_path
+                    )
+                    df.to_csv(filepath, index=False)
+                elif format.lower() == "excel":
+                    if filename.lower().endswith(".xlsx"):
+                        filename = filename[:-5]
+                    filepath = sanitize_filename(
+                        f"{filename}.xlsx", selected_config.export_path
+                    )
+                    df.to_excel(filepath, index=False)
+                else:
+                    raise ValueError("Format must be either 'csv' or 'excel'")
+
+                preview_rows = min(10, len(df))
+                preview_data = [",".join(df.columns)]
+                for i in range(preview_rows):
+                    preview_data.append(",".join(map(str, df.iloc[i])))
+
+                return text_payload_response(
+                    "export_table_query",
+                    f"Query results exported to {filepath}\n\nPreview (first {preview_rows} rows):\n"
+                    + "\n".join(preview_data),
+                )
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to export table query: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/results.py b/src/iotdb_mcp_server/services/results.py
new file mode 100644
index 0000000..7d64f52
--- /dev/null
+++ b/src/iotdb_mcp_server/services/results.py
@@ -0,0 +1,226 @@
+#
+# 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.
+#
+
+from __future__ import annotations
+
+import logging
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.services.json_response import payload_response
+from iotdb_mcp_server.services.target_selection import select_target_config
+
+
+def _cursor_to_offset(cursor: str | None, offset: int | None) -> int:
+    if offset is not None:
+        return max(0, int(offset))
+    if cursor is None or str(cursor).strip() == "":
+        return 0
+    cleaned = str(cursor).strip()
+    if not cleaned.isdigit():
+        raise ValueError("cursor must be a non-negative integer row offset.")
+    return int(cleaned)
+
+
+def _csv_text(columns: list[str], rows: list[str]) -> str:
+    return "\n".join([",".join(columns)] + rows)
+
+
+def register_result_store_tools(
+    mcp,
+    config: Config,
+    logger: logging.Logger,
+) -> None:
+    """Register tools for paging file-backed MCP result sets."""
+
+    @mcp.tool()
+    async def read_result_page(
+        result_id: str,
+        cursor: str | None = None,
+        offset: int | None = None,
+        limit: int | None = None,
+        owner_session_id: str | None = None,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Read one page from a previously returned ResultStore result_id.
+
+        Pass the same target_id used by the original query when multiple IoTDB
+        targets are registered. cursor is the next_cursor returned by a prior
+        page; offset can be used for random access and takes precedence.
+        """
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        store = result_store_from_export_path(selected_config.export_path)
+        try:
+            payload = store.read_page(
+                result_id,
+                offset=_cursor_to_offset(cursor, offset),
+                limit=limit,
+                owner_session_id=owner_session_id,
+            )
+            payload["target_id"] = selected_config.target_id
+            payload["text"] = _csv_text(payload["columns"], payload["rows"])
+            payload["inline_row_count"] = payload["returned_rows"]
+            payload["inline_truncated"] = payload["has_more"]
+            return payload_response(
+                "read_result_page",
+                payload,
+                message="Result page loaded from ResultStore.",
+            )
+        except Exception as e:
+            logger.error(f"Failed to read ResultStore page: {str(e)}")
+            raise
+
+    @mcp.tool()
+    async def read_result_pages(
+        pages: list[dict[str, object]],
+        owner_session_id: str | None = None,
+        default_limit: int | None = None,
+        max_pages: int | None = None,
+        max_total_rows: int | None = None,
+        continue_on_error: bool = True,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Read multiple ResultStore pages in one MCP call.
+
+        Each page request is an object with result_id plus optional cursor,
+        offset, limit, and owner_session_id. offset takes precedence over
+        cursor. owner_session_id on an item takes precedence over the shared
+        owner_session_id argument.
+        """
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        store = result_store_from_export_path(selected_config.export_path)
+        try:
+            payload = store.read_pages(
+                pages,
+                owner_session_id=owner_session_id,
+                default_limit=default_limit,
+                max_pages=max_pages,
+                max_total_rows=max_total_rows,
+                continue_on_error=continue_on_error,
+            )
+            payload["target_id"] = selected_config.target_id
+            for page in payload["pages"]:
+                if page.get("status") != "success":
+                    continue
+                page["text"] = _csv_text(page["columns"], page["rows"])
+                page["inline_row_count"] = page["returned_rows"]
+                page["inline_truncated"] = page["has_more"]
+            return payload_response(
+                "read_result_pages",
+                payload,
+                message="Result pages loaded from ResultStore.",
+            )
+        except Exception as e:
+            logger.error(f"Failed to read ResultStore pages: {str(e)}")
+            raise
+
+    @mcp.tool()
+    async def list_result_store(
+        owner_session_id: str | None = None,
+        all_sessions: bool = False,
+        limit: int = 50,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """List ResultStore entries for the current session by default."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        store = result_store_from_export_path(selected_config.export_path)
+        try:
+            payload = store.list_results(
+                owner_session_id=owner_session_id,
+                all_sessions=all_sessions,
+                limit=limit,
+            )
+            payload["target_id"] = selected_config.target_id
+            return payload_response(
+                "list_result_store",
+                payload,
+                message="ResultStore entries listed.",
+            )
+        except Exception as e:
+            logger.error(f"Failed to list ResultStore entries: {str(e)}")
+            raise
+
+    @mcp.tool()
+    async def cleanup_result_store(
+        owner_session_id: str | None = None,
+        all_sessions: bool = False,
+        ttl_seconds: int | None = None,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Run TTL and quota cleanup for ResultStore entries."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        store = result_store_from_export_path(selected_config.export_path)
+        try:
+            payload = store.cleanup(
+                owner_session_id=owner_session_id,
+                all_sessions=all_sessions,
+                ttl_seconds=ttl_seconds,
+            )
+            payload["target_id"] = selected_config.target_id
+            return payload_response(
+                "cleanup_result_store",
+                payload,
+                message="ResultStore cleanup completed.",
+            )
+        except Exception as e:
+            logger.error(f"Failed to cleanup ResultStore: {str(e)}")
+            raise
+
+    @mcp.tool()
+    async def delete_result(
+        result_id: str,
+        owner_session_id: str | None = None,
+        all_sessions: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Delete one cached ResultStore result."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        store = result_store_from_export_path(selected_config.export_path)
+        try:
+            payload = store.delete_result(
+                result_id,
+                owner_session_id=owner_session_id,
+                all_sessions=all_sessions,
+            )
+            payload["target_id"] = selected_config.target_id
+            return payload_response(
+                "delete_result",
+                payload,
+                message="ResultStore result deleted.",
+            )
+        except Exception as e:
+            logger.error(f"Failed to delete ResultStore result: {str(e)}")
+            raise
diff --git a/src/iotdb_mcp_server/services/runtime_policy.py b/src/iotdb_mcp_server/services/runtime_policy.py
new file mode 100644
index 0000000..c768389
--- /dev/null
+++ b/src/iotdb_mcp_server/services/runtime_policy.py
@@ -0,0 +1,77 @@
+# 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.
+#
+
+from __future__ import annotations
+
+import logging
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_policy_snapshot
+from iotdb_mcp_server.runtime_policy import reset_session_policy
+from iotdb_mcp_server.runtime_policy import set_session_policy
+from iotdb_mcp_server.services.json_response import payload_response
+
+
+def register_runtime_policy_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register session-scoped policy controls for this MCP process."""
+
+    @mcp.tool()
+    async def get_iotdb_session_policy() -> list[TextContent]:
+        """Inspect effective IoTDB MCP permissions for this running session."""
+        return payload_response(
+            "get_iotdb_session_policy",
+            dynamic_policy_snapshot(),
+            message="IoTDB session policy snapshot.",
+        )
+
+    @mcp.tool()
+    async def set_iotdb_session_policy(
+        policy: dict[str, object] | None = None,
+        preset: str | None = None,
+        replace: bool = False,
+    ) -> list[TextContent]:
+        """
+        Set session-scoped IoTDB MCP permissions without restarting the MCP server.
+
+        preset accepts: full, ddl, readonly. Explicit policy values override preset values.
+        """
+        snapshot = set_session_policy(policy=policy, preset=preset, replace=replace)
+        logger.info(
+            "Updated IoTDB session policy preset=%s replace=%s keys=%s",
+            preset,
+            replace,
+            sorted((policy or {}).keys()),
+        )
+        return payload_response(
+            "set_iotdb_session_policy",
+            snapshot,
+            message="IoTDB session policy updated.",
+        )
+
+    @mcp.tool()
+    async def reset_iotdb_session_policy(keys: list[str] | None = None) -> list[TextContent]:
+        """Clear all or selected session-scoped IoTDB MCP permission overrides."""
+        snapshot = reset_session_policy(keys=keys)
+        logger.info("Reset IoTDB session policy keys=%s", keys or "*")
+        return payload_response(
+            "reset_iotdb_session_policy",
+            snapshot,
+            message="IoTDB session policy reset.",
+        )
diff --git a/src/iotdb_mcp_server/services/sql_driver.py b/src/iotdb_mcp_server/services/sql_driver.py
new file mode 100644
index 0000000..055c3e5
--- /dev/null
+++ b/src/iotdb_mcp_server/services/sql_driver.py
@@ -0,0 +1,1348 @@
+#
+# 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.
+#
+
+import asyncio
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass, field
+from functools import partial
+import logging
+import os
+import re
+import threading
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.result_store import StoredCsvResult
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import dynamic_policy_snapshot
+from iotdb_mcp_server.runtime_policy import permission_enforcement_mode
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import (
+    csv_result_payload_response,
+    payload_response,
+)
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+from iotdb_mcp_server.services.tree_sql_guardrails import assert_tree_query_shape
+from iotdb_mcp_server.services.tree_sql_guardrails import (
+    tree_from_wildcard_runtime_hint,
+)
+
+_READONLY_PREFIXES_COMMON = (
+    "SELECT",
+    "SHOW",
+    "EXPLAIN",
+    "DESC",
+    "DESCRIBE",
+)
+_READONLY_PREFIXES_TREE = (
+    "COUNT TIMESERIES",
+    "COUNT NODES",
+    "COUNT DEVICES",
+    "CALL INFERENCE",
+    "SHOW CONTINUOUS QUERIES",
+    "SHOW CQS",
+)
+_READONLY_PREFIXES_TABLE: tuple[str, ...] = ()
+
+_DDL_PREFIXES_COMMON = (
+    "CREATE DATABASE",
+    "ALTER DATABASE",
+    "DROP DATABASE",
+    "CREATE TABLE",
+    "ALTER TABLE",
+    "DROP TABLE",
+    "USE",
+    "SET TTL",
+    "UNSET TTL",
+    "CREATE MODEL",
+    "DROP MODEL",
+    "LOAD MODEL",
+    "UNLOAD MODEL",
+    "REMOVE AINODE",
+)
+_DDL_PREFIXES_TREE = (
+    "CREATE TIMESERIES",
+    "CREATE ALIGNED TIMESERIES",
+    "ALTER TIMESERIES",
+    "DROP TIMESERIES",
+    "DELETE TIMESERIES",
+    "CREATE CONTINUOUS QUERY",
+    "CREATE CQ",
+    "DROP CONTINUOUS QUERY",
+    "DROP CQ",
+)
+_DDL_PREFIXES_TABLE: tuple[str, ...] = ()
+
+_FULL_PREFIXES_COMMON = (
+    "INSERT INTO",
+    "UPDATE",
+    "DELETE FROM",
+    "DELETE DEVICES",
+)
+_FULL_PREFIXES_TREE: tuple[str, ...] = ()
+_FULL_PREFIXES_TABLE: tuple[str, ...] = ()
+
+_DESTRUCTIVE_PREFIXES = (
+    "DROP ",
+    "DELETE ",
+    "UNSET TTL",
+    "REMOVE AINODE",
+    "UNLOAD MODEL",
+)
+
+_PERMISSION_RANK = {"readonly": 0, "ddl": 1, "full": 2}
+_TEMPLATE_FIELD_PATTERN = re.compile(
+    r"\{\{\s*([A-Za-z_][A-Za-z0-9_]*)(?::(literal|path|identifier))?\s*\}\}"
+)
+_PATH_SEGMENT_PATTERN = re.compile(r"^([A-Za-z0-9_]+|\*|\*\*)$")
+_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+_DEFAULT_BATCH_PER_ITEM_TIMEOUT_MS = 60_000
+_DEFAULT_BATCH_TIMEOUT_MS = 300_000
+_DEFAULT_BATCH_MAX_ROWS_PER_ITEM = 10_000
+_DEFAULT_BATCH_MAX_BYTES_PER_ITEM = 16 * 1024 * 1024
+_DEFAULT_BATCH_MAX_TOTAL_ROWS = 100_000
+_DEFAULT_BATCH_MAX_TOTAL_BYTES = 64 * 1024 * 1024
+_DEFAULT_BATCH_WORKER_POOL_SIZE = 4
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _env_int(
+    name: str, default: int, minimum: int = 1, maximum: int | None = None
+) -> int:
+    raw = (dynamic_getenv(name, "") or os.getenv(name, "") or "").strip()
+    if not raw:
+        value = default
+    else:
+        try:
+            value = int(raw)
+        except ValueError:
+            value = default
+    value = max(minimum, value)
+    if maximum is not None:
+        value = min(maximum, value)
+    return value
+
+
+def _positive_int(
+    value: int | None,
+    *,
+    env_name: str,
+    default: int,
+    minimum: int = 1,
+    maximum: int | None = None,
+) -> int:
+    if value is None:
+        return _env_int(env_name, default, minimum=minimum, maximum=maximum)
+    resolved = max(minimum, int(value))
+    if maximum is not None:
+        resolved = min(maximum, resolved)
+    return resolved
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _parse_mode() -> str:
+    mode = (
+        (dynamic_getenv("IOTDB_SQL_DRIVER_MODE", "readonly") or "readonly")
+        .strip()
+        .lower()
+    )
+    if mode not in ("readonly", "ddl", "full"):
+        raise ValueError(
+            "Invalid IOTDB_SQL_DRIVER_MODE. Expected one of: readonly, ddl, full."
+        )
+    return mode
+
+
+def _strip_single_trailing_semicolon(sql: str, empty_message: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError(empty_message)
+
+    quote: str | None = None
+    escaped = False
+    semicolon_indexes: list[int] = []
+    index = 0
+    while index < len(cleaned):
+        char = cleaned[index]
+        if escaped:
+            escaped = False
+            index += 1
+            continue
+        if char == "\\" and quote:
+            escaped = True
+            index += 1
+            continue
+        if quote:
+            if char == quote:
+                if index + 1 < len(cleaned) and cleaned[index + 1] == quote:
+                    index += 2
+                    continue
+                quote = None
+            index += 1
+            continue
+        if char in {"'", '"'}:
+            quote = char
+        elif char == ";":
+            semicolon_indexes.append(index)
+        index += 1
+
+    if not semicolon_indexes:
+        return cleaned
+    if semicolon_indexes == [len(cleaned) - 1]:
+        return cleaned[:-1].strip()
+    raise ValueError("Only a single SQL statement is allowed.")
+
+
+def _normalize_sql(sql: str) -> str:
+    return _strip_single_trailing_semicolon(sql, "SQL cannot be empty.")
+
+
+def _normalize_for_prefix(sql: str) -> str:
+    return re.sub(r"\s+", " ", sql).strip().upper()
+
+
+def _top_level_sql_keywords(sql: str) -> list[str]:
+    """Return unquoted SQL words outside parentheses and comments."""
+    keywords: list[str] = []
+    token: list[str] = []
+    quote: str | None = None
+    depth = 0
+    index = 0
+
+    def flush_token() -> None:
+        if token:
+            keywords.append("".join(token).upper())
+            token.clear()
+
+    while index < len(sql):
+        char = sql[index]
+        next_char = sql[index + 1] if index + 1 < len(sql) else ""
+
+        if quote is not None:
+            if char == quote:
+                if next_char == quote:
+                    index += 2
+                    continue
+                quote = None
+            elif char == "\\":
+                index += 2
+                continue
+            index += 1
+            continue
+
+        if char in {"'", '"', "`"}:
+            flush_token()
+            quote = char
+            index += 1
+            continue
+        if char == "-" and next_char == "-":
+            flush_token()
+            newline = sql.find("\n", index + 2)
+            index = len(sql) if newline < 0 else newline + 1
+            continue
+        if char == "/" and next_char == "*":
+            flush_token()
+            comment_end = sql.find("*/", index + 2)
+            index = len(sql) if comment_end < 0 else comment_end + 2
+            continue
+        if char == "(":
+            flush_token()
+            depth += 1
+            index += 1
+            continue
+        if char == ")":
+            flush_token()
+            depth = max(0, depth - 1)
+            index += 1
+            continue
+        if depth == 0 and (char.isalnum() or char == "_"):
+            token.append(char)
+        else:
+            flush_token()
+        index += 1
+
+    flush_token()
+    return keywords
+
+
+def _select_writes_results(sql: str) -> bool:
+    keywords = _top_level_sql_keywords(sql)
+    return bool(keywords and keywords[0] == "SELECT" and "INTO" in keywords[1:])
+
+
+def _matches_prefix(normalized_upper: str, prefix: str) -> bool:
+    if not normalized_upper.startswith(prefix):
+        return False
+    return (
+        len(normalized_upper) == len(prefix)
+        or normalized_upper[len(prefix)].isspace()
+        or normalized_upper[len(prefix)] == "("
+    )
+
+
+def _merge_prefixes(base: tuple[str, ...], env_var: str) -> tuple[str, ...]:
+    extra = tuple(
+        item.strip().upper()
+        for item in (dynamic_getenv(env_var, "") or "").split(",")
+        if item.strip()
+    )
+    all_prefixes = tuple(dict.fromkeys([*base, *extra]))
+    return tuple(sorted(all_prefixes, key=len, reverse=True))
+
+
+def _resolve_whitelists(sql_dialect: str) -> dict[str, tuple[str, ...]]:
+    readonly = _READONLY_PREFIXES_COMMON + (
+        _READONLY_PREFIXES_TREE if sql_dialect == "tree" else _READONLY_PREFIXES_TABLE
+    )
+    ddl = _DDL_PREFIXES_COMMON + (
+        _DDL_PREFIXES_TREE if sql_dialect == "tree" else _DDL_PREFIXES_TABLE
+    )
+    full = _FULL_PREFIXES_COMMON + (
+        _FULL_PREFIXES_TREE if sql_dialect == "tree" else _FULL_PREFIXES_TABLE
+    )
+
+    return {
+        "readonly": _merge_prefixes(
+            readonly, "IOTDB_SQL_DRIVER_EXTRA_READONLY_PREFIXES"
+        ),
+        "ddl": _merge_prefixes(ddl, "IOTDB_SQL_DRIVER_EXTRA_DDL_PREFIXES"),
+        "full": _merge_prefixes(full, "IOTDB_SQL_DRIVER_EXTRA_FULL_PREFIXES"),
+    }
+
+
+def _classify_sql(sql: str, whitelists: dict[str, tuple[str, ...]]) -> tuple[str, str]:
+    normalized_upper = _normalize_for_prefix(sql)
+
+    if _select_writes_results(sql):
+        return "full", "SELECT INTO"
+
+    for category in ("readonly", "ddl", "full"):
+        for prefix in whitelists[category]:
+            if _matches_prefix(normalized_upper, prefix):
+                return category, prefix
+
+    raise ValueError(
+        "SQL is not in whitelist. Allowed categories and prefixes can be inspected "
+        "with sql_driver_policy tool."
+    )
+
+
+def _is_destructive_prefix(prefix: str) -> bool:
+    upper_prefix = prefix.upper()
+    return upper_prefix.startswith(_DESTRUCTIVE_PREFIXES)
+
+
+def _sql_literal(value: Any) -> str:
+    if value is None:
+        return "NULL"
+    if isinstance(value, bool):
+        return "true" if value else "false"
+    if isinstance(value, int):
+        return str(value)
+    if isinstance(value, float):
+        if value != value or value in (float("inf"), float("-inf")):
+            raise ValueError("Non-finite float values are not valid SQL literals.")
+        return repr(value)
+    return "'" + str(value).replace("'", "''") + "'"
+
+
+def _validate_iotdb_path(value: Any) -> str:
+    text = str(value).strip()
+    if not text:
+        raise ValueError("Path placeholder value cannot be empty.")
+    if any(char in text for char in (";", "'", '"', "`", "\\", "\n", "\r", "\t", " ")):
+        raise ValueError(f"Unsafe IoTDB path placeholder value: {text!r}")
+    parts = text.split(".")
+    if not parts or any(not _PATH_SEGMENT_PATTERN.fullmatch(part) for part in parts):
+        raise ValueError(f"Invalid IoTDB path placeholder value: {text!r}")
+    return text
+
+
+def _validate_identifier(value: Any) -> str:
+    text = str(value).strip()
+    if not _IDENTIFIER_PATTERN.fullmatch(text):
+        raise ValueError(f"Invalid SQL identifier placeholder value: {text!r}")
+    return text
+
+
+def _render_sql_template(sql_template: str, params: dict[str, Any]) -> str:
+    template = str(sql_template or "").strip()
+    if not template:
+        raise ValueError("sql_template cannot be empty.")
+    seen: set[str] = set()
+
+    def replace(match: re.Match[str]) -> str:
+        name = match.group(1)
+        kind = match.group(2) or "literal"
+        if name not in params:
+            raise ValueError(f"Missing SQL template parameter: {name}")
+        seen.add(name)
+        value = params[name]
+        if kind == "literal":
+            return _sql_literal(value)
+        if kind == "path":
+            return _validate_iotdb_path(value)
+        if kind == "identifier":
+            return _validate_identifier(value)
+        raise ValueError(f"Unsupported SQL template placeholder kind: {kind}")
+
+    rendered = _TEMPLATE_FIELD_PATTERN.sub(replace, template)
+    if "{{" in rendered or "}}" in rendered:
+        raise ValueError(
+            "Invalid sql_template placeholder. Use {{name}}, {{name:path}}, or {{name:identifier}}."
+        )
+    if not seen:
+        raise ValueError(
+            "sql_template must contain at least one placeholder such as {{device:path}}."
+        )
+    return _normalize_sql(rendered)
+
+
+def _resolve_batch_sqls(
+    sqls: list[str] | None,
+    sql_template: str | None,
+    param_sets: list[dict[str, Any]] | None,
+) -> list[dict[str, Any]]:
+    direct_sqls = [str(sql) for sql in (sqls or []) if str(sql).strip()]
+    has_template = bool(sql_template and str(sql_template).strip())
+    has_param_sets = bool(param_sets)
+
+    if direct_sqls and (has_template or has_param_sets):
+        raise ValueError("Use either sqls or sql_template+param_sets, not both.")
+    if direct_sqls:
+        return [
+            {"index": index, "sql": _normalize_sql(sql), "params": None}
+            for index, sql in enumerate(direct_sqls)
+        ]
+
+    if has_template or has_param_sets:
+        if not has_template:
+            raise ValueError("sql_template is required when param_sets is provided.")
+        if not has_param_sets:
+            raise ValueError("param_sets must contain at least one parameter set.")
+        statements: list[dict[str, Any]] = []
+        for index, params in enumerate(param_sets or []):
+            if not isinstance(params, dict):
+                raise ValueError("Each param_sets item must be an object.")
+            statements.append(
+                {
+                    "index": index,
+                    "sql": _render_sql_template(str(sql_template), params),
+                    "params": dict(params),
+                }
+            )
+        return statements
+
+    raise ValueError("Provide either sqls or sql_template+param_sets.")
+
+
+def _mode_satisfies(required_category: str, mode: str) -> bool:
+    return _PERMISSION_RANK[mode] >= _PERMISSION_RANK[required_category]
+
+
+def _sql_permission_payload(
+    sql: str,
+    selected_config: Config,
+    whitelists: dict[str, tuple[str, ...]],
+    mode: str,
+) -> dict[str, object]:
+    normalized_sql = _normalize_sql(sql)
+    category, matched_prefix = _classify_sql(normalized_sql, whitelists)
+    destructive = _is_destructive_prefix(matched_prefix)
+    destructive_confirmation_required = destructive and _env_bool(
+        "IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM", True
+    )
+    current_satisfies = _mode_satisfies(category, mode)
+    approval_required = destructive_confirmation_required or not current_satisfies
+    approval_reasons: list[str] = []
+    if not current_satisfies:
+        approval_reasons.append(
+            f"current advisory permission is '{mode}', SQL requires '{category}'"
+        )
+    if destructive_confirmation_required:
+        approval_reasons.append("SQL is destructive and requires explicit confirmation")
+
+    return {
+        "sql": normalized_sql,
+        "sql_dialect": selected_config.sql_dialect,
+        "matched_prefix": matched_prefix,
+        "required_permission": category,
+        "current_advisory_permission": mode,
+        "current_permission_satisfies": current_satisfies,
+        "permission_enforcement": permission_enforcement_mode(),
+        "strict_enforcement": strict_permission_enforcement(),
+        "destructive": destructive,
+        "risk_level": (
+            "high" if destructive else ("medium" if category != "readonly" else "low")
+        ),
+        "confirmation_parameter": (
+            "confirm_destructive" if destructive_confirmation_required else None
+        ),
+        "approval_required": approval_required,
+        "approval_reasons": approval_reasons,
+        "agent_approval_protocol": {
+            "owner": "host agent system",
+            "before_execution": (
+                "If approval_required is true, ask the user through the host agent "
+                "question/approval mechanism before executing."
+            ),
+            "after_user_approval": (
+                "Call sql_execute with confirm_destructive=true when the "
+                "confirmation_parameter is confirm_destructive."
+            ),
+            "do_not_use_environment_as_primary_gate": True,
+        },
+    }
+
+
+def _assert_sql_driver_permission(
+    config: Config,
+    required_category: str,
+    mode: str,
+    confirm_destructive: bool,
+    matched_prefix: str,
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_SQL_DRIVER", False):
+            raise PermissionError(
+                "SQL driver tool is disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_SQL_DRIVER=true to enable."
+            )
+
+        allowed_users = _csv_set(
+            dynamic_getenv("IOTDB_SQL_DRIVER_ALLOWED_USERS", "root") or "root"
+        )
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_SQL_DRIVER_ALLOWED_USERS."
+            )
+
+        if not _mode_satisfies(required_category, mode):
+            raise PermissionError(
+                f"Current sql driver mode '{mode}' does not allow SQL requiring "
+                f"'{required_category}' permission ('{matched_prefix}')."
+            )
+
+    if (
+        _is_destructive_prefix(matched_prefix)
+        and _env_bool("IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM", True)
+        and not confirm_destructive
+    ):
+        raise PermissionError(
+            "Destructive SQL requires confirm_destructive=True by server policy."
+        )
+
+
+def _format_tree_result(
+    res: SessionDataSet,
+    session: Session,
+    tool_name: str,
+    export_path: str,
+    sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+
+    def rows():
+        while res.has_next():
+            record = res.next()
+            if columns and columns[0] == "Time":
+                timestamp = record.get_timestamp()
+                row = record.get_fields()
+                yield str(timestamp) + "," + ",".join(map(str, row))
+            else:
+                yield ",".join(map(str, record.get_fields()))
+
+    def diagnostics_factory(row_count: int):
+        diagnostics = []
+        if sql:
+            hint = tree_from_wildcard_runtime_hint(sql, row_count=row_count)
+            if hint:
+                diagnostics.append(hint)
+        return diagnostics
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"sql": sql} if sql else None,
+            diagnostics_factory=diagnostics_factory,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        session.close()
+
+
+def _format_table_result(
+    res: SessionDataSet,
+    table_session: TableSession,
+    tool_name: str,
+    export_path: str,
+    sql: str | None = None,
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+
+    def rows():
+        while res.has_next():
+            yield ",".join(map(str, res.next().get_fields()))
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"sql": sql} if sql else None,
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        table_session.close()
+
+
+def _tree_csv_rows(res: SessionDataSet, columns: list[str]):
+    while res.has_next():
+        record = res.next()
+        if columns and columns[0] == "Time":
+            timestamp = record.get_timestamp()
+            row = record.get_fields()
+            yield str(timestamp) + "," + ",".join(map(str, row))
+        else:
+            yield ",".join(map(str, record.get_fields()))
+
+
+def _table_csv_rows(res: SessionDataSet):
+    while res.has_next():
+        yield ",".join(map(str, res.next().get_fields()))
+
+
+class BatchQuotaExceeded(RuntimeError):
+    pass
+
+
+class BatchItemTimeout(TimeoutError):
+    pass
+
+
+class BatchTimeout(TimeoutError):
+    pass
+
+
+@dataclass
+class _ResultQuota:
+    label: str
+    max_rows: int
+    max_bytes: int
+    rows: int = 0
+    bytes: int = 0
+    lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+
+    def consume(self, row: str) -> None:
+        row_bytes = len(row.encode("utf-8")) + 1
+        with self.lock:
+            next_rows = self.rows + 1
+            next_bytes = self.bytes + row_bytes
+            if next_rows > self.max_rows:
+                raise BatchQuotaExceeded(
+                    f"{self.label} row quota exceeded: {next_rows}>{self.max_rows}"
+                )
+            if next_bytes > self.max_bytes:
+                raise BatchQuotaExceeded(
+                    f"{self.label} byte quota exceeded: {next_bytes}>{self.max_bytes}"
+                )
+            self.rows = next_rows
+            self.bytes = next_bytes
+
+
+def _quota_csv_rows(rows, *quotas: _ResultQuota):
+    for row in rows:
+        for quota in quotas:
+            quota.consume(row)
+        yield row
+
+
+def _stored_result_payload(
+    *,
+    index: int,
+    sql: str,
+    stored: StoredCsvResult,
+    columns: list[str],
+    duration_ms: int,
+    diagnostics: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+    preview_rows = stored.preview_rows
+    inline_truncated = stored.row_count > len(preview_rows)
+    payload: dict[str, Any] = {
+        "index": index,
+        "ok": True,
+        "status": "success",
+        "sql": sql,
+        "format": "csv",
+        "columns": columns,
+        "rows": preview_rows,
+        "row_count": stored.row_count,
+        "inline_row_count": len(preview_rows),
+        "inline_truncated": inline_truncated,
+        "result_id": stored.result_id,
+        "owner_session_id": stored.owner_session_id,
+        "next_cursor": str(len(preview_rows)) if inline_truncated else None,
+        "duration_ms": duration_ms,
+        "result_store": {
+            "type": "file",
+            "result_id": stored.result_id,
+            "owner_session_id": stored.owner_session_id,
+            "page_tool": "read_result_page",
+            "batch_page_tool": "read_result_pages",
+            "page_size_rows": stored.page_size_rows,
+            "manifest_path": stored.manifest_path,
+            "shard_count": stored.shard_count,
+            "byte_count": stored.byte_count,
+        },
+    }
+    if diagnostics:
+        payload["diagnostics"] = diagnostics
+    return payload
+
+
+def _batch_error_payload(index: int, sql: str, exc: BaseException) -> dict[str, Any]:
+    return {
+        "index": index,
+        "ok": False,
+        "status": "error",
+        "sql": sql,
+        "error_type": type(exc).__name__,
+        "error": str(exc),
+    }
+
+
+def _batch_skipped_payload(index: int, sql: str) -> dict[str, Any]:
+    return {
+        "index": index,
+        "ok": False,
+        "status": "skipped",
+        "sql": sql,
+        "error_type": "BatchAborted",
+        "error": "Skipped because continue_on_error is false and an earlier item failed.",
+    }
+
+
+def _store_tree_batch_item(
+    *,
+    session_pool: Any,
+    selected_config: Config,
+    index: int,
+    sql: str,
+    batch_id: str,
+    owner_session_id: str | None,
+    max_inline_rows: int | None,
+    page_size_rows: int | None,
+    item_quota: _ResultQuota,
+    batch_quota: _ResultQuota,
+) -> dict[str, Any]:
+    started = time.monotonic()
+    session = None
+    try:
+        session = session_pool.get_session()
+        res = session.execute_query_statement(sql)
+        columns = res.get_column_names()
+        store = result_store_from_export_path(selected_config.export_path)
+        stored = store.write_csv_result(
+            tool="sql_executor_batch",
+            columns=columns,
+            rows=_quota_csv_rows(_tree_csv_rows(res, columns), item_quota, batch_quota),
+            source={"sql": sql, "batch_id": batch_id, "index": index},
+            owner_session_id=owner_session_id,
+            preview_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+        diagnostics = []
+        hint = tree_from_wildcard_runtime_hint(sql, row_count=stored.row_count)
+        if hint:
+            diagnostics.append(hint)
+        return _stored_result_payload(
+            index=index,
+            sql=sql,
+            stored=stored,
+            columns=columns,
+            duration_ms=int((time.monotonic() - started) * 1000),
+            diagnostics=diagnostics,
+        )
+    finally:
+        if session is not None:
+            session.close()
+
+
+def _store_table_batch_item(
+    *,
+    session_pool: Any,
+    selected_config: Config,
+    index: int,
+    sql: str,
+    batch_id: str,
+    owner_session_id: str | None,
+    max_inline_rows: int | None,
+    page_size_rows: int | None,
+    item_quota: _ResultQuota,
+    batch_quota: _ResultQuota,
+) -> dict[str, Any]:
+    started = time.monotonic()
+    table_session = None
+    try:
+        table_session = session_pool.get_session()
+        res = table_session.execute_query_statement(sql)
+        columns = res.get_column_names()
+        store = result_store_from_export_path(selected_config.export_path)
+        stored = store.write_csv_result(
+            tool="sql_executor_batch",
+            columns=columns,
+            rows=_quota_csv_rows(_table_csv_rows(res), item_quota, batch_quota),
+            source={"sql": sql, "batch_id": batch_id, "index": index},
+            owner_session_id=owner_session_id,
+            preview_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+        return _stored_result_payload(
+            index=index,
+            sql=sql,
+            stored=stored,
+            columns=columns,
+            duration_ms=int((time.monotonic() - started) * 1000),
+        )
+    finally:
+        if table_session is not None:
+            table_session.close()
+
+
+def register_sql_driver_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register generic SQL execution tools with advisory permission metadata."""
+    logger.info("SQL driver registered; mode is resolved dynamically per tool call")
+
+    @mcp.tool()
+    async def sql_driver_policy(
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Show current sql_driver policy and statement whitelists."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        mode = _parse_mode()
+        whitelists = _resolve_whitelists(selected_config.sql_dialect)
+        with iotdb_target_response_context(selected_config):
+            return payload_response(
+                "sql_driver_policy",
+                {
+                    "sql_dialect": selected_config.sql_dialect,
+                    "mode": mode,
+                    "advisory_permission": mode,
+                    "enable_sql_driver": _env_bool("IOTDB_ENABLE_SQL_DRIVER", True),
+                    "permission_enforcement": permission_enforcement_mode(),
+                    "strict_enforcement": strict_permission_enforcement(),
+                    "permission_model": {
+                        "readonly": "read-only SQL such as SELECT, SHOW, DESC, EXPLAIN",
+                        "ddl": "schema/model/TTL management SQL",
+                        "full": "DML or data-changing SQL such as INSERT, UPDATE, DELETE",
+                        "host_agent_approval": (
+                            "MCP reports required_permission and risk. The host agent "
+                            "asks the user through its question/approval mechanism before "
+                            "executing risky SQL."
+                        ),
+                    },
+                    "require_destructive_confirm": _env_bool(
+                        "IOTDB_SQL_DRIVER_REQUIRE_DESTRUCTIVE_CONFIRM", True
+                    ),
+                    "runtime_policy": dynamic_policy_snapshot(),
+                    "whitelists": whitelists,
+                },
+                message="SQL driver policy snapshot.",
+            )
+
+    @mcp.tool()
+    async def inspect_sql_permission(
+        sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Inspect SQL permission, risk, and approval requirements without executing it."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        mode = _parse_mode()
+        whitelists = _resolve_whitelists(selected_config.sql_dialect)
+        with iotdb_target_response_context(selected_config):
+            return payload_response(
+                "inspect_sql_permission",
+                _sql_permission_payload(sql, selected_config, whitelists, mode),
+                message="SQL permission inspection completed.",
+            )
+
+    @mcp.tool()
+    async def sql_executor_batch(
+        sqls: list[str] | None = None,
+        sql_template: str | None = None,
+        param_sets: list[dict[str, object]] | None = None,
+        max_concurrency: int = 4,
+        worker_pool_size: int | None = None,
+        per_item_timeout_ms: int | None = None,
+        batch_timeout_ms: int | None = None,
+        max_result_rows_per_item: int | None = None,
+        max_result_bytes_per_item: int | None = None,
+        max_batch_result_rows: int | None = None,
+        max_batch_result_bytes: int | None = None,
+        continue_on_error: bool = True,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = 5,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute many readonly SQL statements in parallel and store each result.
+
+        Use either `sqls=[...]` for explicit single statements or
+        `sql_template` with `param_sets` to generate repeated SELECT/SHOW
+        statements without asking the model to spell out each SQL. Template
+        placeholders are `{{name}}` for SQL literals, `{{name:path}}` for IoTDB
+        paths, and `{{name:identifier}}` for SQL identifiers.
+        """
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        mode = _parse_mode()
+        whitelists = _resolve_whitelists(selected_config.sql_dialect)
+        max_statements = _env_int(
+            "IOTDB_SQL_EXECUTOR_BATCH_MAX_STATEMENTS", 64, minimum=1, maximum=1000
+        )
+        max_allowed_concurrency = _env_int(
+            "IOTDB_SQL_EXECUTOR_BATCH_MAX_CONCURRENCY", 16, minimum=1, maximum=128
+        )
+        max_allowed_workers = _env_int(
+            "IOTDB_SQL_EXECUTOR_BATCH_MAX_WORKER_POOL_SIZE",
+            16,
+            minimum=1,
+            maximum=128,
+        )
+        per_item_timeout_ms = _positive_int(
+            per_item_timeout_ms,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_PER_ITEM_TIMEOUT_MS",
+            default=_DEFAULT_BATCH_PER_ITEM_TIMEOUT_MS,
+            maximum=3_600_000,
+        )
+        batch_timeout_ms = _positive_int(
+            batch_timeout_ms,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_TIMEOUT_MS",
+            default=_DEFAULT_BATCH_TIMEOUT_MS,
+            maximum=24 * 3_600_000,
+        )
+        max_result_rows_per_item = _positive_int(
+            max_result_rows_per_item,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_MAX_RESULT_ROWS_PER_ITEM",
+            default=_DEFAULT_BATCH_MAX_ROWS_PER_ITEM,
+            maximum=10_000_000,
+        )
+        max_result_bytes_per_item = _positive_int(
+            max_result_bytes_per_item,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_MAX_RESULT_BYTES_PER_ITEM",
+            default=_DEFAULT_BATCH_MAX_BYTES_PER_ITEM,
+            maximum=10 * 1024 * 1024 * 1024,
+        )
+        max_batch_result_rows = _positive_int(
+            max_batch_result_rows,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_MAX_TOTAL_RESULT_ROWS",
+            default=_DEFAULT_BATCH_MAX_TOTAL_ROWS,
+            maximum=100_000_000,
+        )
+        max_batch_result_bytes = _positive_int(
+            max_batch_result_bytes,
+            env_name="IOTDB_SQL_EXECUTOR_BATCH_MAX_TOTAL_RESULT_BYTES",
+            default=_DEFAULT_BATCH_MAX_TOTAL_BYTES,
+            maximum=100 * 1024 * 1024 * 1024,
+        )
+        statements = _resolve_batch_sqls(
+            sqls,
+            sql_template,
+            param_sets,
+        )
+        if len(statements) > max_statements:
+            raise ValueError(
+                f"sql_executor_batch accepts at most {max_statements} statements."
+            )
+
+        for item in statements:
+            normalized_sql = item["sql"]
+            category, matched_prefix = _classify_sql(normalized_sql, whitelists)
+            if category != "readonly":
+                raise PermissionError(
+                    "sql_executor_batch only supports readonly SQL. "
+                    f"Statement {item['index']} requires '{category}' permission."
+                )
+            _assert_sql_driver_permission(
+                selected_config,
+                required_category=category,
+                mode=mode,
+                confirm_destructive=False,
+                matched_prefix=matched_prefix,
+            )
+            if (
+                selected_config.sql_dialect == "tree"
+                and normalized_sql.upper().startswith("SELECT")
+            ):
+                assert_tree_query_shape(normalized_sql)
+            item["matched_prefix"] = matched_prefix
+
+        batch_id = (
+            "batch_"
+            + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+            + "_"
+            + uuid.uuid4().hex[:12]
+        )
+        concurrency = min(
+            max(1, int(max_concurrency or 1)),
+            max_allowed_concurrency,
+            len(statements),
+        )
+        workers = min(
+            _positive_int(
+                worker_pool_size,
+                env_name="IOTDB_SQL_EXECUTOR_BATCH_WORKER_POOL_SIZE",
+                default=max(concurrency, _DEFAULT_BATCH_WORKER_POOL_SIZE),
+                maximum=max_allowed_workers,
+            ),
+            concurrency,
+            len(statements),
+        )
+        started = time.monotonic()
+        batch_deadline = started + (batch_timeout_ms / 1000.0)
+        stop_event = asyncio.Event()
+        semaphore = asyncio.Semaphore(concurrency)
+        batch_quota = _ResultQuota(
+            "batch",
+            max_rows=max_batch_result_rows,
+            max_bytes=max_batch_result_bytes,
+        )
+
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=concurrency,
+                    wait_timeout_in_ms=selected_config.tree_wait_timeout_in_ms,
+                    tool_name="sql_executor_batch",
+                )
+                store_item = _store_tree_batch_item
+            else:
+                _, session_pool = table_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=concurrency,
+                    wait_timeout_in_ms=selected_config.table_wait_timeout_in_ms,
+                    tool_name="sql_executor_batch",
+                )
+                store_item = _store_table_batch_item
+
+            loop = asyncio.get_running_loop()
+            executor = ThreadPoolExecutor(
+                max_workers=workers,
+                thread_name_prefix="iotdb-sql-batch",
+            )
+
+            async def execute_item(item: dict[str, Any]) -> dict[str, Any]:
+                if stop_event.is_set():
+                    return _batch_skipped_payload(item["index"], item["sql"])
+                async with semaphore:
+                    if stop_event.is_set():
+                        return _batch_skipped_payload(item["index"], item["sql"])
+                    try:
+                        remaining_seconds = batch_deadline - time.monotonic()
+                        if remaining_seconds <= 0:
+                            raise BatchTimeout(
+                                f"Batch timeout exceeded after {batch_timeout_ms} ms."
+                            )
+                        timeout_seconds = min(
+                            per_item_timeout_ms / 1000.0,
+                            remaining_seconds,
+                        )
+                        item_quota = _ResultQuota(
+                            f"statement {item['index']}",
+                            max_rows=max_result_rows_per_item,
+                            max_bytes=max_result_bytes_per_item,
+                        )
+                        call = partial(
+                            store_item,
+                            session_pool=session_pool,
+                            selected_config=selected_config,
+                            index=item["index"],
+                            sql=item["sql"],
+                            batch_id=batch_id,
+                            owner_session_id=owner_session_id,
+                            max_inline_rows=max_inline_rows,
+                            page_size_rows=page_size_rows,
+                            item_quota=item_quota,
+                            batch_quota=batch_quota,
+                        )
+                        return await asyncio.wait_for(
+                            loop.run_in_executor(executor, call),
+                            timeout=timeout_seconds,
+                        )
+                    except asyncio.TimeoutError as exc:
+                        if not continue_on_error:
+                            stop_event.set()
+                        return _batch_error_payload(
+                            item["index"],
+                            item["sql"],
+                            BatchItemTimeout(
+                                f"Statement timed out after {per_item_timeout_ms} ms."
+                            ),
+                        )
+                    except Exception as exc:
+                        if not continue_on_error:
+                            stop_event.set()
+                        logger.error(
+                            "sql_executor_batch item %s failed: %s",
+                            item["index"],
+                            str(exc),
+                        )
+                        return _batch_error_payload(item["index"], item["sql"], exc)
+
+            tasks = {
+                asyncio.create_task(execute_item(item)): item for item in statements
+            }
+            done, pending = await asyncio.wait(
+                tasks,
+                timeout=batch_timeout_ms / 1000.0,
+            )
+            results = []
+            for task in done:
+                try:
+                    results.append(task.result())
+                except asyncio.CancelledError:
+                    item = tasks[task]
+                    results.append(
+                        _batch_error_payload(
+                            item["index"],
+                            item["sql"],
+                            BatchTimeout(
+                                f"Batch timeout exceeded after {batch_timeout_ms} ms."
+                            ),
+                        )
+                    )
+            if pending:
+                stop_event.set()
+                for task in pending:
+                    item = tasks[task]
+                    task.cancel()
+                    results.append(
+                        _batch_error_payload(
+                            item["index"],
+                            item["sql"],
+                            BatchTimeout(
+                                f"Batch timeout exceeded after {batch_timeout_ms} ms."
+                            ),
+                        )
+                    )
+            executor.shutdown(wait=False, cancel_futures=True)
+            results.sort(key=lambda item: int(item.get("index", 0)))
+            succeeded = sum(1 for item in results if item.get("status") == "success")
+            failed = sum(1 for item in results if item.get("status") == "error")
+            skipped = sum(1 for item in results if item.get("status") == "skipped")
+            payload = {
+                "batch_id": batch_id,
+                "sql_dialect": selected_config.sql_dialect,
+                "target_id": selected_config.target_id,
+                "statement_count": len(statements),
+                "succeeded": succeeded,
+                "failed": failed,
+                "skipped": skipped,
+                "continue_on_error": continue_on_error,
+                "max_concurrency": concurrency,
+                "worker_pool_size": workers,
+                "duration_ms": int((time.monotonic() - started) * 1000),
+                "generated_from_template": bool(sql_template),
+                "execution_controls": {
+                    "per_item_timeout_ms": per_item_timeout_ms,
+                    "batch_timeout_ms": batch_timeout_ms,
+                    "timeout_cancellation": "best_effort",
+                    "max_result_rows_per_item": max_result_rows_per_item,
+                    "max_result_bytes_per_item": max_result_bytes_per_item,
+                    "max_batch_result_rows": max_batch_result_rows,
+                    "max_batch_result_bytes": max_batch_result_bytes,
+                    "batch_rows_consumed": batch_quota.rows,
+                    "batch_bytes_consumed": batch_quota.bytes,
+                },
+                "result_store": {
+                    "type": "file",
+                    "page_tool": "read_result_page",
+                    "batch_page_tool": "read_result_pages",
+                    "owner_session_id": owner_session_id,
+                },
+                "results": results,
+            }
+            return payload_response(
+                "sql_executor_batch",
+                payload,
+                message="Batch SQL execution completed.",
+            )
+
+    @mcp.tool()
+    async def sql_execute(
+        sql: str,
+        confirm_destructive: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute exactly one SQL statement on the selected IoTDB target.
+
+        The selected target's dialect determines the whitelist and session pool.
+        """
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        mode = _parse_mode()
+        whitelists = _resolve_whitelists(selected_config.sql_dialect)
+        with iotdb_target_response_context(selected_config):
+            normalized_sql = _normalize_sql(sql)
+            category, matched_prefix = _classify_sql(normalized_sql, whitelists)
+            _assert_sql_driver_permission(
+                selected_config,
+                required_category=category,
+                mode=mode,
+                confirm_destructive=confirm_destructive,
+                matched_prefix=matched_prefix,
+            )
+
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=100,
+                    wait_timeout_in_ms=5000,
+                    tool_name="sql_execute",
+                )
+                session = None
+                try:
+                    if category == "readonly":
+                        if normalized_sql.upper().startswith("SELECT"):
+                            assert_tree_query_shape(normalized_sql)
+                        session = session_pool.get_session()
+                        res = session.execute_query_statement(normalized_sql)
+                        return _format_tree_result(
+                            res,
+                            session,
+                            "sql_execute",
+                            selected_config.export_path,
+                            normalized_sql,
+                            owner_session_id=owner_session_id,
+                            max_inline_rows=max_inline_rows,
+                            page_size_rows=page_size_rows,
+                        )
+
+                    session = session_pool.get_session()
+                    session.execute_non_query_statement(normalized_sql)
+                    session.close()
+                    return payload_response(
+                        "sql_execute",
+                        {
+                            "sql": normalized_sql,
+                            "category": category,
+                            "mode": mode,
+                            "matched_prefix": matched_prefix,
+                            "result": "success",
+                        },
+                        message="SQL executed successfully.",
+                    )
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute sql_execute (tree): {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=100,
+                tool_name="sql_execute",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                if category == "readonly":
+                    res = table_session.execute_query_statement(normalized_sql)
+                    return _format_table_result(
+                        res,
+                        table_session,
+                        "sql_execute",
+                        selected_config.export_path,
+                        normalized_sql,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+
+                table_session.execute_non_query_statement(normalized_sql)
+                table_session.close()
+                return payload_response(
+                    "sql_execute",
+                    {
+                        "sql": normalized_sql,
+                        "category": category,
+                        "mode": mode,
+                        "matched_prefix": matched_prefix,
+                        "result": "success",
+                    },
+                    message="SQL executed successfully.",
+                )
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute sql_execute (table): {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/table.py b/src/iotdb_mcp_server/services/table.py
new file mode 100644
index 0000000..958a05f
--- /dev/null
+++ b/src/iotdb_mcp_server/services/table.py
@@ -0,0 +1,178 @@
+#
+# 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.
+#
+
+import logging
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import sql_success_response
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    table_session_pool,
+)
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_table_ddl_permission(
+    config: Config, action: str, confirm: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_TABLE_DDL", False):
+            raise PermissionError(
+                "Table DDL tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_TABLE_DDL=true to enable."
+            )
+
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_TABLE_DDL_ALLOWED_USERS", "root") or "root")
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_TABLE_DDL_ALLOWED_USERS."
+            )
+
+    if (
+        action == "DROP"
+        and _env_bool("IOTDB_REQUIRE_TABLE_DROP_CONFIRM", True)
+        and not confirm
+    ):
+        raise PermissionError("DROP TABLE requires confirm=True by server policy.")
+
+
+def _normalize_sql(sql: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError("DDL SQL cannot be empty.")
+    if cleaned.endswith(";"):
+        cleaned = cleaned[:-1].strip()
+    if ";" in cleaned:
+        raise ValueError("Only a single SQL statement is allowed.")
+    return cleaned
+
+
+def _ensure_prefix(sql: str, allowed_prefixes: tuple[str, ...], action_name: str) -> None:
+    upper = sql.upper()
+    if not upper.startswith(allowed_prefixes):
+        raise ValueError(
+            f"{action_name} only supports SQL starting with: {', '.join(allowed_prefixes)}"
+        )
+
+
+def register_table_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register table-model table-level DDL tools."""
+
+    @mcp.tool()
+    async def create_table_ddl(
+        ddl_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute table-model CREATE TABLE DDL."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            tool_name="create_table_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            table_session = None
+            try:
+                _assert_table_ddl_permission(selected_config, action="CREATE")
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(sql, ("CREATE TABLE",), "create_table_ddl")
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("create_table_ddl", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute create_table_ddl: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def alter_table_ddl(
+        ddl_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute table-model ALTER TABLE DDL."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            tool_name="alter_table_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            table_session = None
+            try:
+                _assert_table_ddl_permission(selected_config, action="ALTER")
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(sql, ("ALTER TABLE",), "alter_table_ddl")
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("alter_table_ddl", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute alter_table_ddl: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def drop_table_ddl(
+        ddl_sql: str,
+        confirm: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute table-model DROP TABLE DDL (destructive)."""
+        selected_config, session_pool = table_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            tool_name="drop_table_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            table_session = None
+            try:
+                _assert_table_ddl_permission(selected_config, action="DROP", confirm=confirm)
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(sql, ("DROP TABLE",), "drop_table_ddl")
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("drop_table_ddl", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute drop_table_ddl: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/target_selection.py b/src/iotdb_mcp_server/services/target_selection.py
new file mode 100644
index 0000000..e07aadd
--- /dev/null
+++ b/src/iotdb_mcp_server/services/target_selection.py
@@ -0,0 +1,492 @@
+from __future__ import annotations
+
+import copy
+from contextlib import contextmanager
+import os
+from pathlib import Path
+import time
+from typing import Any, Iterator, Mapping
+
+from iotdb.Session import Session
+from iotdb.table_session import TableSession, TableSessionConfig
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.session_manager import (
+    create_table_session_pool,
+    create_tree_session_pool,
+)
+from iotdb_mcp_server.target_registry import IoTDBTargetRegistry
+from iotdb_mcp_server.services.json_response import (
+    reset_iotdb_target_context,
+    set_iotdb_target_context,
+)
+
+
+TargetSelector = str | Mapping[str, Any] | None
+_AINODE_PROBE_CACHE: dict[str, dict[str, Any]] = {}
+_DEFAULT_AINODE_PROBE_TTL_SECONDS = 10.0
+
+
+def _copy_target_fields(config: Config, source: Config) -> None:
+    for field_name in (
+        "host",
+        "port",
+        "user",
+        "password",
+        "database",
+        "sql_dialect",
+        "timezone",
+        "export_path",
+        "target_id",
+        "display_name",
+        "target_kind",
+        "node_urls",
+        "iotdb_home",
+        "use_ssl",
+        "ca_certs",
+        "connection_timeout_in_ms",
+        "enable_redirection",
+        "enable_compression",
+        "fetch_size",
+        "max_retry",
+        "max_pool_size",
+        "tree_wait_timeout_in_ms",
+        "table_wait_timeout_in_ms",
+        "target_fingerprint",
+        "policy",
+        "last_known_good_credential",
+        "verified_at",
+    ):
+        setattr(config, field_name, getattr(source, field_name))
+
+
+def apply_target_registry(
+    config: Config,
+    registry: IoTDBTargetRegistry,
+    *,
+    active_target_id: str | None = None,
+    targets_file_mtime_ns: int | None = None,
+    close_pools: bool = True,
+) -> Config:
+    if not registry.list_targets(include_secret=False):
+        config.target_registry = registry
+        config.target_id = ""
+        config.display_name = ""
+        config.target_kind = ""
+        config.host = ""
+        config.port = 0
+        config.node_urls = ()
+        config.user = ""
+        config.password = ""
+        config.database = ""
+        config.target_fingerprint = ""
+        config.policy = {}
+        config.last_known_good_credential = {}
+        config.verified_at = ""
+        if config.session_manager is not None:
+            config.session_manager.update_registry(registry, close_pools=close_pools)
+        return config
+    active_target = registry.resolve(active_target_id or registry.default_target_id)
+    selected = Config.from_target(
+        active_target,
+        target_registry=registry,
+        session_manager=config.session_manager,
+    )
+    _copy_target_fields(config, selected)
+    config.target_registry = registry
+    if targets_file_mtime_ns is not None:
+        config.targets_file_mtime_ns = targets_file_mtime_ns
+    if config.session_manager is not None:
+        update = getattr(config.session_manager, "update_registry", None)
+        if update is not None:
+            update(registry, close_pools=close_pools)
+        else:
+            config.session_manager.registry = registry
+    return config
+
+
+def maybe_reload_target_registry(config: Config) -> bool:
+    targets_file = (config.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")).strip()
+    if not targets_file:
+        return False
+    path = Path(targets_file).expanduser()
+    try:
+        mtime_ns = path.stat().st_mtime_ns
+    except OSError:
+        return False
+    if config.targets_file_mtime_ns == mtime_ns:
+        return False
+    registry = IoTDBTargetRegistry.from_env(
+        targets_file=str(path),
+        targets_json=config.targets_json or os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "") or None,
+    )
+    apply_target_registry(
+        config,
+        registry,
+        targets_file_mtime_ns=mtime_ns,
+        close_pools=True,
+    )
+    return True
+
+
+def _string_fields(row: Any) -> list[str]:
+    fields = row.get_fields() if hasattr(row, "get_fields") else row
+    if isinstance(fields, (list, tuple)):
+        return [str(field) for field in fields]
+    return [str(fields)]
+
+
+def _row_dict(columns: list[str], row: list[str]) -> dict[str, str] | dict[str, list[str]]:
+    if len(columns) == len(row):
+        return dict(zip(columns, row))
+    return {"values": row}
+
+
+def _has_running_ainode(columns: list[str], rows: list[list[str]]) -> bool:
+    status_index = next(
+        (index for index, column in enumerate(columns) if column.strip().lower() == "status"),
+        None,
+    )
+    if status_index is not None:
+        return any(
+            len(row) > status_index and row[status_index].strip().lower() == "running"
+            for row in rows
+        )
+    return any(any(value.strip().lower() == "running" for value in row) for row in rows)
+
+
+def _read_dataset(res: Any) -> tuple[list[str], list[list[str]]]:
+    columns = [str(column) for column in res.get_column_names()]
+    rows: list[list[str]] = []
+    while res.has_next():
+        rows.append(_string_fields(res.next()))
+    return columns, rows
+
+
+def _ainode_probe_ttl_seconds() -> float:
+    raw = os.getenv("TIMESEEK_AINODE_PROBE_TTL_SECONDS", "").strip()
+    if not raw:
+        return _DEFAULT_AINODE_PROBE_TTL_SECONDS
+    try:
+        return max(0.0, float(raw))
+    except ValueError:
+        return _DEFAULT_AINODE_PROBE_TTL_SECONDS
+
+
+def _ainode_probe_cache_key(config: Config) -> str:
+    try:
+        return config.to_target().auth_fingerprint()
+    except Exception:
+        return config.target_fingerprint or f"{config.target_id}:{config.host}:{config.port}"
+
+
+def _cached_ainode_probe_result(
+    entry: dict[str, Any], age_seconds: float, ttl_seconds: float
+) -> dict[str, Any]:
+    result = copy.deepcopy(entry["result"])
+    source = result.get("ainode_availability_source")
+    if isinstance(source, dict):
+        source["cached"] = True
+        source["cache_age_ms"] = int(max(0.0, age_seconds) * 1000)
+        source["cache_ttl_seconds"] = ttl_seconds
+    return result
+
+
+def _last_success_for_stale_result(
+    cache_key: str, error_source: dict[str, Any]
+) -> dict[str, Any] | None:
+    cached = _AINODE_PROBE_CACHE.get(cache_key)
+    if not cached:
+        return None
+    age_seconds = time.monotonic() - float(cached["monotonic"])
+    cached_result = cached["result"]
+    source = {
+        **error_source,
+        "stale_from_last_success": True,
+        "cache_age_ms": int(max(0.0, age_seconds) * 1000),
+        "last_success": copy.deepcopy(
+            cached_result.get("ainode_availability_source")
+        ),
+    }
+    return {
+        "ainode_available": cached_result.get("ainode_available", "unknown"),
+        "ainode_availability_source": source,
+    }
+
+
+def _open_ainode_probe_session(config: Config):
+    """Open a short-lived direct session for AINode discovery.
+
+    This intentionally avoids the shared SessionPool used by business tools.
+    The IoTDB Python SessionPool requires `put_back()`, while many call sites
+    close raw sessions; a one-session probe pool can therefore time out under
+    repeated response annotation. A direct session keeps probe health isolated.
+    """
+    if config.sql_dialect == "tree":
+        session = Session.init_from_node_urls(
+            list(config.node_urls),
+            config.user,
+            config.password,
+            config.fetch_size,
+            config.timezone,
+            enable_redirection=config.enable_redirection,
+            use_ssl=config.use_ssl,
+            ca_certs=config.ca_certs or None,
+            connection_timeout_in_ms=config.connection_timeout_in_ms,
+        )
+        session.sql_dialect = "tree"
+        session.database = config.database
+        session.open(config.enable_compression)
+        return session
+
+    return TableSession(
+        TableSessionConfig(
+            node_urls=list(config.node_urls),
+            username=config.user,
+            password=config.password,
+            database=config.database or None,
+            fetch_size=config.fetch_size,
+            time_zone=config.timezone,
+            enable_redirection=config.enable_redirection,
+            enable_compression=config.enable_compression,
+            use_ssl=config.use_ssl,
+            ca_certs=config.ca_certs or None,
+            connection_timeout_in_ms=config.connection_timeout_in_ms,
+        )
+    )
+
+
+def _probe_ainode_availability(config: Config) -> dict[str, Any]:
+    session = None
+    cache_key = _ainode_probe_cache_key(config)
+    ttl_seconds = _ainode_probe_ttl_seconds()
+    cached = _AINODE_PROBE_CACHE.get(cache_key)
+    if cached and ttl_seconds > 0:
+        age_seconds = time.monotonic() - float(cached["monotonic"])
+        if age_seconds <= ttl_seconds:
+            return _cached_ainode_probe_result(cached, age_seconds, ttl_seconds)
+
+    try:
+        session = _open_ainode_probe_session(config)
+        res = session.execute_query_statement("SHOW AINODES")
+        columns, raw_rows = _read_dataset(res)
+        running_count = sum(
+            1
+            for row in raw_rows
+            if _has_running_ainode(columns, [row])
+        )
+        result = {
+            "ainode_available": "yes" if running_count > 0 else "no",
+            "ainode_availability_source": {
+                "method": "SHOW AINODES",
+                "ok": True,
+                "columns": columns,
+                "rows": [_row_dict(columns, row) for row in raw_rows],
+                "row_count": len(raw_rows),
+                "running_count": running_count,
+            },
+        }
+        _AINODE_PROBE_CACHE[cache_key] = {
+            "monotonic": time.monotonic(),
+            "result": copy.deepcopy(result),
+        }
+        return result
+    except Exception as exc:
+        error_source = {
+            "method": "SHOW AINODES",
+            "ok": False,
+            "error_type": type(exc).__name__,
+            "error": str(exc),
+        }
+        stale = _last_success_for_stale_result(cache_key, error_source)
+        if stale:
+            return stale
+        return {
+            "ainode_available": "unknown",
+            "ainode_availability_source": error_source,
+        }
+    finally:
+        if session is not None:
+            close = getattr(session, "close", None)
+            if close is not None:
+                close()
+
+
+def _target_payload(config: Config) -> dict[str, Any]:
+    payload = {
+        "target_id": config.target_id,
+        "display_name": config.display_name,
+        "kind": config.target_kind,
+        "host": config.host,
+        "port": config.port,
+        "node_urls": list(config.node_urls),
+        "database": config.database,
+        "sql_dialect": config.sql_dialect,
+        "timezone": config.timezone,
+        "policy": dict(config.policy),
+        "fingerprint": config.target_fingerprint,
+        "ainode_available": "unknown",
+        "ainode_availability_source": {"method": "not_probed", "ok": None},
+    }
+    return payload
+
+
+def select_target_config(
+    config: Config,
+    target_id: str | None = None,
+    target: TargetSelector = None,
+    required_sql_dialect: str | None = None,
+    tool_name: str | None = None,
+) -> Config:
+    maybe_reload_target_registry(config)
+    registry = config.target_registry
+    if registry is None:
+        if target_id and target_id != config.target_id:
+            raise KeyError(
+                f"IoTDB target '{target_id}' is not registered in this MCP server."
+            )
+        if isinstance(target, str) and target != config.target_id:
+            raise KeyError(
+                f"IoTDB target '{target}' is not registered in this MCP server."
+            )
+        if isinstance(target, Mapping):
+            requested_id = target.get("target_id") or target.get("id") or target.get("name")
+            if requested_id and str(requested_id) != config.target_id:
+                raise KeyError(
+                    f"IoTDB target '{requested_id}' is not registered in this MCP server."
+                )
+            candidate = _target_payload(config)
+            candidate["target_kind"] = candidate["kind"]
+            candidate["dialect"] = candidate["sql_dialect"]
+            for key in (
+                "display_name",
+                "kind",
+                "target_kind",
+                "host",
+                "port",
+                "database",
+                "sql_dialect",
+                "dialect",
+            ):
+                expected = target.get(key)
+                if expected is not None and str(candidate[key]) != str(expected):
+                    raise KeyError(
+                        "IoTDB target selector does not match the active target."
+                    )
+            endpoint = target.get("node_url") or target.get("endpoint")
+            if endpoint and str(endpoint).strip() not in config.node_urls:
+                raise KeyError(
+                    "IoTDB target selector does not match the active target."
+                )
+            raw_node_urls = (
+                target.get("node_urls")
+                or target.get("endpoints")
+                or target.get("nodes")
+            )
+            if raw_node_urls is not None:
+                if isinstance(raw_node_urls, (list, tuple)):
+                    requested_node_urls = [
+                        str(item).strip() for item in raw_node_urls if str(item).strip()
+                    ]
+                else:
+                    requested_node_urls = [
+                        item.strip()
+                        for item in str(raw_node_urls).split(",")
+                        if item.strip()
+                    ]
+                if not all(item in config.node_urls for item in requested_node_urls):
+                    raise KeyError(
+                        "IoTDB target selector does not match the active target."
+                    )
+        selected = config
+    else:
+        selected_target = registry.resolve_selector(
+            target,
+            target_id=target_id or (None if target is not None else config.target_id),
+        )
+        selected = Config.from_target(
+            selected_target,
+            target_registry=config.target_registry,
+            session_manager=config.session_manager,
+        )
+        # Nested pool helpers select again. Preserve the reload watermark so a
+        # derived explicit-target config is not reset to the registry default.
+        selected.targets_file = config.targets_file
+        selected.targets_json = config.targets_json
+        selected.targets_file_mtime_ns = config.targets_file_mtime_ns
+
+    if required_sql_dialect and selected.sql_dialect != required_sql_dialect:
+        owner = f"{tool_name} " if tool_name else ""
+        raise ValueError(
+            f"{owner}requires {required_sql_dialect} SQL dialect, but IoTDB target "
+            f"'{selected.target_id}' uses {selected.sql_dialect}."
+        )
+    return selected
+
+
+def tree_session_pool(
+    config: Config,
+    target_id: str | None = None,
+    target: TargetSelector = None,
+    max_pool_size: int | None = None,
+    wait_timeout_in_ms: int | None = None,
+    tool_name: str | None = None,
+):
+    selected = select_target_config(
+        config,
+        target_id=target_id,
+        target=target,
+        required_sql_dialect="tree",
+        tool_name=tool_name,
+    )
+    if selected.session_manager is not None:
+        return selected, selected.session_manager.tree_pool(
+            selected.target_id,
+            max_pool_size=max_pool_size,
+            wait_timeout_in_ms=wait_timeout_in_ms,
+        )
+    return selected, create_tree_session_pool(
+        selected,
+        max_pool_size=max_pool_size,
+        wait_timeout_in_ms=wait_timeout_in_ms,
+    )
+
+
+def table_session_pool(
+    config: Config,
+    target_id: str | None = None,
+    target: TargetSelector = None,
+    max_pool_size: int | None = None,
+    database: str | None = None,
+    wait_timeout_in_ms: int | None = None,
+    tool_name: str | None = None,
+):
+    selected = select_target_config(
+        config,
+        target_id=target_id,
+        target=target,
+        required_sql_dialect="table",
+        tool_name=tool_name,
+    )
+    if selected.session_manager is not None:
+        return selected, selected.session_manager.table_pool(
+            selected.target_id,
+            max_pool_size=max_pool_size,
+            database=database,
+            wait_timeout_in_ms=wait_timeout_in_ms,
+        )
+    return selected, create_table_session_pool(
+        selected,
+        max_pool_size=max_pool_size,
+        database=database,
+        wait_timeout_in_ms=wait_timeout_in_ms,
+    )
+
+
+@contextmanager
+def iotdb_target_response_context(config: Config) -> Iterator[None]:
+    token = set_iotdb_target_context(_target_payload(config))
+    try:
+        yield
+    finally:
+        reset_iotdb_target_context(token)
diff --git a/src/iotdb_mcp_server/services/targets.py b/src/iotdb_mcp_server/services/targets.py
new file mode 100644
index 0000000..3e51e52
--- /dev/null
+++ b/src/iotdb_mcp_server/services/targets.py
@@ -0,0 +1,533 @@
+#
+# 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.
+#
+
+import logging
+import json
+import os
+from pathlib import Path
+import tempfile
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.services.json_response import payload_response
+from iotdb_mcp_server.services.target_selection import (
+    apply_target_registry,
+    iotdb_target_response_context,
+    maybe_reload_target_registry,
+    select_target_config,
+)
+from iotdb_mcp_server.target_registry import (
+    IoTDBTargetRegistry,
+    mark_target_last_known_good,
+    target_from_mapping,
+)
+from iotdb_mcp_server.target_lifecycle import (
+    IoTDBTargetCandidateStore,
+    candidate_target,
+    same_target_identity,
+    verify_target_once,
+)
+
+
+def _registry(config: Config) -> IoTDBTargetRegistry:
+    maybe_reload_target_registry(config)
+    if config.target_registry is not None:
+        return config.target_registry
+    registry = IoTDBTargetRegistry({})
+    config.target_registry = registry
+    return registry
+
+
+def _targets_file(config: Config, targets_file: str | None = None) -> Path:
+    resolved = (
+        targets_file
+        or config.targets_file
+        or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")
+    ).strip()
+    if not resolved:
+        raise ValueError(
+            "No IoTDB targets file is configured. Pass targets_file or set "
+            "TIMESEEK_IOTDB_TARGETS_FILE to persist dynamic target changes."
+        )
+    return Path(resolved).expanduser()
+
+
+def _persist_registry(
+    config: Config, registry: IoTDBTargetRegistry, targets_file: str | None
+) -> None:
+    path = _targets_file(config, targets_file)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    payload = (
+        json.dumps(registry.as_dict(include_secret=True), ensure_ascii=False, indent=2)
+        + "\n"
+    )
+    temporary_path: Path | None = None
+    try:
+        with tempfile.NamedTemporaryFile(
+            mode="w",
+            encoding="utf-8",
+            dir=path.parent,
+            prefix=f".{path.name}.",
+            suffix=".tmp",
+            delete=False,
+        ) as handle:
+            handle.write(payload)
+            handle.flush()
+            os.fsync(handle.fileno())
+            temporary_path = Path(handle.name)
+        temporary_path.chmod(0o600)
+        os.replace(temporary_path, path)
+    finally:
+        if temporary_path is not None and temporary_path.exists():
+            temporary_path.unlink()
+    try:
+        path.chmod(0o600)
+    except OSError:
+        pass
+    config.targets_file = str(path)
+    try:
+        config.targets_file_mtime_ns = path.stat().st_mtime_ns
+    except OSError:
+        config.targets_file_mtime_ns = None
+
+
+def _targets_payload(config: Config) -> dict[str, object]:
+    registry = _registry(config)
+    return {
+        "default_target_id": registry.default_target_id,
+        "active_target_id": config.target_id or None,
+        "targets": registry.list_targets(include_secret=False),
+        "selector_fields": [
+            "target_id",
+            "id",
+            "display_name",
+            "kind",
+            "host",
+            "port",
+            "database",
+            "sql_dialect",
+            "user",
+            "node_url",
+            "node_urls",
+        ],
+        "dynamic_tools": [
+            "prepare_iotdb_target",
+            "connect_iotdb_target",
+            "register_iotdb_target",
+            "remove_iotdb_target",
+            "set_default_iotdb_target",
+            "reload_iotdb_targets",
+        ],
+        "targets_file": config.targets_file
+        or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", ""),
+    }
+
+
+def register_target_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register IoTDB target discovery tools."""
+    candidate_store = IoTDBTargetCandidateStore()
+
+    def publish_target(
+        target,
+        *,
+        set_default: bool,
+        persist: bool | None,
+        targets_file: str | None,
+    ):
+        registry = _registry(config)
+        updated = registry
+        duplicate_ids = []
+        for item in registry.list_targets(include_secret=True):
+            existing = target_from_mapping(item)
+            if existing.target_id != target.target_id and same_target_identity(
+                existing, target
+            ):
+                duplicate_ids.append(existing.target_id)
+        for duplicate_id in duplicate_ids:
+            updated = updated.without_target(duplicate_id)
+        make_default = (
+            set_default
+            or updated.default_target_id is None
+            or registry.default_target_id in duplicate_ids
+        )
+        updated = updated.with_target(
+            target,
+            default_target_id=(
+                target.target_id if make_default else updated.default_target_id
+            ),
+        )
+        persist_resolved = (
+            persist
+            if persist is not None
+            else bool(
+                targets_file
+                or config.targets_file
+                or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")
+            )
+        )
+        if persist_resolved:
+            _persist_registry(config, updated, targets_file)
+        apply_target_registry(
+            config,
+            updated,
+            active_target_id=(
+                target.target_id if make_default else updated.default_target_id
+            ),
+            close_pools=True,
+        )
+        return updated, duplicate_ids, persist_resolved
+
+    def evict_on_connection_error(
+        target_id: str,
+        error: BaseException,
+        updated: IoTDBTargetRegistry,
+    ) -> None:
+        apply_target_registry(
+            config,
+            updated,
+            active_target_id=updated.default_target_id,
+            close_pools=False,
+        )
+        if config.targets_file or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", ""):
+            try:
+                _persist_registry(config, updated, None)
+            except Exception as persist_error:
+                logger.error(
+                    "Removed failed IoTDB target %s in memory but could not persist: %s",
+                    target_id,
+                    persist_error,
+                )
+        logger.error(
+            "Removed IoTDB target %s after connection error %s: %s",
+            target_id,
+            type(error).__name__,
+            error,
+        )
+
+    if config.session_manager is not None:
+        config.session_manager.set_connection_error_callback(evict_on_connection_error)
+
+    @mcp.tool()
+    async def list_iotdb_targets() -> list[TextContent]:
+        """List registered IoTDB targets with redacted credentials."""
+        return payload_response(
+            "list_iotdb_targets",
+            _targets_payload(config),
+            message="Registered IoTDB targets.",
+        )
+
+    @mcp.tool()
+    async def describe_iotdb_target(
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Describe one IoTDB target selected by id or selector fields."""
+        selected_config = select_target_config(
+            config, target_id=target_id, target=target
+        )
+        with iotdb_target_response_context(selected_config):
+            return payload_response(
+                "describe_iotdb_target",
+                selected_config.to_target().as_dict(include_secret=False),
+                message="IoTDB target description.",
+            )
+
+    @mcp.tool()
+    async def prepare_iotdb_target(
+        target_template: dict[str, object],
+        user_confirmed_retry: bool = False,
+    ) -> list[TextContent]:
+        """Create a credential-free, non-routable target candidate.
+
+        A previous failed endpoint requires an explicit user-requested retry.
+        Templates are memory-only and never appear in list_iotdb_targets.
+        """
+        candidate = candidate_store.prepare(
+            target_template,
+            user_confirmed_retry=user_confirmed_retry,
+        )
+        return payload_response(
+            "prepare_iotdb_target",
+            candidate.as_dict(),
+            message="IoTDB target candidate prepared; credentials are still required.",
+        )
+
+    @mcp.tool()
+    async def connect_iotdb_target(
+        candidate_id: str,
+        username: str,
+        password: str,
+        set_default: bool = True,
+        persist: bool | None = None,
+        targets_file: str | None = None,
+    ) -> list[TextContent]:
+        """Authenticate once and publish only after success.
+
+        When a targets file is configured, the verified credential is persisted
+        by default so MCP and iotdb-target-cli read the same target state.
+        """
+        candidate = candidate_store.consume(candidate_id)
+        target = candidate_target(candidate, user=username, password=password)
+        try:
+            verified = verify_target_once(target)
+        except Exception as exc:
+            candidate_store.record_failure(candidate)
+            logger.error(
+                "IoTDB candidate %s was deleted after connection failure: %s",
+                candidate_id,
+                exc,
+            )
+            raise ConnectionError(
+                "IoTDB connection failed; the one-time candidate was deleted. "
+                "Do not retry until the user explicitly requests reconnection. "
+                f"Cause: {type(exc).__name__}: {exc}"
+            ) from None
+        updated, duplicate_ids, persisted = publish_target(
+            verified,
+            set_default=set_default,
+            persist=persist,
+            targets_file=targets_file,
+        )
+        return payload_response(
+            "connect_iotdb_target",
+            {
+                "registered_target": verified.as_dict(include_secret=False),
+                "replaced_target_ids": duplicate_ids,
+                "registry": _targets_payload(config),
+                "persisted": persisted,
+            },
+            message="IoTDB target authenticated and registered.",
+        )
+
+    @mcp.tool()
+    async def register_iotdb_target(
+        target: dict[str, object],
+        set_default: bool = False,
+        persist: bool | None = None,
+        targets_file: str | None = None,
+        user_confirmed_retry: bool = False,
+    ) -> list[TextContent]:
+        """Compatibility entry point that verifies credentials before registration."""
+        if not isinstance(target, dict):
+            raise ValueError("target must be an object.")
+        username = target.get("user", target.get("username"))
+        if username is None or "password" not in target:
+            raise ValueError(
+                "register_iotdb_target requires user and an explicitly supplied password. "
+                "When credentials are unavailable, call prepare_iotdb_target and ask the user."
+            )
+        template = {
+            key: value
+            for key, value in target.items()
+            if key
+            not in {
+                "user",
+                "username",
+                "password",
+                "last_known_good_credential",
+                "verified_at",
+            }
+        }
+        candidate = candidate_store.prepare(
+            template,
+            user_confirmed_retry=user_confirmed_retry,
+        )
+        consumed = candidate_store.consume(candidate.candidate_id)
+        parsed_target = candidate_target(
+            consumed,
+            user=str(username),
+            password="" if target["password"] is None else str(target["password"]),
+        )
+        try:
+            verified = verify_target_once(parsed_target)
+        except Exception as exc:
+            candidate_store.record_failure(consumed)
+            raise ConnectionError(
+                "IoTDB connection failed; no target was registered. Do not retry "
+                "until the user explicitly requests reconnection. "
+                f"Cause: {type(exc).__name__}: {exc}"
+            ) from None
+        updated, duplicate_ids, persisted = publish_target(
+            verified,
+            set_default=set_default,
+            persist=persist,
+            targets_file=targets_file,
+        )
+        logger.info("Authenticated and registered IoTDB target %s", verified.target_id)
+        return payload_response(
+            "register_iotdb_target",
+            {
+                "registered_target": verified.as_dict(include_secret=False),
+                "replaced_target_ids": duplicate_ids,
+                "registry": _targets_payload(config),
+                "persisted": persisted,
+            },
+            message="IoTDB target authenticated and registered.",
+        )
+
+    @mcp.tool()
+    async def remember_iotdb_target_credential(
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        persist: bool = False,
+        targets_file: str | None = None,
+    ) -> list[TextContent]:
+        """Record the selected target's current credential as last-known-good.
+
+        Call this only after a connection/query has succeeded with the selected
+        target. This tool does not test login and does not try fallback
+        passwords.
+        """
+        registry = _registry(config)
+        selected_target = registry.resolve_selector(
+            target,
+            target_id=target_id or (None if target is not None else config.target_id),
+        )
+        updated_target = mark_target_last_known_good(selected_target)
+        updated = registry.with_target(updated_target)
+        apply_target_registry(
+            config,
+            updated,
+            active_target_id=(
+                config.target_id
+                if config.target_id
+                in {
+                    item["target_id"]
+                    for item in updated.list_targets(include_secret=False)
+                }
+                else updated.default_target_id
+            ),
+            close_pools=False,
+        )
+        if persist:
+            _persist_registry(config, updated, targets_file)
+        logger.info(
+            "Remembered IoTDB target credential for %s",
+            updated_target.target_id,
+        )
+        return payload_response(
+            "remember_iotdb_target_credential",
+            {
+                "remembered_target": updated_target.as_dict(include_secret=False),
+                "registry": _targets_payload(config),
+                "persisted": persist,
+            },
+            message="IoTDB target last-known-good credential recorded.",
+        )
+
+    @mcp.tool()
+    async def remove_iotdb_target(
+        target_id: str,
+        persist: bool = False,
+        targets_file: str | None = None,
+    ) -> list[TextContent]:
+        """Remove an IoTDB target from this running MCP process."""
+        registry = _registry(config)
+        updated = registry.without_target(target_id)
+        apply_target_registry(
+            config,
+            updated,
+            active_target_id=updated.default_target_id,
+            close_pools=True,
+        )
+        if persist:
+            _persist_registry(config, updated, targets_file)
+        logger.info("Removed IoTDB target %s", target_id)
+        return payload_response(
+            "remove_iotdb_target",
+            {
+                "removed_target_id": target_id,
+                "registry": _targets_payload(config),
+                "persisted": persist,
+            },
+            message="IoTDB target removed.",
+        )
+
+    @mcp.tool()
+    async def set_default_iotdb_target(
+        target_id: str,
+        persist: bool = False,
+        targets_file: str | None = None,
+    ) -> list[TextContent]:
+        """Set the default IoTDB target for this running MCP process."""
+        registry = _registry(config)
+        updated = registry.with_default(target_id)
+        apply_target_registry(
+            config,
+            updated,
+            active_target_id=target_id,
+            close_pools=False,
+        )
+        if persist:
+            _persist_registry(config, updated, targets_file)
+        logger.info("Set default IoTDB target %s", target_id)
+        return payload_response(
+            "set_default_iotdb_target",
+            {
+                "default_target_id": target_id,
+                "registry": _targets_payload(config),
+                "persisted": persist,
+            },
+            message="Default IoTDB target updated.",
+        )
+
+    @mcp.tool()
+    async def reload_iotdb_targets(
+        targets_file: str | None = None,
+        targets_json: str | None = None,
+    ) -> list[TextContent]:
+        """Reload IoTDB targets from env, a targets file, or an explicit JSON string."""
+        resolved_file = (
+            targets_file
+            or config.targets_file
+            or os.getenv("TIMESEEK_IOTDB_TARGETS_FILE", "")
+            or None
+        )
+        resolved_json = (
+            targets_json
+            or config.targets_json
+            or os.getenv("TIMESEEK_IOTDB_TARGETS_JSON", "")
+            or None
+        )
+        registry = IoTDBTargetRegistry.from_env(
+            targets_file=resolved_file,
+            targets_json=resolved_json,
+        )
+        mtime_ns = None
+        if resolved_file:
+            try:
+                mtime_ns = Path(resolved_file).expanduser().stat().st_mtime_ns
+            except OSError:
+                mtime_ns = None
+            config.targets_file = resolved_file
+        if resolved_json:
+            config.targets_json = resolved_json
+        apply_target_registry(
+            config,
+            registry,
+            targets_file_mtime_ns=mtime_ns,
+            close_pools=True,
+        )
+        logger.info("Reloaded IoTDB targets")
+        return payload_response(
+            "reload_iotdb_targets",
+            _targets_payload(config),
+            message="IoTDB targets reloaded.",
+        )
diff --git a/src/iotdb_mcp_server/services/timeseries.py b/src/iotdb_mcp_server/services/timeseries.py
new file mode 100644
index 0000000..89b034e
--- /dev/null
+++ b/src/iotdb_mcp_server/services/timeseries.py
@@ -0,0 +1,351 @@
+#
+# 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.
+#
+
+import logging
+import time
+from typing import Any
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import payload_response, sql_success_response
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    tree_session_pool,
+)
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_timeseries_ddl_permission(
+    config: Config, action: str, confirm: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_TIMESERIES_DDL", False):
+            raise PermissionError(
+                "Timeseries DDL tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_TIMESERIES_DDL=true to enable."
+            )
+
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_TIMESERIES_DDL_ALLOWED_USERS", "root") or "root")
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_TIMESERIES_DDL_ALLOWED_USERS."
+            )
+
+    if (
+        action == "DROP"
+        and _env_bool("IOTDB_REQUIRE_TIMESERIES_DROP_CONFIRM", True)
+        and not confirm
+    ):
+        raise PermissionError(
+            "DROP/DELETE TIMESERIES requires confirm=True by server policy."
+        )
+
+
+def _normalize_sql(sql: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError("DDL SQL cannot be empty.")
+    if cleaned.endswith(";"):
+        cleaned = cleaned[:-1].strip()
+    if ";" in cleaned:
+        raise ValueError("Only a single SQL statement is allowed.")
+    return cleaned
+
+
+def _ensure_prefix(sql: str, allowed_prefixes: tuple[str, ...], action_name: str) -> None:
+    upper = sql.upper()
+    if not upper.startswith(allowed_prefixes):
+        raise ValueError(
+            f"{action_name} only supports SQL starting with: {', '.join(allowed_prefixes)}"
+        )
+
+
+def _is_already_exists_error(message: str) -> bool:
+    lower = message.lower()
+    patterns = (
+        "already exist",
+        "already_exists",
+        "path already exists",
+        "timeseries already exists",
+    )
+    return any(p in lower for p in patterns)
+
+
+def register_timeseries_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register tree-model timeseries-level DDL tools."""
+
+    @mcp.tool()
+    async def create_timeseries_ddl(
+        ddl_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute tree-model CREATE TIMESERIES DDL.
+
+        Supported prefixes:
+        - CREATE TIMESERIES
+        - CREATE ALIGNED TIMESERIES
+        """
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="create_timeseries_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                _assert_timeseries_ddl_permission(selected_config, action="CREATE")
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(
+                    sql,
+                    ("CREATE TIMESERIES", "CREATE ALIGNED TIMESERIES"),
+                    "create_timeseries_ddl",
+                )
+                session = session_pool.get_session()
+                session.execute_non_query_statement(sql)
+                session.close()
+                return sql_success_response("create_timeseries_ddl", sql)
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute create_timeseries_ddl: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def create_timeseries_batch_ddl(
+        ddl_list: list[str],
+        continue_on_error: bool = True,
+        skip_if_exists: bool = True,
+        max_statements: int = 1000,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute a batch of tree-model CREATE TIMESERIES DDL in one session pipeline.
+
+        Supported prefixes in each statement:
+        - CREATE TIMESERIES
+        - CREATE ALIGNED TIMESERIES
+        """
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="create_timeseries_batch_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            started_at = time.perf_counter()
+            try:
+                _assert_timeseries_ddl_permission(selected_config, action="CREATE")
+
+                if not isinstance(ddl_list, list) or len(ddl_list) == 0:
+                    raise ValueError("ddl_list must be a non-empty list of SQL statements.")
+                if max_statements <= 0:
+                    raise ValueError("max_statements must be greater than 0.")
+                if len(ddl_list) > max_statements:
+                    raise ValueError(
+                        f"ddl_list length {len(ddl_list)} exceeds max_statements={max_statements}."
+                    )
+
+                validated_sqls: list[dict[str, Any]] = []
+                per_statement: list[dict[str, Any]] = []
+                failed_count = 0
+
+                for idx, raw_sql in enumerate(ddl_list, start=1):
+                    statement_started = time.perf_counter()
+                    try:
+                        sql = _normalize_sql(str(raw_sql))
+                        _ensure_prefix(
+                            sql,
+                            ("CREATE TIMESERIES", "CREATE ALIGNED TIMESERIES"),
+                            "create_timeseries_batch_ddl",
+                        )
+                        validated_sqls.append({"index": idx, "sql": sql})
+                    except Exception as e:
+                        failed_count += 1
+                        per_statement.append(
+                            {
+                                "index": idx,
+                                "sql": str(raw_sql),
+                                "status": "invalid_sql",
+                                "elapsed_ms": round(
+                                    (time.perf_counter() - statement_started) * 1000, 3
+                                ),
+                                "error": str(e),
+                            }
+                        )
+                        if not continue_on_error:
+                            break
+
+                applied_count = 0
+                skipped_count = 0
+                execution_aborted = False
+
+                if validated_sqls and (failed_count == 0 or continue_on_error):
+                    session = session_pool.get_session()
+                    for item in validated_sqls:
+                        idx = int(item["index"])
+                        sql = str(item["sql"])
+                        statement_started = time.perf_counter()
+                        status = "applied"
+                        error_msg = ""
+                        try:
+                            session.execute_non_query_statement(sql)
+                            applied_count += 1
+                        except Exception as e:
+                            if skip_if_exists and _is_already_exists_error(str(e)):
+                                status = "skipped_existing"
+                                skipped_count += 1
+                            else:
+                                status = "error"
+                                error_msg = str(e)
+                                failed_count += 1
+                                if not continue_on_error:
+                                    execution_aborted = True
+                        per_statement.append(
+                            {
+                                "index": idx,
+                                "sql": sql,
+                                "status": status,
+                                "elapsed_ms": round(
+                                    (time.perf_counter() - statement_started) * 1000, 3
+                                ),
+                                "error": error_msg,
+                            }
+                        )
+                        if execution_aborted:
+                            break
+
+                if session:
+                    session.close()
+
+                per_statement.sort(key=lambda x: x.get("index", 0))
+                total_elapsed_ms = round((time.perf_counter() - started_at) * 1000, 3)
+                executed_count = applied_count + skipped_count + (
+                    len([x for x in per_statement if x.get("status") == "error"])
+                )
+
+                return payload_response(
+                    "create_timeseries_batch_ddl",
+                    {
+                        "result": "success" if failed_count == 0 else "partial_success",
+                        "execution_mode": "single_session_pipeline",
+                        "continue_on_error": continue_on_error,
+                        "skip_if_exists": skip_if_exists,
+                        "total_input": len(ddl_list),
+                        "validated_count": len(validated_sqls),
+                        "executed_count": executed_count,
+                        "applied_count": applied_count,
+                        "skipped_count": skipped_count,
+                        "failed_count": failed_count,
+                        "total_elapsed_ms": total_elapsed_ms,
+                        "statements": per_statement,
+                    },
+                    message="Batch CREATE TIMESERIES execution completed.",
+                )
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute create_timeseries_batch_ddl: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def alter_timeseries_ddl(
+        ddl_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute tree-model ALTER TIMESERIES DDL."""
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="alter_timeseries_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                _assert_timeseries_ddl_permission(selected_config, action="ALTER")
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(sql, ("ALTER TIMESERIES",), "alter_timeseries_ddl")
+                session = session_pool.get_session()
+                session.execute_non_query_statement(sql)
+                session.close()
+                return sql_success_response("alter_timeseries_ddl", sql)
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute alter_timeseries_ddl: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def drop_timeseries_ddl(
+        ddl_sql: str,
+        confirm: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute tree-model DROP/DELETE TIMESERIES DDL (destructive)."""
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="drop_timeseries_ddl",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                _assert_timeseries_ddl_permission(
+                    selected_config, action="DROP", confirm=confirm
+                )
+                sql = _normalize_sql(ddl_sql)
+                _ensure_prefix(
+                    sql,
+                    ("DROP TIMESERIES", "DELETE TIMESERIES"),
+                    "drop_timeseries_ddl",
+                )
+                session = session_pool.get_session()
+                session.execute_non_query_statement(sql)
+                session.close()
+                return sql_success_response("drop_timeseries_ddl", sql)
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute drop_timeseries_ddl: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/tree_sql_guardrails.py b/src/iotdb_mcp_server/services/tree_sql_guardrails.py
new file mode 100644
index 0000000..7d2a87b
--- /dev/null
+++ b/src/iotdb_mcp_server/services/tree_sql_guardrails.py
@@ -0,0 +1,199 @@
+import re
+
+
+AGGREGATE_FUNCTIONS = (
+    "COUNT",
+    "SUM",
+    "AVG",
+    "MAX_VALUE",
+    "MIN_VALUE",
+    "FIRST_VALUE",
+    "LAST_VALUE",
+    "MAX",
+    "MIN",
+)
+
+
+def _split_top_level_csv(expr: str) -> list[str]:
+    parts: list[str] = []
+    cur: list[str] = []
+    depth = 0
+    in_single_quote = False
+
+    idx = 0
+    while idx < len(expr):
+        ch = expr[idx]
+        if ch == "'":
+            in_single_quote = not in_single_quote
+            cur.append(ch)
+        elif not in_single_quote and ch == "(":
+            depth += 1
+            cur.append(ch)
+        elif not in_single_quote and ch == ")":
+            depth = max(0, depth - 1)
+            cur.append(ch)
+        elif not in_single_quote and ch == "," and depth == 0:
+            part = "".join(cur).strip()
+            if part:
+                parts.append(part)
+            cur = []
+        else:
+            cur.append(ch)
+        idx += 1
+
+    tail = "".join(cur).strip()
+    if tail:
+        parts.append(tail)
+    return parts
+
+
+def _strip_alias(expr: str) -> str:
+    out = re.sub(r"\s+AS\s+[A-Z_][A-Z0-9_]*\s*$", "", expr, flags=re.IGNORECASE)
+    m = re.match(r"^(.*\S)\s+([A-Z_][A-Z0-9_]*)\s*$", out, flags=re.IGNORECASE)
+    if m and "(" in m.group(1):
+        return m.group(1)
+    return out
+
+
+def _strip_identifier_quotes(identifier: str) -> str:
+    value = identifier.strip()
+    if len(value) >= 2 and value[0] == value[-1] and value[0] in {"`", '"'}:
+        return value[1:-1].strip()
+    return value
+
+
+def _extract_projection_expr(sql: str) -> str:
+    match = re.search(
+        r"\bSELECT\b\s+(?:LAST\s+)?(.+?)\s+\bFROM\b",
+        sql,
+        flags=re.IGNORECASE | re.DOTALL,
+    )
+    if not match:
+        return ""
+    return match.group(1).strip()
+
+
+def _extract_from_identifier(sql: str) -> str:
+    match = re.search(r"\bFROM\s+([^\s,;]+)", sql, flags=re.IGNORECASE)
+    if not match:
+        return ""
+    identifier = match.group(1).strip()
+    if not identifier or identifier.startswith("("):
+        return ""
+    return _strip_identifier_quotes(identifier.rstrip(",;"))
+
+
+def _projection_measurement_names(sql: str) -> set[str]:
+    projection = _extract_projection_expr(sql)
+    if not projection:
+        return set()
+
+    aggregate_re = re.compile(
+        rf"^(?:{'|'.join(AGGREGATE_FUNCTIONS)})\s*\((.*)\)$",
+        flags=re.IGNORECASE | re.DOTALL,
+    )
+    simple_identifier_re = re.compile(
+        r"^[`\"]?[A-Za-z_][A-Za-z0-9_]*[`\"]?"
+        r"(?:\.[`\"]?[A-Za-z_][A-Za-z0-9_]*[`\"]?)*$"
+    )
+
+    measurements: set[str] = set()
+    for raw_item in _split_top_level_csv(projection):
+        item = _strip_alias(raw_item.strip())
+        aggregate = aggregate_re.match(item)
+        if aggregate:
+            item = aggregate.group(1).strip()
+        if item == "*" or not simple_identifier_re.fullmatch(item):
+            continue
+        measurement = _strip_identifier_quotes(item.rsplit(".", 1)[-1])
+        if measurement:
+            measurements.add(measurement.lower())
+    return measurements
+
+
+def validate_tree_query_shape(sql: str) -> list[dict[str, object]]:
+    """Return high-confidence Tree SQL shape issues before runtime execution.
+
+    This intentionally covers only patterns that are almost certainly wrong in
+    ordinary Tree reads. It does not try to infer schema or rewrite SQL.
+    """
+    from_identifier = _extract_from_identifier(sql)
+    if not from_identifier.lower().startswith("root.") or "*" in from_identifier:
+        return []
+
+    path_segments = [_strip_identifier_quotes(part) for part in from_identifier.split(".") if part]
+    if len(path_segments) < 4:
+        return []
+
+    from_tail = path_segments[-1].lower()
+    projection_measurements = _projection_measurement_names(sql)
+    if from_tail not in projection_measurements:
+        return []
+
+    device_path = ".".join(path_segments[:-1])
+    measurement = path_segments[-1]
+    return [
+        {
+            "code": "tree_dialect_from_must_use_device_path",
+            "message": (
+                "Tree SQL should use a device/path-pattern in FROM and project "
+                "measurement names in SELECT. The FROM path appears to include "
+                f"measurement `{measurement}`."
+            ),
+            "rewrite_hint": (
+                f"Use FROM {device_path} and keep `{measurement}` in the SELECT "
+                "projection or aggregate expression."
+            ),
+            "from_path": from_identifier,
+            "suggested_from_path": device_path,
+            "measurement": measurement,
+        }
+    ]
+
+
+def tree_from_wildcard_runtime_hint(sql: str, *, row_count: int | None = None) -> dict[str, object] | None:
+    """Return a non-blocking hint for device-path wildcard FROM shapes.
+
+    IoTDB Tree accepts prefix paths such as ``root.sg.d1.*`` syntactically, but
+    agents often build that shape by appending ``.*`` to a device path copied
+    from schema metadata. For ordinary measurement projections, the canonical
+    query shape is ``SELECT measurement FROM root.sg.d1``. This helper is a
+    runtime diagnostic only; it does not reject broad scans such as ``root.**``.
+    """
+    from_identifier = _extract_from_identifier(sql)
+    if not from_identifier.lower().startswith("root."):
+        return None
+    if not from_identifier.endswith(".*"):
+        return None
+    suggested_from_path = from_identifier[:-2]
+    if not suggested_from_path or suggested_from_path.endswith("."):
+        return None
+    projection_measurements = _projection_measurement_names(sql)
+    if not projection_measurements:
+        return None
+
+    qualifier = " If this query returned zero rows," if row_count == 0 else ""
+    return {
+        "code": "tree_dialect_from_path_wildcard_noncanonical",
+        "message": (
+            f"Tree SQL `FROM {from_identifier}` is a path-pattern form, not the "
+            "canonical device-scoped form for ordinary measurement reads."
+            f"{qualifier} retry with the device path in FROM and keep measurement names in SELECT."
+        ),
+        "rewrite_hint": (
+            f"Use FROM {suggested_from_path}; metadata `paths`/`timeseries` values are full "
+            "timeseries paths and should not be copied as FROM path.* clauses."
+        ),
+        "from_path": from_identifier,
+        "suggested_from_path": suggested_from_path,
+    }
+
+
+def assert_tree_query_shape(sql: str) -> None:
+    issues = validate_tree_query_shape(sql)
+    if not issues:
+        return
+    issue = issues[0]
+    raise ValueError(
+        f"{issue['code']}: {issue['message']} {issue['rewrite_hint']}"
+    )
diff --git a/src/iotdb_mcp_server/services/ttl.py b/src/iotdb_mcp_server/services/ttl.py
new file mode 100644
index 0000000..b22786e
--- /dev/null
+++ b/src/iotdb_mcp_server/services/ttl.py
@@ -0,0 +1,185 @@
+#
+# 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.
+#
+
+import logging
+
+from iotdb.Session import Session
+from iotdb.utils.SessionDataSet import SessionDataSet
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import (
+    csv_payload_response,
+    sql_success_response,
+)
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    tree_session_pool,
+)
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_ttl_permission(
+    config: Config, action: str, confirm_unset: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_TTL_SQL", False):
+            raise PermissionError(
+                "TTL tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_TTL_SQL=true to enable."
+            )
+
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_TTL_ALLOWED_USERS", "root") or "root")
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_TTL_ALLOWED_USERS."
+            )
+
+    if (
+        action == "UNSET"
+        and _env_bool("IOTDB_REQUIRE_TTL_UNSET_CONFIRM", True)
+        and not confirm_unset
+    ):
+        raise PermissionError(
+            "UNSET TTL requires confirm_unset=True by server policy."
+        )
+
+
+def _normalize_sql(sql: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError("TTL SQL cannot be empty.")
+    if cleaned.endswith(";"):
+        cleaned = cleaned[:-1].strip()
+    if ";" in cleaned:
+        raise ValueError("Only a single SQL statement is allowed.")
+    return cleaned
+
+
+def _format_result(res: SessionDataSet, session: Session) -> list[TextContent]:
+    columns = res.get_column_names()
+    rows: list[str] = []
+    while res.has_next():
+        row = res.next().get_fields()
+        rows.append(",".join(map(str, row)))
+    session.close()
+    return csv_payload_response("ttl_query", columns, rows)
+
+
+def register_ttl_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register TTL SQL tools.
+
+    Note: TTL grammar is tree-model SQL.
+    """
+    @mcp.tool()
+    async def ttl_command(
+        ttl_sql: str,
+        confirm_unset: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute TTL command SQL.
+
+        Supported prefixes:
+        - SET TTL
+        - UNSET TTL
+        """
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="ttl_command",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                sql = _normalize_sql(ttl_sql)
+                upper = sql.upper()
+                if upper.startswith("SET TTL"):
+                    _assert_ttl_permission(selected_config, action="SET")
+                elif upper.startswith("UNSET TTL"):
+                    _assert_ttl_permission(
+                        selected_config, action="UNSET", confirm_unset=confirm_unset
+                    )
+                else:
+                    raise ValueError(
+                        "ttl_command only supports SQL starting with: SET TTL, UNSET TTL"
+                    )
+
+                session = session_pool.get_session()
+                session.execute_non_query_statement(sql)
+                session.close()
+                return sql_success_response("ttl_command", sql)
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute ttl_command: {str(e)}")
+                raise
+
+    @mcp.tool()
+    async def ttl_query(
+        ttl_sql: str,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute TTL query SQL.
+
+        Supported prefixes:
+        - SHOW TTL ON
+        - SHOW ALL TTL
+        """
+        selected_config, session_pool = tree_session_pool(
+            config,
+            target_id=target_id,
+            target=target,
+            max_pool_size=100,
+            wait_timeout_in_ms=5000,
+            tool_name="ttl_query",
+        )
+        with iotdb_target_response_context(selected_config):
+            session = None
+            try:
+                _assert_ttl_permission(selected_config, action="SHOW")
+                sql = _normalize_sql(ttl_sql)
+                upper = sql.upper()
+                if not upper.startswith(("SHOW TTL ON", "SHOW ALL TTL")):
+                    raise ValueError(
+                        "ttl_query only supports SQL starting with: SHOW TTL ON, SHOW ALL TTL"
+                    )
+
+                session = session_pool.get_session()
+                res = session.execute_query_statement(sql)
+                return _format_result(res, session)
+            except Exception as e:
+                if session:
+                    session.close()
+                logger.error(f"Failed to execute ttl_query: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/services/udf.py b/src/iotdb_mcp_server/services/udf.py
new file mode 100644
index 0000000..e720c35
--- /dev/null
+++ b/src/iotdb_mcp_server/services/udf.py
@@ -0,0 +1,534 @@
+from __future__ import annotations
+
+import datetime
+import logging
+import re
+import uuid
+from typing import Any
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.result_store import result_store_from_export_path
+from iotdb_mcp_server.services.json_response import (
+    csv_result_payload_response,
+    payload_response,
+    text_payload_response,
+)
+from iotdb_mcp_server.services.query import (
+    _ensure_export_directory,
+    _prepare_table_res,
+    _prepare_tree_res,
+    sanitize_filename,
+)
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+from iotdb_mcp_server.services.tree_sql_guardrails import assert_tree_query_shape
+
+_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+_TREE_PATH_RE = re.compile(r"^root(?:\.[A-Za-z_][A-Za-z0-9_]*|\.\*|\.\*\*)*$")
+_TREE_EXPR_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
+_DANGEROUS_SQL_RE = re.compile(
+    r";|--|/\*|\*/|\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|LOAD|SET|GRANT|REVOKE|TRUNCATE)\b",
+    re.IGNORECASE,
+)
+
+
+def _validate_identifier(value: str, label: str) -> str:
+    cleaned = str(value or "").strip()
+    if not _IDENTIFIER_RE.fullmatch(cleaned):
+        raise ValueError(f"Invalid {label}. Use letters, numbers, and underscore only.")
+    return cleaned
+
+
+def _validate_function_name(function_name: str) -> str:
+    return _validate_identifier(function_name, "function_name")
+
+
+def _validate_tree_path(path: str) -> str:
+    cleaned = str(path or "").strip()
+    if not _TREE_PATH_RE.fullmatch(cleaned):
+        raise ValueError("Invalid tree from_path. Use path like root.sg.d1 or root.sg.*.")
+    return cleaned
+
+
+def _validate_expression(expression: str, *, dialect: str) -> str:
+    cleaned = str(expression or "").strip()
+    if not cleaned:
+        raise ValueError("UDF expression cannot be empty.")
+    if _DANGEROUS_SQL_RE.search(cleaned):
+        raise ValueError("UDF expression contains unsupported SQL syntax.")
+    if dialect == "table":
+        return _validate_identifier(cleaned, "table expression")
+    if not _TREE_EXPR_RE.fullmatch(cleaned):
+        raise ValueError("Invalid tree expression. Use measurement names or relative paths.")
+    return cleaned
+
+
+def _validate_where_clause(where_clause: str | None) -> str | None:
+    if where_clause is None:
+        return None
+    cleaned = str(where_clause).strip()
+    if not cleaned:
+        return None
+    if _DANGEROUS_SQL_RE.search(cleaned):
+        raise ValueError("where_clause contains unsupported or non-readonly SQL syntax.")
+    if cleaned.upper().startswith("WHERE "):
+        cleaned = cleaned[6:].strip()
+    if not cleaned:
+        return None
+    return cleaned
+
+
+def _render_attribute_value(value: Any) -> str:
+    text = str(value)
+    escaped = text.replace("\\", "\\\\").replace('"', '\\"')
+    return f'"{escaped}"'
+
+
+def _render_attributes(attributes: dict[str, Any] | None) -> list[str]:
+    if not attributes:
+        return []
+    rendered = []
+    for key, value in attributes.items():
+        safe_key = _validate_identifier(str(key), "attribute key")
+        rendered.append(f'"{safe_key}"={_render_attribute_value(value)}')
+    return rendered
+
+
+def _normalize_expressions(expressions: list[str] | str | None, *, dialect: str) -> list[str]:
+    if expressions is None:
+        raise ValueError("expressions is required.")
+    raw_values = [expressions] if isinstance(expressions, str) else list(expressions)
+    if not raw_values:
+        raise ValueError("expressions cannot be empty.")
+    return [_validate_expression(str(value), dialect=dialect) for value in raw_values]
+
+
+def _build_udf_query_plan(
+    *,
+    dialect: str,
+    function_name: str,
+    expressions: list[str] | str,
+    table_name: str | None = None,
+    from_path: str | None = None,
+    attributes: dict[str, Any] | None = None,
+    where_clause: str | None = None,
+    limit: int | None = None,
+    alias: str | None = None,
+    align_by_device: bool = False,
+) -> dict[str, Any]:
+    safe_dialect = str(dialect or "").strip().lower()
+    if safe_dialect not in {"tree", "table"}:
+        raise ValueError("dialect must be tree or table.")
+    safe_function = _validate_function_name(function_name)
+    safe_expressions = _normalize_expressions(expressions, dialect=safe_dialect)
+    function_args = safe_expressions + _render_attributes(attributes)
+    projection = f"{safe_function}({', '.join(function_args)})"
+    if alias:
+        projection += f" AS {_validate_identifier(alias, 'alias')}"
+
+    where = _validate_where_clause(where_clause)
+    clauses: list[str]
+    if safe_dialect == "table":
+        source = _validate_identifier(str(table_name or ""), "table_name")
+        clauses = [f"SELECT {projection}", f"FROM {source}"]
+    else:
+        source = _validate_tree_path(str(from_path or ""))
+        clauses = [f"SELECT {projection}", f"FROM {source}"]
+
+    if where:
+        clauses.append(f"WHERE {where}")
+    if limit is not None:
+        safe_limit = int(limit)
+        if safe_limit <= 0:
+            raise ValueError("limit must be positive.")
+        clauses.append(f"LIMIT {safe_limit}")
+    if safe_dialect == "tree" and align_by_device:
+        clauses.append("ALIGN BY DEVICE")
+
+    sql = " ".join(clauses)
+    return {
+        "version": "v0",
+        "kind": "iotdb_udf_query_plan_ir",
+        "sql": sql,
+        "dialect": safe_dialect,
+        "function_name": safe_function,
+        "expressions": safe_expressions,
+        "source": {"table_name": source} if safe_dialect == "table" else {"from_path": source},
+        "attributes": attributes or {},
+        "where_clause": where,
+        "limit": limit,
+        "alias": alias,
+        "align_by_device": bool(align_by_device),
+        "readonly": True,
+    }
+
+
+def _export_dataset(
+    *,
+    res: Any,
+    session: Any,
+    selected_config: Config,
+    tool_name: str,
+    query_sql: str,
+    filename: str | None,
+    fmt: str,
+    logger: logging.Logger,
+) -> list[TextContent]:
+    try:
+        df = res.todf()
+    finally:
+        session.close()
+
+    timestamp = int(datetime.datetime.now().timestamp())
+    if filename is None:
+        filename = f"udf_{uuid.uuid4().hex[:4]}_{timestamp}"
+
+    fmt_lower = fmt.lower()
+    if fmt_lower == "csv":
+        if filename.lower().endswith(".csv"):
+            filename = filename[:-4]
+        filepath = sanitize_filename(f"{filename}.csv", selected_config.export_path)
+        df.to_csv(filepath, index=False)
+    elif fmt_lower == "excel":
+        if filename.lower().endswith(".xlsx"):
+            filename = filename[:-5]
+        filepath = sanitize_filename(f"{filename}.xlsx", selected_config.export_path)
+        df.to_excel(filepath, index=False)
+    else:
+        raise ValueError("format must be either 'csv' or 'excel'.")
+
+    preview_rows = min(10, len(df))
+    preview_data = [",".join(df.columns)]
+    for index in range(preview_rows):
+        preview_data.append(",".join(map(str, df.iloc[index])))
+    logger.info("Exported UDF query result to %s", filepath)
+    return text_payload_response(
+        tool_name,
+        f"Query results exported to {filepath}\n\nSQL:\n{query_sql}\n\nPreview (first {preview_rows} rows):\n"
+        + "\n".join(preview_data),
+    )
+
+
+def _prepare_udf_res(
+    res: Any,
+    session: Any,
+    tool_name: str,
+    export_path: str,
+    plan: dict[str, Any],
+    owner_session_id: str | None = None,
+    max_inline_rows: int | None = None,
+    page_size_rows: int | None = None,
+) -> list[TextContent]:
+    columns = res.get_column_names()
+
+    def rows():
+        while res.has_next():
+            record = res.next()
+            fields = record.get_fields()
+            if plan["dialect"] == "tree" and columns and columns[0] == "Time":
+                yield str(record.get_timestamp()) + "," + ",".join(
+                    map(str, fields)
+                )
+            else:
+                yield ",".join(map(str, fields))
+
+    try:
+        return csv_result_payload_response(
+            tool_name,
+            columns,
+            rows(),
+            result_store=result_store_from_export_path(export_path),
+            source={"query_sql": plan["sql"], "udf_query_plan": plan},
+            diagnostics=[
+                {
+                    "code": "iotdb_udf_query_plan",
+                    "query_sql": plan["sql"],
+                    "function_name": plan["function_name"],
+                    "dialect": plan["dialect"],
+                    "readonly": True,
+                }
+            ],
+            owner_session_id=owner_session_id,
+            max_inline_rows=max_inline_rows,
+            page_size_rows=page_size_rows,
+        )
+    finally:
+        session.close()
+
+
+def register_udf_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register read-only IoTDB UDF discovery and execution tools."""
+    _ensure_export_directory(config.export_path, logger)
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def list_udf_functions(
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """List IoTDB functions/UDFs visible to the selected target via SHOW FUNCTIONS."""
+        selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="list_udf_functions")
+        with iotdb_target_response_context(selected_config):
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="list_udf_functions",
+                )
+                session = None
+                try:
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement("SHOW FUNCTIONS")
+                    return _prepare_tree_res(
+                        res,
+                        session,
+                        "list_udf_functions",
+                        selected_config.export_path,
+                        "SHOW FUNCTIONS",
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                except Exception:
+                    if session:
+                        session.close()
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="list_udf_functions",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement("SHOW FUNCTIONS")
+                return _prepare_table_res(
+                    res,
+                    table_session,
+                    "list_udf_functions",
+                    selected_config.export_path,
+                    "SHOW FUNCTIONS",
+                    owner_session_id=owner_session_id,
+                    max_inline_rows=max_inline_rows,
+                    page_size_rows=page_size_rows,
+                )
+            except Exception:
+                if table_session:
+                    table_session.close()
+                raise
+
+    @mcp.tool()
+    async def prepare_udf_query(
+        function_name: str,
+        expressions: list[str] | str,
+        table_name: str | None = None,
+        from_path: str | None = None,
+        attributes: dict[str, object] | None = None,
+        where_clause: str | None = None,
+        limit: int | None = None,
+        alias: str | None = None,
+        align_by_device: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Build a conservative read-only SQL plan for invoking an IoTDB UDF."""
+        selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="prepare_udf_query")
+        with iotdb_target_response_context(selected_config):
+            payload = _build_udf_query_plan(
+                dialect=selected_config.sql_dialect,
+                function_name=function_name,
+                expressions=expressions,
+                table_name=table_name,
+                from_path=from_path,
+                attributes=attributes,
+                where_clause=where_clause,
+                limit=limit,
+                alias=alias,
+                align_by_device=align_by_device,
+            )
+            return payload_response("prepare_udf_query", payload, message="UDF query plan prepared.")
+
+    @mcp.tool()
+    async def execute_udf_query(
+        function_name: str,
+        expressions: list[str] | str,
+        table_name: str | None = None,
+        from_path: str | None = None,
+        attributes: dict[str, object] | None = None,
+        where_clause: str | None = None,
+        limit: int | None = None,
+        alias: str | None = None,
+        align_by_device: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+        owner_session_id: str | None = None,
+        max_inline_rows: int | None = None,
+        page_size_rows: int | None = None,
+    ) -> list[TextContent]:
+        """Execute one read-only IoTDB UDF SELECT built from validated inputs."""
+        selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="execute_udf_query")
+        with iotdb_target_response_context(selected_config):
+            plan = _build_udf_query_plan(
+                dialect=selected_config.sql_dialect,
+                function_name=function_name,
+                expressions=expressions,
+                table_name=table_name,
+                from_path=from_path,
+                attributes=attributes,
+                where_clause=where_clause,
+                limit=limit,
+                alias=alias,
+                align_by_device=align_by_device,
+            )
+            sql = plan["sql"]
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="execute_udf_query",
+                )
+                session = None
+                try:
+                    assert_tree_query_shape(sql)
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(sql)
+                    return _prepare_udf_res(
+                        res,
+                        session,
+                        "execute_udf_query",
+                        selected_config.export_path,
+                        plan,
+                        owner_session_id=owner_session_id,
+                        max_inline_rows=max_inline_rows,
+                        page_size_rows=page_size_rows,
+                    )
+                except Exception:
+                    if session:
+                        session.close()
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="execute_udf_query",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(sql)
+                return _prepare_udf_res(
+                    res,
+                    table_session,
+                    "execute_udf_query",
+                    selected_config.export_path,
+                    plan,
+                    owner_session_id=owner_session_id,
+                    max_inline_rows=max_inline_rows,
+                    page_size_rows=page_size_rows,
+                )
+            except Exception:
+                if table_session:
+                    table_session.close()
+                raise
+
+    @mcp.tool()
+    async def export_udf_query(
+        function_name: str,
+        expressions: list[str] | str,
+        table_name: str | None = None,
+        from_path: str | None = None,
+        attributes: dict[str, object] | None = None,
+        where_clause: str | None = None,
+        limit: int | None = None,
+        alias: str | None = None,
+        align_by_device: bool = False,
+        format: str = "csv",
+        filename: str | None = None,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute one read-only IoTDB UDF SELECT and export the result set."""
+        selected_config = select_target_config(config, target_id=target_id, target=target, tool_name="export_udf_query")
+        with iotdb_target_response_context(selected_config):
+            _ensure_export_directory(selected_config.export_path, logger)
+            plan = _build_udf_query_plan(
+                dialect=selected_config.sql_dialect,
+                function_name=function_name,
+                expressions=expressions,
+                table_name=table_name,
+                from_path=from_path,
+                attributes=attributes,
+                where_clause=where_clause,
+                limit=limit,
+                alias=alias,
+                align_by_device=align_by_device,
+            )
+            sql = plan["sql"]
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    config,
+                    target_id=selected_config.target_id,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="export_udf_query",
+                )
+                session = None
+                try:
+                    assert_tree_query_shape(sql)
+                    session = session_pool.get_session()
+                    res = session.execute_query_statement(sql)
+                    return _export_dataset(
+                        res=res,
+                        session=session,
+                        selected_config=selected_config,
+                        tool_name="export_udf_query",
+                        query_sql=sql,
+                        filename=filename,
+                        fmt=format,
+                        logger=logger,
+                    )
+                except Exception:
+                    if session:
+                        session.close()
+                    raise
+
+            _, session_pool = table_session_pool(
+                config,
+                target_id=selected_config.target_id,
+                max_pool_size=max_pool_size,
+                tool_name="export_udf_query",
+            )
+            table_session = None
+            try:
+                table_session = session_pool.get_session()
+                res = table_session.execute_query_statement(sql)
+                return _export_dataset(
+                    res=res,
+                    session=table_session,
+                    selected_config=selected_config,
+                    tool_name="export_udf_query",
+                    query_sql=sql,
+                    filename=filename,
+                    fmt=format,
+                    logger=logger,
+                )
+            except Exception:
+                if table_session:
+                    table_session.close()
+                raise
diff --git a/src/iotdb_mcp_server/services/write.py b/src/iotdb_mcp_server/services/write.py
new file mode 100644
index 0000000..5250112
--- /dev/null
+++ b/src/iotdb_mcp_server/services/write.py
@@ -0,0 +1,163 @@
+#
+# 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.
+#
+
+import logging
+
+from mcp.types import TextContent
+
+from iotdb_mcp_server.config import Config
+from iotdb_mcp_server.runtime_policy import dynamic_env_bool
+from iotdb_mcp_server.runtime_policy import dynamic_getenv
+from iotdb_mcp_server.runtime_policy import strict_permission_enforcement
+from iotdb_mcp_server.services.json_response import sql_success_response
+from iotdb_mcp_server.services.target_selection import (
+    iotdb_target_response_context,
+    select_target_config,
+    table_session_pool,
+    tree_session_pool,
+)
+
+
+def _env_bool(name: str, default: bool) -> bool:
+    return dynamic_env_bool(name, default)
+
+
+def _csv_set(value: str) -> set[str]:
+    return {item.strip() for item in value.split(",") if item.strip()}
+
+
+def _assert_write_permission(
+    config: Config, action: str, confirm_delete: bool = False
+) -> None:
+    if strict_permission_enforcement():
+        if not _env_bool("IOTDB_ENABLE_WRITE_DML", False):
+            raise PermissionError(
+                "Write DML tools are disabled by strict MCP policy. "
+                "Set IOTDB_ENABLE_WRITE_DML=true to enable."
+            )
+
+        allowed_users = _csv_set(dynamic_getenv("IOTDB_WRITE_ALLOWED_USERS", "root") or "root")
+        if "*" not in allowed_users and config.user not in allowed_users:
+            raise PermissionError(
+                f"Current MCP user '{config.user}' is not allowed by IOTDB_WRITE_ALLOWED_USERS."
+            )
+
+    if (
+        action == "DELETE"
+        and _env_bool("IOTDB_REQUIRE_DELETE_CONFIRM", True)
+        and not confirm_delete
+    ):
+        raise PermissionError(
+            "DELETE operation requires confirm_delete=True by server policy."
+        )
+
+
+def _normalize_sql(sql: str) -> str:
+    cleaned = sql.strip()
+    if not cleaned:
+        raise ValueError("Write SQL cannot be empty.")
+    if cleaned.endswith(";"):
+        cleaned = cleaned[:-1].strip()
+    if ";" in cleaned:
+        raise ValueError("Only a single SQL statement is allowed.")
+    return cleaned
+
+
+def register_write_tools(mcp, config: Config, logger: logging.Logger) -> None:
+    """Register write-related DML tools with per-call IoTDB target selection."""
+    max_pool_size = 100
+
+    @mcp.tool()
+    async def write_query(
+        write_sql: str,
+        confirm_delete: bool = False,
+        target_id: str | None = None,
+        target: dict[str, object] | None = None,
+    ) -> list[TextContent]:
+        """Execute exactly one write statement on the selected IoTDB target."""
+        selected_config = select_target_config(config, target_id=target_id, target=target)
+        with iotdb_target_response_context(selected_config):
+            sql = _normalize_sql(write_sql)
+            upper = sql.upper()
+
+            if selected_config.sql_dialect == "tree":
+                _, session_pool = tree_session_pool(
+                    selected_config,
+                    max_pool_size=max_pool_size,
+                    wait_timeout_in_ms=5000,
+                    tool_name="write_query",
+                )
+                session = None
+                try:
+                    if upper.startswith("INSERT INTO"):
+                        _assert_write_permission(selected_config, action="INSERT")
+                    elif upper.startswith("DELETE FROM"):
+                        _assert_write_permission(
+                            selected_config,
+                            action="DELETE",
+                            confirm_delete=confirm_delete,
+                        )
+                    else:
+                        raise ValueError(
+                            "tree write_query only supports SQL starting with: "
+                            "INSERT INTO, DELETE FROM"
+                        )
+
+                    session = session_pool.get_session()
+                    session.execute_non_query_statement(sql)
+                    session.close()
+                    return sql_success_response("write_query", sql)
+                except Exception as e:
+                    if session:
+                        session.close()
+                    logger.error(f"Failed to execute tree write_query: {str(e)}")
+                    raise
+
+            _, session_pool = table_session_pool(
+                selected_config,
+                max_pool_size=max_pool_size,
+                tool_name="write_query",
+            )
+            table_session = None
+            try:
+                if upper.startswith("INSERT INTO"):
+                    _assert_write_permission(selected_config, action="INSERT")
+                elif upper.startswith("UPDATE"):
+                    _assert_write_permission(selected_config, action="UPDATE")
+                elif upper.startswith("DELETE FROM") or upper.startswith("DELETE DEVICES"):
+                    _assert_write_permission(
+                        selected_config,
+                        action="DELETE",
+                        confirm_delete=confirm_delete,
+                    )
+                else:
+                    raise ValueError(
+                        "table write_query only supports SQL starting with: "
+                        "INSERT INTO, UPDATE, DELETE FROM, DELETE DEVICES"
+                    )
+
+                table_session = session_pool.get_session()
+                table_session.execute_non_query_statement(sql)
+                table_session.close()
+                return sql_success_response("write_query", sql)
+            except Exception as e:
+                if table_session:
+                    table_session.close()
+                logger.error(f"Failed to execute table write_query: {str(e)}")
+                raise
diff --git a/src/iotdb_mcp_server/session_manager.py b/src/iotdb_mcp_server/session_manager.py
new file mode 100644
index 0000000..3321440
--- /dev/null
+++ b/src/iotdb_mcp_server/session_manager.py
@@ -0,0 +1,463 @@
+# 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.
+#
+
+from __future__ import annotations
+
+from typing import Any, Callable, Mapping
+
+from iotdb.SessionPool import PoolConfig, SessionPool
+from iotdb.table_session import TableSession
+from iotdb.table_session_pool import TableSessionPoolConfig
+from iotdb.utils.exception import IoTDBConnectionException
+
+from iotdb_mcp_server.target_registry import (
+    IoTDBTarget,
+    IoTDBTargetRegistry,
+    merge_target,
+    target_from_mapping,
+)
+
+
+def target_from_config(config: Any) -> IoTDBTarget:
+    if isinstance(config, IoTDBTarget):
+        return config
+    if hasattr(config, "to_target"):
+        return config.to_target()
+
+    keys = (
+        "target_id",
+        "display_name",
+        "target_kind",
+        "kind",
+        "host",
+        "port",
+        "node_urls",
+        "user",
+        "password",
+        "database",
+        "sql_dialect",
+        "timezone",
+        "export_path",
+        "iotdb_home",
+        "use_ssl",
+        "ca_certs",
+        "connection_timeout_in_ms",
+        "enable_redirection",
+        "enable_compression",
+        "fetch_size",
+        "max_retry",
+        "max_pool_size",
+        "tree_wait_timeout_in_ms",
+        "table_wait_timeout_in_ms",
+        "policy",
+        "last_known_good_credential",
+        "verified_at",
+    )
+    payload: dict[str, Any] = {}
+    for key in keys:
+        if hasattr(config, key):
+            payload[key] = getattr(config, key)
+    if "target_kind" in payload and "kind" not in payload:
+        payload["kind"] = payload["target_kind"]
+    return target_from_mapping(payload)
+
+
+def _target_with_overrides(
+    config_or_target: Any,
+    overrides: Mapping[str, Any] | None = None,
+) -> IoTDBTarget:
+    target = target_from_config(config_or_target)
+    clean_overrides = {
+        key: value for key, value in (overrides or {}).items() if value is not None
+    }
+    if not clean_overrides:
+        return target
+    return merge_target(target, clean_overrides)
+
+
+def make_tree_pool_config(target: IoTDBTarget) -> PoolConfig:
+    return PoolConfig(
+        host=target.host,
+        port=target.port,
+        node_urls=None,
+        user_name=target.user,
+        password=target.password,
+        fetch_size=target.fetch_size,
+        time_zone=target.timezone,
+        max_retry=target.max_retry,
+        enable_compression=target.enable_compression,
+        enable_redirection=target.enable_redirection,
+        use_ssl=target.use_ssl,
+        ca_certs=target.ca_certs or None,
+        connection_timeout_in_ms=target.connection_timeout_in_ms,
+    )
+
+
+def make_table_pool_config(
+    target: IoTDBTarget, database: str | None = None
+) -> TableSessionPoolConfig:
+    resolved_database = database if database is not None else target.database
+    return TableSessionPoolConfig(
+        node_urls=list(target.node_urls),
+        username=target.user,
+        password=target.password,
+        max_pool_size=target.max_pool_size,
+        database=resolved_database or None,
+        fetch_size=target.fetch_size,
+        time_zone=target.timezone,
+        enable_redirection=target.enable_redirection,
+        enable_compression=target.enable_compression,
+        wait_timeout_in_ms=target.table_wait_timeout_in_ms,
+        max_retry=target.max_retry,
+        use_ssl=target.use_ssl,
+        ca_certs=target.ca_certs or None,
+        connection_timeout_in_ms=target.connection_timeout_in_ms,
+    )
+
+
+def create_tree_session_pool(
+    config_or_target: Any,
+    max_pool_size: int | None = None,
+    wait_timeout_in_ms: int | None = None,
+) -> SessionPool:
+    target = _target_with_overrides(
+        config_or_target,
+        {
+            "max_pool_size": max_pool_size,
+            "tree_wait_timeout_in_ms": wait_timeout_in_ms,
+        },
+    )
+    return SessionPool(
+        make_tree_pool_config(target),
+        target.max_pool_size,
+        target.tree_wait_timeout_in_ms,
+    )
+
+
+def create_table_session_pool(
+    config_or_target: Any,
+    max_pool_size: int | None = None,
+    database: str | None = None,
+    wait_timeout_in_ms: int | None = None,
+) -> Any:
+    target = _target_with_overrides(
+        config_or_target,
+        {
+            "max_pool_size": max_pool_size,
+            "database": database,
+            "table_wait_timeout_in_ms": wait_timeout_in_ms,
+        },
+    )
+    return _SingleEndpointTableSessionPool(target)
+
+
+class _SingleEndpointTableSessionPool:
+    """TableSessionPool equivalent that opens only the target's primary endpoint."""
+
+    def __init__(self, target: IoTDBTarget) -> None:
+        self.database = target.database or None
+        self._session_pool = SessionPool(
+            make_tree_pool_config(target),
+            target.max_pool_size,
+            target.table_wait_timeout_in_ms,
+        )
+        self._session_pool.sql_dialect = "table"
+        self._session_pool.database = self.database
+
+    def get_session(self) -> TableSession:
+        return TableSession(None, session_pool=self._session_pool)
+
+    @staticmethod
... 3951 lines suppressed ...