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 a8d473c  fix: preserve list operation failures (#144)
a8d473c is described below

commit a8d473c766d255e1ec95f2c94f7aa947d2fa70c7
Author: Yijia Su <[email protected]>
AuthorDate: Thu Jul 30 11:37:09 2026 +0800

    fix: preserve list operation failures (#144)
---
 CHANGELOG.md                                   |   2 +
 README.md                                      |  11 ++
 doris_mcp_server/protocol.py                   | 105 +++++++++---
 doris_mcp_server/tools/resources_manager.py    |  86 +++++++---
 test/integration/test_real_doris_transports.py |  18 +-
 test/protocol/list_error_semantics_server.py   | 115 +++++++++++++
 test/protocol/test_list_error_semantics.py     | 227 +++++++++++++++++++++++++
 test/tools/test_resources_manager_cache.py     | 128 +++++++++++++-
 test/tools/test_tools_operation_guard.py       |  23 ++-
 9 files changed, 662 insertions(+), 53 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65dfb8c..d955da7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,8 @@ under **Unreleased** until a new version is selected and 
published.
 
 - Preserved service availability after malformed requests, unknown methods,
   header mismatches, unsupported versions, and missing capabilities.
+- Stopped list operations from converting Doris, permission, or internal
+  failures into successful empty resource, tool, or prompt collections.
 - Corrected resource and prompt error semantics and tool `isError` results.
 - Removed admin-token query authentication and dashboard URL propagation;
   management endpoints now accept admin credentials only in headers.
diff --git a/README.md b/README.md
index 6d401a2..a1e409f 100644
--- a/README.md
+++ b/README.md
@@ -560,6 +560,17 @@ changes, or under another principal returns `Invalid 
Params`. Restart the
 listing without a cursor in that case. This prevents a multi-page traversal
 from silently duplicating, dropping, or crossing permission-scoped entries.
 
+List failures are never represented as successful empty collections. A Doris
+metadata outage returns `List backend unavailable`; a Doris metadata permission
+failure returns `List operation permission denied`; and an unexpected
+tool/resource/prompt registry failure returns `Internal server error`. These
+responses use JSON-RPC code `-32603` and include only the list operation and a
+bounded error category/code, never backend exception text. A successful
+response with an empty `resources`, `tools`, or `prompts` array therefore means
+that the caller's visible collection is genuinely empty. The same contract
+applies to Streamable HTTP and stdio, and a failed request does not prevent the
+next list request from succeeding.
+
 ### Subscriptions and Change Notifications
 
 The server does not currently advertise or serve `subscriptions/listen`.
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 9eccec9..1b576fe 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -181,6 +181,16 @@ _PROMPT_INVALID_PARAMS_MESSAGES = {
     "MISSING_REQUIRED_ARGUMENT": "Missing required prompt argument",
 }
 _PROMPT_DATABASE_CONTEXT_ERROR = "DATABASE_CONTEXT_UNAVAILABLE"
+_LIST_ERROR_CATEGORIES = {
+    "backend_unavailable",
+    "internal_error",
+    "permission_denied",
+}
+_LIST_ERROR_MESSAGES = {
+    "backend_unavailable": "List backend unavailable",
+    "internal_error": "Internal server error",
+    "permission_denied": "List operation permission denied",
+}
 
 
 def _decode_resource_request_error(payload: str) -> tuple[str, str] | None:
@@ -201,6 +211,31 @@ def _decode_resource_request_error(payload: str) -> 
tuple[str, str] | None:
     return error_code, message
 
 
+def _list_operation_error(operation: str, exc: Exception) -> MCPError:
+    """Build a value-safe MCP failure for a list operation."""
+    category = getattr(exc, "list_error_category", "internal_error")
+    if not isinstance(category, str) or category not in _LIST_ERROR_CATEGORIES:
+        category = "internal_error"
+
+    data = {
+        "operation": operation,
+        "listErrorCategory": category,
+    }
+    error_code = getattr(exc, "error_code", None)
+    if (
+        isinstance(error_code, str)
+        and len(error_code) <= 96
+        and error_code.replace("_", "").isalnum()
+    ):
+        data["listErrorCode"] = error_code
+
+    return MCPError(
+        code=INTERNAL_ERROR,
+        message=_LIST_ERROR_MESSAGES[category],
+        data=data,
+    )
+
+
 def _paginate_list_or_raise(
     items: Sequence[_ListItemT],
     *,
@@ -268,14 +303,20 @@ def create_doris_mcp_server(
     ) -> ListResourcesResult:
         del ctx
         authorize_operation(get_current_auth_context(), "list_resources")
-        resources = await resources_manager.list_resources()
-        page = _paginate_list_or_raise(
-            resources,
-            collection="resources",
-            cursor=params.cursor if params else None,
-            page_size=list_page_size,
-            identifier=lambda resource: str(resource.uri),
-        )
+        try:
+            resources = await resources_manager.list_resources()
+            page = _paginate_list_or_raise(
+                resources,
+                collection="resources",
+                cursor=params.cursor if params else None,
+                page_size=list_page_size,
+                identifier=lambda resource: str(resource.uri),
+            )
+        except (MCPError, OperationAuthorizationError):
+            raise
+        except Exception as exc:
+            logger.exception("resources/list failed")
+            raise _list_operation_error("resources/list", exc) from exc
         logger.info(
             "Returning %d of %d resources",
             len(page.items),
@@ -320,16 +361,22 @@ def create_doris_mcp_server(
         params: PaginatedRequestParams | None,
     ) -> ListToolsResult:
         authorize_operation(get_current_auth_context(), "list_tools")
-        tools = await tools_manager.list_tools()
-        schema_guard.compile_catalog(tools)
-        tools = _tools_for_protocol(tools, ctx.protocol_version)
-        page = _paginate_list_or_raise(
-            tools,
-            collection="tools",
-            cursor=params.cursor if params else None,
-            page_size=list_page_size,
-            identifier=lambda tool: tool.name,
-        )
+        try:
+            tools = await tools_manager.list_tools()
+            schema_guard.compile_catalog(tools)
+            tools = _tools_for_protocol(tools, ctx.protocol_version)
+            page = _paginate_list_or_raise(
+                tools,
+                collection="tools",
+                cursor=params.cursor if params else None,
+                page_size=list_page_size,
+                identifier=lambda tool: tool.name,
+            )
+        except (MCPError, OperationAuthorizationError):
+            raise
+        except Exception as exc:
+            logger.exception("tools/list failed")
+            raise _list_operation_error("tools/list", exc) from exc
         logger.info("Returning %d of %d tools", len(page.items), len(tools))
         return ListToolsResult(
             tools=page.items,
@@ -402,14 +449,20 @@ def create_doris_mcp_server(
     ) -> ListPromptsResult:
         del ctx
         authorize_operation(get_current_auth_context(), "list_prompts")
-        prompts = await prompts_manager.list_prompts()
-        page = _paginate_list_or_raise(
-            prompts,
-            collection="prompts",
-            cursor=params.cursor if params else None,
-            page_size=list_page_size,
-            identifier=lambda prompt: prompt.name,
-        )
+        try:
+            prompts = await prompts_manager.list_prompts()
+            page = _paginate_list_or_raise(
+                prompts,
+                collection="prompts",
+                cursor=params.cursor if params else None,
+                page_size=list_page_size,
+                identifier=lambda prompt: prompt.name,
+            )
+        except (MCPError, OperationAuthorizationError):
+            raise
+        except Exception as exc:
+            logger.exception("prompts/list failed")
+            raise _list_operation_error("prompts/list", exc) from exc
         logger.info(
             "Returning %d of %d prompts",
             len(page.items),
diff --git a/doris_mcp_server/tools/resources_manager.py 
b/doris_mcp_server/tools/resources_manager.py
index b6de177..ba486ec 100644
--- a/doris_mcp_server/tools/resources_manager.py
+++ b/doris_mcp_server/tools/resources_manager.py
@@ -107,13 +107,25 @@ class MetadataCache:
         self.cache[key] = (value, datetime.now().timestamp())
 
 
-class DorisOAuthResourceError(RuntimeError):
-    """Structured resources metadata failure for Doris OAuth requests."""
+class ResourceMetadataError(RuntimeError):
+    """Structured resource-list failure that is safe to classify at the 
protocol boundary."""
 
-    def __init__(self, message: str, *, error_code: str, status_code: int):
+    def __init__(
+        self,
+        message: str,
+        *,
+        error_code: str,
+        status_code: int,
+        list_error_category: str,
+    ) -> None:
         super().__init__(message)
         self.error_code = error_code
         self.status_code = status_code
+        self.list_error_category = list_error_category
+
+
+class DorisOAuthResourceError(ResourceMetadataError):
+    """Structured resource metadata failure for Doris OAuth requests."""
 
 
 class InvalidResourceURIError(ValueError):
@@ -224,15 +236,32 @@ class DorisResourcesManager:
             return f"doris://stats/database/{segment}"
         return f"doris://stats/{segment}"
 
-    def _resource_error_from_exception(self, exc: Exception) -> 
DorisOAuthResourceError:
+    def _resource_error_from_exception(self, exc: Exception) -> 
ResourceMetadataError:
+        if isinstance(exc, ResourceMetadataError):
+            return exc
+
+        is_doris_oauth = self._is_doris_oauth_context()
+        error_type = (
+            DorisOAuthResourceError if is_doris_oauth else 
ResourceMetadataError
+        )
+        error_prefix = (
+            "DORIS_OAUTH_METADATA" if is_doris_oauth else "DORIS_METADATA"
+        )
         error_code = getattr(exc, "error_code", None)
         status_code = getattr(exc, "status_code", None)
         message = str(exc) or exc.__class__.__name__
         if error_code:
-            return DorisOAuthResourceError(
+            normalized_status = int(status_code or 500)
+            category = (
+                "permission_denied"
+                if normalized_status in {401, 403}
+                else "backend_unavailable"
+            )
+            return error_type(
                 message,
                 error_code=str(error_code),
-                status_code=int(status_code or 500),
+                status_code=normalized_status,
+                list_error_category=category,
             )
 
         mysql_error_code = None
@@ -242,24 +271,46 @@ class DorisResourcesManager:
             except (TypeError, ValueError):
                 mysql_error_code = None
         if mysql_error_code in {1044, 1045, 1049, 1142, 1227}:
-            return DorisOAuthResourceError(
+            return error_type(
                 message,
-                error_code="DORIS_OAUTH_METADATA_PERMISSION_DENIED",
+                error_code=f"{error_prefix}_PERMISSION_DENIED",
                 status_code=403,
+                list_error_category="permission_denied",
             )
 
         lowered = message.lower()
-        permission_markers = ("permission denied", "access denied", "not 
authorized", "privilege")
+        permission_markers = (
+            "permission denied",
+            "access denied",
+            "not authorized",
+            "privilege",
+        )
         if any(marker in lowered for marker in permission_markers):
-            return DorisOAuthResourceError(
+            return error_type(
                 message,
-                error_code="DORIS_OAUTH_METADATA_PERMISSION_DENIED",
+                error_code=f"{error_prefix}_PERMISSION_DENIED",
                 status_code=403,
+                list_error_category="permission_denied",
             )
-        return DorisOAuthResourceError(
+
+        exception_family = exc.__class__.__module__.partition(".")[0]
+        if (
+            is_doris_oauth
+            or isinstance(exc, OSError | TimeoutError)
+            or exception_family in {"aiomysql", "pymysql"}
+        ):
+            return error_type(
+                message,
+                error_code=f"{error_prefix}_BACKEND_ERROR",
+                status_code=502,
+                list_error_category="backend_unavailable",
+            )
+
+        return error_type(
             message,
-            error_code="DORIS_OAUTH_METADATA_BACKEND_ERROR",
-            status_code=502,
+            error_code=f"{error_prefix}_INTERNAL_ERROR",
+            status_code=500,
+            list_error_category="internal_error",
         )
 
     def _reraise_if_doris_oauth_resource_error(self, exc: Exception) -> None:
@@ -332,8 +383,8 @@ class DorisResourcesManager:
             )
 
         except Exception as e:
-            self._reraise_if_doris_oauth_resource_error(e)
             logger.exception("Failed to get resource list")
+            raise self._resource_error_from_exception(e) from e
 
         return resources
 
@@ -471,13 +522,10 @@ class DorisResourcesManager:
         tables: list[TableMetadata] = []
 
         for row in result.data:
-            columns = await self._get_table_columns(connection, 
row["table_name"], db_name)
-
             table = TableMetadata(
                 name=row["table_name"],
                 comment=row.get("table_comment"),
                 row_count=row.get("row_count", 0),
-                columns=columns,
                 create_time=row.get("create_time"),
                 database=db_name,
             )
@@ -542,7 +590,6 @@ class DorisResourcesManager:
         views_query = f"""
         SELECT
             table_name,
-            table_comment,
             view_definition
         FROM information_schema.views
         WHERE {schema_filter}
@@ -656,7 +703,6 @@ class DorisResourcesManager:
         view_query = f"""
         SELECT
             table_name,
-            table_comment,
             view_definition
         FROM information_schema.views
         WHERE {schema_filter}
diff --git a/test/integration/test_real_doris_transports.py 
b/test/integration/test_real_doris_transports.py
index 76d1157..b59c1a9 100644
--- a/test/integration/test_real_doris_transports.py
+++ b/test/integration/test_real_doris_transports.py
@@ -288,7 +288,13 @@ async def _http_process(environment: dict[str, str]) -> 
AsyncIterator[str]:
         )
         try:
             await _wait_for_http_server(process, port, log_file)
-            yield f"http://127.0.0.1:{port}";
+            try:
+                yield f"http://127.0.0.1:{port}";
+            except Exception as exc:
+                exc.add_note(
+                    "HTTP MCP process log:\n" + _read_process_log(log_file)
+                )
+                raise
         finally:
             await _stop_process(process)
 
@@ -335,7 +341,15 @@ async def _stdio_client(
                 pytrace=False,
             )
         try:
-            yield client
+            try:
+                yield client
+            except Exception as exc:
+                log_file.flush()
+                log_file.seek(0)
+                exc.add_note(
+                    "STDIO MCP process log:\n" + log_file.read()[-4000:]
+                )
+                raise
         finally:
             await stack.aclose()
 
diff --git a/test/protocol/list_error_semantics_server.py 
b/test/protocol/list_error_semantics_server.py
new file mode 100644
index 0000000..b92e94e
--- /dev/null
+++ b/test/protocol/list_error_semantics_server.py
@@ -0,0 +1,115 @@
+# 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.
+"""Transport fixture for list failure and recovery semantics."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+
+from mcp.server.stdio import stdio_server
+from mcp.types import GetPromptResult, Prompt, Resource, Tool
+
+from doris_mcp_server import __version__
+from doris_mcp_server.protocol import create_doris_mcp_server
+from doris_mcp_server.tools.resources_manager import ResourceMetadataError
+
+SENSITIVE_MARKER = "backend-sensitive-list-error-detail"
+
+
+class SequentialResourcesManager:
+    def __init__(self) -> None:
+        self.calls = 0
+
+    async def list_resources(self) -> list[Resource]:
+        self.calls += 1
+        if self.calls == 1:
+            raise ResourceMetadataError(
+                SENSITIVE_MARKER,
+                error_code="DORIS_METADATA_BACKEND_ERROR",
+                status_code=502,
+                list_error_category="backend_unavailable",
+            )
+        if self.calls == 2:
+            raise ResourceMetadataError(
+                SENSITIVE_MARKER,
+                error_code="DORIS_METADATA_PERMISSION_DENIED",
+                status_code=403,
+                list_error_category="permission_denied",
+            )
+        return []
+
+    async def read_resource(self, uri: str) -> str:
+        return json.dumps({"uri": uri})
+
+
+class SequentialToolsManager:
+    def __init__(self) -> None:
+        self.calls = 0
+
+    async def list_tools(self) -> list[Tool]:
+        self.calls += 1
+        if self.calls == 1:
+            raise RuntimeError(SENSITIVE_MARKER)
+        return []
+
+    async def call_tool(self, name: str, arguments: dict) -> str:
+        return json.dumps({"name": name, "arguments": arguments})
+
+
+class SequentialPromptsManager:
+    def __init__(self) -> None:
+        self.calls = 0
+
+    async def list_prompts(self) -> list[Prompt]:
+        self.calls += 1
+        if self.calls == 1:
+            raise RuntimeError(SENSITIVE_MARKER)
+        return []
+
+    async def get_prompt(
+        self,
+        name: str,
+        arguments: dict,
+    ) -> GetPromptResult:
+        raise AssertionError((name, arguments))
+
+
+def create_list_error_semantics_server():
+    return create_doris_mcp_server(
+        resources_manager=SequentialResourcesManager(),
+        tools_manager=SequentialToolsManager(),
+        prompts_manager=SequentialPromptsManager(),
+        name="doris-mcp-list-error-semantics-test",
+        version=__version__,
+        logger=logging.getLogger(__name__),
+    )
+
+
+async def main() -> None:
+    server = create_list_error_semantics_server()
+    async with stdio_server() as (read_stream, write_stream):
+        await server.run(
+            read_stream,
+            write_stream,
+            server.create_initialization_options(),
+        )
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
diff --git a/test/protocol/test_list_error_semantics.py 
b/test/protocol/test_list_error_semantics.py
new file mode 100644
index 0000000..1d92046
--- /dev/null
+++ b/test/protocol/test_list_error_semantics.py
@@ -0,0 +1,227 @@
+# 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.
+"""Contract tests that distinguish list failures from successful empty 
lists."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import httpx2
+import pytest
+from mcp import Client, MCPError, StdioServerParameters
+from mcp.client.stdio import stdio_client
+
+from doris_mcp_server.protocol import create_transport_security
+from test.protocol.list_error_semantics_server import (
+    SENSITIVE_MARKER,
+    create_list_error_semantics_server,
+)
+
+
+def _modern_request(request_id: int, method: str) -> dict:
+    return {
+        "jsonrpc": "2.0",
+        "id": request_id,
+        "method": method,
+        "params": {
+            "_meta": {
+                "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+                "io.modelcontextprotocol/clientCapabilities": {},
+                "io.modelcontextprotocol/clientInfo": {
+                    "name": "list-error-semantics-test",
+                    "version": "1.0.0",
+                },
+            }
+        },
+    }
+
+
+def _modern_headers(method: str) -> dict[str, str]:
+    return {
+        "Accept": "application/json, text/event-stream",
+        "Content-Type": "application/json",
+        "Mcp-Protocol-Version": "2026-07-28",
+        "Mcp-Method": method,
+    }
+
+
+def _assert_list_error(
+    error: MCPError,
+    *,
+    operation: str,
+    category: str,
+    message: str,
+    error_code: str | None = None,
+) -> None:
+    assert error.code == -32603
+    assert error.message == message
+    expected = {
+        "operation": operation,
+        "listErrorCategory": category,
+    }
+    if error_code is not None:
+        expected["listErrorCode"] = error_code
+    assert error.data == expected
+    assert SENSITIVE_MARKER not in repr(error)
+
+
+async def _exercise_stdio_list_contract(client: Client) -> None:
+    with pytest.raises(MCPError) as backend:
+        await client.list_resources(cache_mode="bypass")
+    _assert_list_error(
+        backend.value,
+        operation="resources/list",
+        category="backend_unavailable",
+        message="List backend unavailable",
+        error_code="DORIS_METADATA_BACKEND_ERROR",
+    )
+
+    with pytest.raises(MCPError) as permission:
+        await client.list_resources(cache_mode="bypass")
+    _assert_list_error(
+        permission.value,
+        operation="resources/list",
+        category="permission_denied",
+        message="List operation permission denied",
+        error_code="DORIS_METADATA_PERMISSION_DENIED",
+    )
+    assert (await client.list_resources(cache_mode="bypass")).resources == []
+
+    with pytest.raises(MCPError) as tools_failure:
+        await client.list_tools(cache_mode="bypass")
+    _assert_list_error(
+        tools_failure.value,
+        operation="tools/list",
+        category="internal_error",
+        message="Internal server error",
+    )
+    assert (await client.list_tools(cache_mode="bypass")).tools == []
+
+    with pytest.raises(MCPError) as prompts_failure:
+        await client.list_prompts(cache_mode="bypass")
+    _assert_list_error(
+        prompts_failure.value,
+        operation="prompts/list",
+        category="internal_error",
+        message="Internal server error",
+    )
+    assert (await client.list_prompts(cache_mode="bypass")).prompts == []
+
+
[email protected]
+async def test_streamable_http_list_failures_are_not_successful_empty_lists():
+    app = create_list_error_semantics_server().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,
+    ):
+        request_id = 0
+
+        async def send(method: str):
+            nonlocal request_id
+            request_id += 1
+            return await client.post(
+                "/mcp",
+                json=_modern_request(request_id, method),
+                headers=_modern_headers(method),
+            )
+
+        backend = await send("resources/list")
+        assert backend.status_code == 200
+        assert backend.json()["error"] == {
+            "code": -32603,
+            "message": "List backend unavailable",
+            "data": {
+                "operation": "resources/list",
+                "listErrorCategory": "backend_unavailable",
+                "listErrorCode": "DORIS_METADATA_BACKEND_ERROR",
+            },
+        }
+        assert SENSITIVE_MARKER not in backend.text
+
+        permission = await send("resources/list")
+        assert permission.status_code == 200
+        assert permission.json()["error"] == {
+            "code": -32603,
+            "message": "List operation permission denied",
+            "data": {
+                "operation": "resources/list",
+                "listErrorCategory": "permission_denied",
+                "listErrorCode": "DORIS_METADATA_PERMISSION_DENIED",
+            },
+        }
+        assert SENSITIVE_MARKER not in permission.text
+
+        empty_resources = await send("resources/list")
+        assert empty_resources.status_code == 200
+        assert empty_resources.json()["result"]["resources"] == []
+
+        internal_tools = await send("tools/list")
+        assert internal_tools.status_code == 200
+        assert internal_tools.json()["error"] == {
+            "code": -32603,
+            "message": "Internal server error",
+            "data": {
+                "operation": "tools/list",
+                "listErrorCategory": "internal_error",
+            },
+        }
+        assert SENSITIVE_MARKER not in internal_tools.text
+
+        empty_tools = await send("tools/list")
+        assert empty_tools.status_code == 200
+        assert empty_tools.json()["result"]["tools"] == []
+
+        internal_prompts = await send("prompts/list")
+        assert internal_prompts.status_code == 200
+        assert internal_prompts.json()["error"] == {
+            "code": -32603,
+            "message": "Internal server error",
+            "data": {
+                "operation": "prompts/list",
+                "listErrorCategory": "internal_error",
+            },
+        }
+        assert SENSITIVE_MARKER not in internal_prompts.text
+
+        empty_prompts = await send("prompts/list")
+        assert empty_prompts.status_code == 200
+        assert empty_prompts.json()["result"]["prompts"] == []
+
+
[email protected]
[email protected]("mode", ["2026-07-28", "legacy"])
+async def 
test_true_subprocess_stdio_list_failures_recover_to_empty_lists(mode: str):
+    server_script = Path(__file__).with_name("list_error_semantics_server.py")
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=[str(server_script)],
+    )
+
+    async with Client(stdio_client(server_params), mode=mode) as client:
+        await _exercise_stdio_list_contract(client)
diff --git a/test/tools/test_resources_manager_cache.py 
b/test/tools/test_resources_manager_cache.py
index 87c5192..96ae446 100644
--- a/test/tools/test_resources_manager_cache.py
+++ b/test/tools/test_resources_manager_cache.py
@@ -8,6 +8,7 @@ from doris_mcp_server.tools.resources_manager import (
     DorisOAuthResourceError,
     DorisResourcesManager,
     MetadataCache,
+    ResourceMetadataError,
 )
 from doris_mcp_server.utils.security import (
     AuthContext,
@@ -19,6 +20,7 @@ from doris_mcp_server.utils.security import (
 class FakeConnection:
     def __init__(self):
         self.table_metadata_queries = 0
+        self.column_metadata_queries = 0
 
     async def execute(self, sql, params=None, auth_context=None):
         if "FROM information_schema.tables" in sql and "AND table_type = 'BASE 
TABLE'" in sql:
@@ -34,6 +36,7 @@ class FakeConnection:
                 ]
             )
         if "FROM information_schema.columns" in sql:
+            self.column_metadata_queries += 1
             return SimpleNamespace(data=[])
         return SimpleNamespace(data=[])
 
@@ -73,6 +76,29 @@ class RaisingConnectionManager:
             self.releases += 1
 
 
+class ClassifiedErrorConnection:
+    def __init__(self, error):
+        self.error = error
+
+    async def execute(self, sql, params=None, auth_context=None):
+        raise self.error
+
+
+class ClassifiedErrorConnectionManager:
+    def __init__(self, error):
+        self.connection = ClassifiedErrorConnection(error)
+        self.acquires = 0
+        self.releases = 0
+
+    @asynccontextmanager
+    async def get_connection_context(self, session_id):
+        self.acquires += 1
+        try:
+            yield self.connection
+        finally:
+            self.releases += 1
+
+
 class DorisOAuthResourceConnection:
     def __init__(self):
         self.calls = []
@@ -140,6 +166,16 @@ class DorisOAuthReadConnection:
 
     async def execute(self, sql, params=None, auth_context=None):
         self.calls.append((sql, params, auth_context))
+        if "FROM information_schema.views" in sql and "AND table_name" in sql:
+            assert params == ("db1", "orders_view")
+            return SimpleNamespace(
+                data=[
+                    {
+                        "table_name": "orders_view",
+                        "view_definition": "SELECT * FROM orders",
+                    }
+                ]
+            )
         if "FROM information_schema.tables" in sql and "AND table_name" in sql:
             assert params in {("db1", "orders"), ("db/slash", "orders/slash")}
             _db_name, table_name = params
@@ -208,6 +244,7 @@ async def 
test_resources_manager_reuses_identity_scoped_metadata_cache():
     assert [table.name for table in second] == ["orders_1"]
     assert connection_manager.acquires == 1
     assert connection_manager.releases == 1
+    assert connection_manager.connection.column_metadata_queries == 0
 
 
 @pytest.mark.asyncio
@@ -267,6 +304,55 @@ async def 
test_doris_oauth_list_resources_backend_error_is_structured_failure():
     assert connection_manager.releases == 1
 
 
[email protected]
+async def test_list_resources_backend_error_is_not_a_successful_partial_list():
+    connection_manager = ClassifiedErrorConnectionManager(
+        OSError("metadata backend unavailable")
+    )
+    manager = DorisResourcesManager(connection_manager)
+
+    with pytest.raises(ResourceMetadataError) as exc:
+        await manager.list_resources()
+
+    assert exc.value.error_code == "DORIS_METADATA_BACKEND_ERROR"
+    assert exc.value.status_code == 502
+    assert exc.value.list_error_category == "backend_unavailable"
+    assert connection_manager.acquires == 1
+    assert connection_manager.releases == 1
+
+
[email protected]
+async def 
test_list_resources_permission_error_is_not_a_successful_empty_list():
+    connection_manager = ClassifiedErrorConnectionManager(
+        RuntimeError(1142, "SELECT command denied")
+    )
+    manager = DorisResourcesManager(connection_manager)
+
+    with pytest.raises(ResourceMetadataError) as exc:
+        await manager.list_resources()
+
+    assert exc.value.error_code == "DORIS_METADATA_PERMISSION_DENIED"
+    assert exc.value.status_code == 403
+    assert exc.value.list_error_category == "permission_denied"
+    assert connection_manager.acquires == 1
+    assert connection_manager.releases == 1
+
+
[email protected]
+async def test_list_resources_internal_error_is_not_a_successful_empty_list():
+    connection_manager = RaisingConnectionManager()
+    manager = DorisResourcesManager(connection_manager)
+
+    with pytest.raises(ResourceMetadataError) as exc:
+        await manager.list_resources()
+
+    assert exc.value.error_code == "DORIS_METADATA_INTERNAL_ERROR"
+    assert exc.value.status_code == 500
+    assert exc.value.list_error_category == "internal_error"
+    assert connection_manager.acquires == 1
+    assert connection_manager.releases == 1
+
+
 @pytest.mark.asyncio
 async def 
test_doris_oauth_list_resources_uses_database_qualified_uris_without_database_function():
     connection_manager = DorisOAuthResourceConnectionManager()
@@ -288,7 +374,15 @@ async def 
test_doris_oauth_list_resources_uses_database_qualified_uris_without_d
     assert "doris://table/information_schema/information_schema_orders" not in 
uris
     assert connection_manager.acquires == 1
     assert connection_manager.releases == 1
-    assert all("DATABASE()" not in sql for sql, _params, _auth in 
connection_manager.connection.calls)
+    assert all(
+        "DATABASE()" not in sql
+        for sql, _params, _auth in connection_manager.connection.calls
+    )
+    assert all(
+        "table_comment" not in sql
+        for sql, _params, _auth in connection_manager.connection.calls
+        if "FROM information_schema.views" in sql
+    )
 
 
 @pytest.mark.asyncio
@@ -325,7 +419,37 @@ async def 
test_doris_oauth_read_database_qualified_table_resource_uses_uri_datab
     assert payload["table_name"] == "orders"
     assert connection_manager.acquires == 1
     assert connection_manager.releases == 1
-    assert all("DATABASE()" not in sql for sql, _params, _auth in 
connection_manager.connection.calls)
+    assert all(
+        "DATABASE()" not in sql
+        for sql, _params, _auth in connection_manager.connection.calls
+    )
+
+
[email protected]
+async def test_doris_oauth_read_view_uses_doris_information_schema_columns():
+    connection_manager = DorisOAuthReadConnectionManager()
+    manager = DorisResourcesManager(connection_manager)
+    token = set_current_auth_context(doris_context(["resource:read"]))
+
+    try:
+        result = await manager.read_resource("doris://view/db1/orders_view")
+    finally:
+        reset_auth_context(token)
+
+    payload = json.loads(result)
+    assert payload == {
+        "database_name": "db1",
+        "view_name": "orders_view",
+        "comment": None,
+        "definition": "SELECT * FROM orders",
+    }
+    view_queries = [
+        sql
+        for sql, _params, _auth in connection_manager.connection.calls
+        if "FROM information_schema.views" in sql
+    ]
+    assert len(view_queries) == 1
+    assert "table_comment" not in view_queries[0]
 
 
 @pytest.mark.asyncio
diff --git a/test/tools/test_tools_operation_guard.py 
b/test/tools/test_tools_operation_guard.py
index d9607b8..ceda039 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
 
 import mcp.types as mcp_types
 import pytest
+from mcp.shared.exceptions import MCPError
 
 from doris_mcp_server import __version__
 from doris_mcp_server.auth.operation_policy import (
@@ -809,8 +810,18 @@ async def 
test_doris_oauth_resources_real_handler_propagates_manager_errors(
     token = set_current_auth_context(doris_context([scope]))
 
     try:
-        with pytest.raises(RuntimeError, match=f"{operation} backend failed"):
-            await _invoke_protocol_handler(server, operation)
+        if operation == "list_resources":
+            with pytest.raises(MCPError) as exc:
+                await _invoke_protocol_handler(server, operation)
+            assert exc.value.code == -32603
+            assert exc.value.message == "Internal server error"
+            assert exc.value.data == {
+                "operation": "resources/list",
+                "listErrorCategory": "internal_error",
+            }
+        else:
+            with pytest.raises(RuntimeError, match=f"{operation} backend 
failed"):
+                await _invoke_protocol_handler(server, operation)
     finally:
         reset_auth_context(token)
 
@@ -820,8 +831,14 @@ async def 
test_list_resources_handler_propagates_backend_failure():
     server = _server_with_mock_managers()
     server.resources_manager.list_resources.side_effect = RuntimeError("legacy 
backend failed")
 
-    with pytest.raises(RuntimeError, match="legacy backend failed"):
+    with pytest.raises(MCPError) as exc:
         await _invoke_protocol_handler(server, "list_resources")
+    assert exc.value.code == -32603
+    assert exc.value.message == "Internal server error"
+    assert exc.value.data == {
+        "operation": "resources/list",
+        "listErrorCategory": "internal_error",
+    }
 
 
 @pytest.mark.parametrize(


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

Reply via email to