This is an automated email from the ASF dual-hosted git repository.
FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git
The following commit(s) were added to refs/heads/master by this push:
new 6187121 fix: bind auth context for SQL profiles (#97)
6187121 is described below
commit 6187121dae9b5ce1804fca6b3b64ab50a053d803
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 17:45:48 2026 +0800
fix: bind auth context for SQL profiles (#97)
Co-authored-by: FreeOnePlus <[email protected]>
---
MCP-2026-07-28-DEVELOPMENT-LEDGER.md | 35 +++++++++-
doris_mcp_server/utils/analysis_tools.py | 4 +-
test/protocol/stdio_capability_server.py | 60 ++++++++++++++++-
test/protocol/test_mcp_v2_protocol.py | 107 ++++++++++++++++++++++++++++++-
test/tools/test_tools_operation_guard.py | 47 ++++++++++++++
5 files changed, 245 insertions(+), 8 deletions(-)
diff --git a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
index 4b79774..005bcb3 100644
--- a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
+++ b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
@@ -138,7 +138,7 @@
| `CORE-007` | P1 | 显式跨调用 handle | PROTO-003, SEC-015 | 状态不依赖协议 session;handle
绑定 principal/expiry | `BACKLOG` |
| `CORE-008` | P2 | 大结果边界 | 无 | 行数、字节数、超时和取消可配置且有硬上限 | `BACKLOG` |
| `CORE-009` | P2 | manager 模块职责拆分 | CORE-005 | 不改变行为前提下缩小超大文件,模块边界有测试 |
`BACKLOG` |
-| `CORE-010` | P1 | 修复 SQL profile 分析未绑定 `auth_context` | 无 | 真实 Doris
调用不再触发局部变量未赋值;鉴权上下文覆盖测试通过 | `READY` |
+| `CORE-010` | P1 | 修复 SQL profile 分析未绑定 `auth_context` | 无 | 真实 Doris
调用不再触发局部变量未赋值;鉴权上下文覆盖测试通过 | `DONE` |
| `CORE-011` | P1 | 修复数据新鲜度空阈值比较 | 无 | 阈值缺失或为 `None` 时返回类型化错误/默认值,不抛
`TypeError` | `READY` |
| `COMPAT-001` | P1 | Doris 4.0 元数据字段兼容 | 无 | Doris 4.0.5 的角色/权限查询不再依赖不存在的
`Default_role` 字段 | `READY` |
| `COMPAT-002` | P2 | FE/BE HTTP 端点独立配置 | SEC-018 | SQL、FE HTTP、BE HTTP
可分别配置主机/端口并通过代理/隧道环境测试 | `BACKLOG` |
@@ -446,13 +446,44 @@ Prompt 的三类失败现在拥有独立、稳定的协议语义:
完全不可达 Doris 的真实 STDIO 进程会在协议协商前退出,无法进入 Prompt handler;这是启动/就绪与故障恢复边界,继续由
`CORE-006` / `TEST-005` 跟踪,未计作本项通过证据。
+提交与评审回执:
+
+- commit:`fc49a31 fix: type prompt retrieval failures`
+- Draft
PR:[apache/doris-mcp-server#96](https://github.com/apache/doris-mcp-server/pull/96)
+
+### CORE-010
+
+SQL Profile 分析现在会在所有分支进入数据库操作前统一绑定当前 `auth_context`。不再依赖 `catalog_name`
分支的局部赋值,因此“未指定 catalog”和“仅指定数据库”两条常见路径均不会触发
`UnboundLocalError`,同一个鉴权上下文会传递到上下文切换、session trace、profile 开关和真实 SQL。
+
+自动化验证:
+
+- 单元路径:未指定数据库、仅指定数据库两种场景均成功,并逐条断言执行调用收到同一个 Doris 鉴权上下文;
+- Streamable HTTP:经生产 `SQLAnalyzer` 路径执行 Profile SQL,随后同一实例继续响应工具调用;
+- 真实子进程 STDIO:modern/legacy 均经生产 `SQLAnalyzer` 路径执行 Profile SQL并保持进程可用;
+- 完整 pytest:`347 passed / 57 skipped / 0 failed / 249 warnings`;
+- `uv lock --check`、新增测试完整 Ruff、实现文件 `F821/F823` Ruff、`compileall`、`uv build`
全部通过。
+
+真实 Doris 验证:
+
+```text
+environment: 192.168.31.63 / hhm_dt_sim
+profile SQL: SELECT COUNT(*) AS row_count FROM org_tenant
+recovery result: org_tenant=47040
+```
+
+- HTTP modern/legacy:Profile SQL 实际执行且包含 `execution_time`,无 `auth_context`
局部变量错误,随后真实查询成功;
+- STDIO modern/legacy:Profile SQL 实际执行且包含 `execution_time`,无 `auth_context`
局部变量错误,随后真实查询成功;
+- 当前隧道环境下 FE HTTP Profile 数据未取回,返回既有的“query ID unavailable”业务结果;SQL
执行与进程恢复均成功。FE HTTP 与 SQL 端点独立映射继续由 `COMPAT-002` 跟踪,不计作本项回归。
+
+连接通过既有 SSH key 与临时 SQL/FE HTTP 隧道完成;凭据未写入仓库、测试或台账,探针结束后 MCP 服务与隧道均已关闭。
+
## 11. 下一开发批次
批次:`BATCH-02-CONFORMANCE-AND-ERROR-SEMANTICS`
按以下顺序推进:
-1. `CORE-010` / `CORE-011` / `COMPAT-001`:修复真实 Doris 已复现缺陷;
+1. `CORE-011` / `COMPAT-001`:继续修复真实 Doris 已复现缺陷;
2. `TEST-003`:运行官方 `server-stateless` Conformance;
3. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
4. `PROTO-018` / `DOC-001` / `DOC-002`:版本单一来源和迁移文档;
diff --git a/doris_mcp_server/utils/analysis_tools.py
b/doris_mcp_server/utils/analysis_tools.py
index d1de5f5..4f63f54 100644
--- a/doris_mcp_server/utils/analysis_tools.py
+++ b/doris_mcp_server/utils/analysis_tools.py
@@ -586,7 +586,8 @@ class SQLAnalyzer:
logger.info(f"Generated trace ID for SQL profiling: {trace_id}")
logger.info(f"Profile will be saved to: {profile_file}")
-
+
+ auth_context = get_auth_context()
connection = await self.connection_manager.get_connection("query")
try:
@@ -598,7 +599,6 @@ class SQLAnalyzer:
except SQLSecurityError as e:
return {"success": False, "error": f"Invalid catalog
name: {e}"}
safe_catalog = quote_identifier(catalog_name, "catalog
name")
- auth_context = get_auth_context()
await connection.execute(f"SWITCH {safe_catalog}",
auth_context=auth_context)
if db_name:
try:
diff --git a/test/protocol/stdio_capability_server.py
b/test/protocol/stdio_capability_server.py
index fecac41..ce15db8 100644
--- a/test/protocol/stdio_capability_server.py
+++ b/test/protocol/stdio_capability_server.py
@@ -19,6 +19,8 @@
import asyncio
import json
import logging
+import tempfile
+from types import SimpleNamespace
from mcp.server.stdio import stdio_server
from mcp.types import (
@@ -32,6 +34,8 @@ from mcp.types import (
)
from doris_mcp_server.protocol import create_doris_mcp_server
+from doris_mcp_server.utils.analysis_tools import SQLAnalyzer
+from doris_mcp_server.utils.db import QueryResult
REQUIRED_EXTENSION = "io.apache.doris/read"
@@ -52,17 +56,71 @@ class EmptyResourcesManager:
)
+class ProfileConnection:
+ async def execute(self, sql: str, params=None, auth_context=None) ->
QueryResult:
+ return QueryResult(
+ data=[{"one": 1}],
+ metadata={"columns": ["one"]},
+ execution_time=0.01,
+ row_count=1,
+ sql=sql,
+ )
+
+
+class ProfileConnectionManager:
+ def __init__(self) -> None:
+ self._temp_dir =
tempfile.TemporaryDirectory(prefix="doris-mcp-profile-")
+ self.config = SimpleNamespace(
+ temp_files_dir=self._temp_dir.name,
+ performance=SimpleNamespace(max_response_content_size=20_000),
+ )
+ self.connection = ProfileConnection()
+
+ async def get_connection(self, session_id: str) -> ProfileConnection:
+ return self.connection
+
+
+class ProfileAnalyzer(SQLAnalyzer):
+ async def _get_query_id_by_trace_id(self, trace_id: str) -> str:
+ return "query-1"
+
+ async def _get_profile_by_query_id(self, query_id: str) -> dict:
+ return {"profile": "ok", "query_id": query_id}
+
+
class OneToolManager:
+ def __init__(self) -> None:
+ self.profile_analyzer = ProfileAnalyzer(ProfileConnectionManager())
+
async def list_tools(self) -> list[Tool]:
return [
Tool(
name="echo",
description="Echo structured input.",
input_schema={"type": "object", "properties": {}},
- )
+ ),
+ Tool(
+ name="get_sql_profile",
+ description="Exercise the production SQL profile path.",
+ input_schema={
+ "type": "object",
+ "properties": {
+ "sql": {"type": "string"},
+ "db_name": {"type": "string"},
+ },
+ "required": ["sql"],
+ },
+ ),
]
async def call_tool(self, name: str, arguments: dict) -> str:
+ if name == "get_sql_profile":
+ return json.dumps(
+ await self.profile_analyzer.get_sql_profile(
+ arguments["sql"],
+ db_name=arguments.get("db_name"),
+ )
+ )
return "{}"
diff --git a/test/protocol/test_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index e8736cf..7741877 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -39,6 +39,7 @@ from doris_mcp_server.protocol import (
create_doris_mcp_server,
create_transport_security,
)
+from test.protocol.stdio_capability_server import OneToolManager as
ProfileToolManager
REQUIRED_EXTENSION = "io.apache.doris/read"
@@ -140,10 +141,11 @@ class StubPromptsManager:
def create_test_server(
required_client_capabilities: dict[str, ClientCapabilities] | None = None,
+ tools_manager=None,
):
return create_doris_mcp_server(
resources_manager=StubResourcesManager(),
- tools_manager=StubToolsManager(),
+ tools_manager=tools_manager or StubToolsManager(),
prompts_manager=StubPromptsManager(),
name="doris-mcp-server",
version="0.6.1",
@@ -276,6 +278,28 @@ def modern_prompt_headers(name: str) -> dict[str, str]:
}
+def modern_tool_request(
+ request_id: int,
+ name: str,
+ arguments: dict | None = None,
+) -> dict:
+ request = modern_request(request_id, "tools/call")
+ request["params"].update(
+ {
+ "name": name,
+ "arguments": arguments or {},
+ }
+ )
+ return request
+
+
+def modern_tool_headers(name: str) -> dict[str, str]:
+ return {
+ **modern_headers("tools/call"),
+ "Mcp-Name": name,
+ }
+
+
@pytest.mark.asyncio
async def
test_http_discover_is_stateless_and_unknown_method_does_not_kill_server():
app = create_test_server().streamable_http_app(
@@ -598,6 +622,48 @@ async def
test_http_prompt_errors_are_typed_and_server_recovers():
)
[email protected]
+async def
test_http_sql_profile_without_catalog_uses_production_analyzer_path():
+ app = create_test_server(
+ tools_manager=ProfileToolManager(),
+ ).streamable_http_app(
+ json_response=True,
+ stateless_http=True,
+ host="127.0.0.1",
+ transport_security=create_transport_security("127.0.0.1"),
+ )
+
+ async with (
+ app.router.lifespan_context(app),
+ httpx2.ASGITransport(app) as transport,
+ httpx2.AsyncClient(
+ transport=transport,
+ base_url="http://127.0.0.1:3000",
+ ) as client,
+ ):
+ result = await client.post(
+ "/mcp",
+ json=modern_tool_request(
+ 1,
+ "get_sql_profile",
+ {"sql": "SELECT 1", "db_name": "hhm_dt_sim"},
+ ),
+ headers=modern_tool_headers("get_sql_profile"),
+ )
+ assert result.status_code == 200
+ assert result.json()["result"]["isError"] is False
+ assert result.json()["result"]["structuredContent"]["success"] is True
+ assert result.json()["result"]["structuredContent"]["query_id"] ==
"query-1"
+
+ recovered = await client.post(
+ "/mcp",
+ json=modern_tool_request(2, "echo", {}),
+ headers=modern_tool_headers("echo"),
+ )
+ assert recovered.status_code == 200
+ assert recovered.json()["result"]["isError"] is False
+
+
@pytest.mark.asyncio
async def test_stdio_validates_capabilities_versions_and_process_survival():
server_script = Path(__file__).with_name("stdio_capability_server.py")
@@ -645,15 +711,50 @@ async def
test_stdio_validates_capabilities_versions_and_process_survival():
extensions=[advertise(REQUIRED_EXTENSION)],
) as capable:
assert [tool.name for tool in (await capable.list_tools()).tools] == [
- "echo"
+ "echo",
+ "get_sql_profile",
]
async with Client(stdio_client(server_params), mode="legacy") as legacy:
- assert [tool.name for tool in (await legacy.list_tools()).tools] ==
["echo"]
+ assert [tool.name for tool in (await legacy.list_tools()).tools] == [
+ "echo",
+ "get_sql_profile",
+ ]
legacy_error = await legacy.read_resource("doris://table/missing")
assert json.loads(legacy_error.contents[0].text)["error_code"] ==
"RESOURCE_NOT_FOUND"
[email protected]
+async def
test_stdio_sql_profile_without_catalog_uses_production_analyzer_path():
+ server_script = Path(__file__).with_name("stdio_capability_server.py")
+ server_params = StdioServerParameters(
+ command=sys.executable,
+ args=[str(server_script)],
+ )
+
+ async with Client(
+ stdio_client(server_params),
+ extensions=[advertise(REQUIRED_EXTENSION)],
+ ) as modern:
+ result = await modern.call_tool(
+ "get_sql_profile",
+ {"sql": "SELECT 1", "db_name": "hhm_dt_sim"},
+ )
+ assert result.is_error is False
+ assert result.structured_content["success"] is True
+ assert result.structured_content["query_id"] == "query-1"
+
+ recovered = await modern.call_tool("echo", {})
+ assert recovered.is_error is False
+
+ async with Client(stdio_client(server_params), mode="legacy") as legacy:
+ result = await legacy.call_tool(
+ "get_sql_profile",
+ {"sql": "SELECT 1", "db_name": "hhm_dt_sim"},
+ )
+ assert json.loads(result.content[0].text)["success"] is True
+
+
@pytest.mark.asyncio
async def test_stdio_prompt_errors_are_typed_and_process_survives():
server_script = Path(__file__).with_name("stdio_capability_server.py")
diff --git a/test/tools/test_tools_operation_guard.py
b/test/tools/test_tools_operation_guard.py
index 66de366..8876a6f 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -69,6 +69,12 @@ class FakeRoutedConnectionManager:
self.connection_acquires += 1
return FakeRoutedConnection(self, session_id, auth_context)
+ async def get_connection(self, session_id):
+ return await self._get_connection_for_auth_context(
+ session_id,
+ self._get_effective_auth_context(),
+ )
+
async def release_connection(self, session_id, connection):
self.connection_releases += 1
@@ -363,6 +369,47 @@ async def
test_doris_oauth_sql_explain_with_db_catalog_uses_one_routed_connectio
assert {call["doris_user"] for call in connection_manager.routed_calls} ==
{"alice"}
[email protected]
[email protected]("db_name", [None, "db1"])
+async def test_sql_profile_binds_auth_context_without_catalog(tmp_path,
db_name):
+ manager, connection_manager = _real_tool_manager_for_routing(tmp_path)
+ manager.sql_analyzer._get_query_id_by_trace_id =
AsyncMock(return_value="query-1")
+ manager.sql_analyzer._get_profile_by_query_id = AsyncMock(
+ return_value={"profile": "ok"}
+ )
+ token = set_current_auth_context(
+ doris_context(
+ ["tool:call:get_sql_profile"],
+ user_id="alice",
+ )
+ )
+
+ try:
+ result = await manager.sql_analyzer.get_sql_profile(
+ "SELECT 1",
+ db_name=db_name,
+ )
+ finally:
+ reset_auth_context(token)
+
+ assert result["success"] is True
+ expected_sql = [
+ 'set session_context="trace_id:',
+ "set enable_profile=true",
+ "SELECT 1",
+ ]
+ if db_name:
+ expected_sql.insert(0, "USE `db1`")
+ assert len(connection_manager.routed_calls) == len(expected_sql)
+ for call, sql_prefix in zip(
+ connection_manager.routed_calls,
+ expected_sql,
+ strict=True,
+ ):
+ assert call["sql"].startswith(sql_prefix)
+ assert call["doris_user"] == "alice"
+
+
@pytest.mark.asyncio
async def
test_doris_oauth_list_tools_uses_configured_default_scope_visibility(tmp_path):
manager, _connection_manager = _real_tool_manager_for_routing(tmp_path)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]