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]