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]

Reply via email to