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 ecbf4b6  fix: return invalid params for missing resources (#95)
ecbf4b6 is described below

commit ecbf4b6f1128f7b7967408c961f32ef359cde537
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 17:42:13 2026 +0800

    fix: return invalid params for missing resources (#95)
    
    Co-authored-by: FreeOnePlus <[email protected]>
---
 MCP-2026-07-28-DEVELOPMENT-LEDGER.md        | 43 ++++++++++++++-
 doris_mcp_server/protocol.py                | 38 +++++++++++++-
 doris_mcp_server/tools/resources_manager.py | 38 ++++++++++----
 test/protocol/stdio_capability_server.py    | 11 +++-
 test/protocol/test_mcp_v2_protocol.py       | 81 +++++++++++++++++++++++++++++
 test/tools/test_resources_manager_cache.py  | 46 +++++++++++++++-
 test/tools/test_tools_operation_guard.py    |  3 +-
 7 files changed, 243 insertions(+), 17 deletions(-)

diff --git a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md 
b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
index 63dd51d..d53cc3c 100644
--- a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
+++ b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
@@ -131,7 +131,7 @@
 |---|---|---|---|---|---|
 | `CORE-001` | P1 | list 异常不再返回空列表 | PROTO-002 | DB/权限/内部错误与真实空列表可区分 | 
`BACKLOG` |
 | `CORE-002` | P1 | Tool 错误使用 `isError=true` | PROTO-002 | 
可恢复业务错误对模型可见;内部异常为稳定协议错误 | `DONE` |
-| `CORE-003` | P1 | Resource not found 使用 `-32602` | PROTO-002 | 不存在 URI 返回 
Invalid Params,不返回错误正文成功 | `READY` |
+| `CORE-003` | P1 | Resource not found 使用 `-32602` | PROTO-002 | 不存在 URI 返回 
Invalid Params,不返回错误正文成功 | `DONE` |
 | `CORE-004` | P1 | Prompt 错误类型化 | PROTO-002 | 缺参数、未知 prompt、DB 上下文失败语义不同 | 
`READY` |
 | `CORE-005` | P1 | 单一 Tool Definition Registry | PROTO-002 | 
schema、policy、handler、审计和文档同源 | `BACKLOG` |
 | `CORE-006` | P1 | `/live` 与 `/ready` 分离 | 无 | Doris 不可用时 live 可真、ready 
必假;探针有短超时 | `BACKLOG` |
@@ -370,13 +370,52 @@ test/protocol/test_mcp_v2_protocol.py
 5 passed
 ```
 
+提交与评审回执:
+
+- commit:`6b5c779 feat: enforce MCP client capability requirements`
+- Draft 
PR:[apache/doris-mcp-server#94](https://github.com/apache/doris-mcp-server/pull/94)
+
+### CORE-003
+
+资源读取错误不再只靠错误字符串判断。资源管理器为以下两类客户端请求错误增加稳定标记:
+
+- `INVALID_RESOURCE_URI`
+- `RESOURCE_NOT_FOUND`
+
+现代 `2026-07-28` 协议边界只识别这两个标记,并返回标准 `-32602 Invalid Params`;普通 Doris 
后端错误不会被误分类为参数错误。legacy 协议仍保留既有 JSON 错误正文,避免破坏旧客户端。
+
+自动化验证:
+
+- Streamable HTTP:不存在资源返回 HTTP 400 / JSON-RPC `-32602`;
+- Streamable HTTP:错误后同一实例继续成功读取有效资源;
+- 子进程 STDIO:不存在资源返回 `-32602`,错误后继续成功读取;
+- STDIO legacy:继续收到带 `error_code` 的兼容错误正文;
+- 资源管理器:非法 URI、缺失表和普通后端错误三类结果可区分;
+- 完整 pytest:`336 passed / 57 skipped / 0 failed / 247 warnings`;
+- `uv lock --check`、作用域 Ruff、`compileall`、`uv build` 全部通过。
+
+真实 Doris 验证:
+
+```text
+environment: 192.168.31.63 / hhm_dt_sim
+valid resource: doris://table/org_tenant
+missing resource: doris://table/__core_003_missing__
+```
+
+- HTTP modern:`-32602` 后有效资源读取成功;
+- HTTP legacy:兼容错误正文后有效资源读取成功;
+- STDIO modern:`-32602` 后有效资源读取成功;
+- STDIO legacy:兼容错误正文后有效资源读取成功。
+
+连接通过 SSH key 和临时本地隧道完成;凭据未写入仓库或台账,探针完成后服务与隧道均已关闭。
+
 ## 11. 下一开发批次
 
 批次:`BATCH-02-CONFORMANCE-AND-ERROR-SEMANTICS`
 
 按以下顺序推进:
 
-1. `CORE-003` / `CORE-004`:Resource 与 Prompt 错误类型化;
+1. `CORE-004`:Prompt 错误类型化;
 2. `CORE-010` / `CORE-011` / `COMPAT-001`:修复真实 Doris 已复现缺陷;
 3. `TEST-003`:运行官方 `server-stateless` Conformance;
 4. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 3750e90..3047598 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -29,6 +29,7 @@ from mcp.server.context import CallNext
 from mcp.server.transport_security import TransportSecuritySettings
 from mcp.shared.exceptions import MCPError
 from mcp.types import (
+    INVALID_PARAMS,
     LATEST_PROTOCOL_VERSION,
     MISSING_REQUIRED_CLIENT_CAPABILITY,
     CallToolRequestParams,
@@ -107,6 +108,30 @@ def _decode_structured_tool_result(payload: str) -> 
tuple[Any | None, bool]:
     return decoded, "error" in decoded
 
 
+_RESOURCE_INVALID_PARAMS_MESSAGES = {
+    "INVALID_RESOURCE_URI": "Invalid resource URI",
+    "RESOURCE_NOT_FOUND": "Resource not found",
+}
+
+
+def _decode_resource_request_error(payload: str) -> tuple[str, str] | None:
+    """Decode only manager errors that are safe to classify as client input."""
+    try:
+        decoded = json.loads(payload)
+    except (TypeError, json.JSONDecodeError):
+        return None
+
+    if not isinstance(decoded, dict):
+        return None
+    error_code = decoded.get("error_code")
+    if not isinstance(error_code, str):
+        return None
+    message = _RESOURCE_INVALID_PARAMS_MESSAGES.get(error_code)
+    if message is None:
+        return None
+    return error_code, message
+
+
 def create_doris_mcp_server(
     *,
     resources_manager: ResourcesManager,
@@ -133,9 +158,20 @@ def create_doris_mcp_server(
         ctx: ServerRequestContext,
         params: ReadResourceRequestParams,
     ) -> ReadResourceResult:
-        del ctx
         authorize_operation(get_current_auth_context(), "read_resource")
         content = await resources_manager.read_resource(params.uri)
+        if ctx.protocol_version == LATEST_PROTOCOL_VERSION:
+            request_error = _decode_resource_request_error(content)
+            if request_error is not None:
+                error_code, message = request_error
+                raise MCPError(
+                    code=INVALID_PARAMS,
+                    message=message,
+                    data={
+                        "uri": str(params.uri),
+                        "resourceErrorCode": error_code,
+                    },
+                )
         return ReadResourceResult(
             contents=[
                 TextResourceContents(
diff --git a/doris_mcp_server/tools/resources_manager.py 
b/doris_mcp_server/tools/resources_manager.py
index f5b16b2..1f615bb 100644
--- a/doris_mcp_server/tools/resources_manager.py
+++ b/doris_mcp_server/tools/resources_manager.py
@@ -101,6 +101,18 @@ class DorisOAuthResourceError(RuntimeError):
         self.status_code = status_code
 
 
+class InvalidResourceURIError(ValueError):
+    """A resource URI that cannot identify a supported Doris resource."""
+
+    error_code = "INVALID_RESOURCE_URI"
+
+
+class ResourceNotFoundError(ValueError):
+    """A syntactically valid Doris resource URI with no visible target."""
+
+    error_code = "RESOURCE_NOT_FOUND"
+
+
 EXCLUDED_RESOURCE_DATABASES = {
     "information_schema",
     "mysql",
@@ -189,6 +201,8 @@ class DorisResourcesManager:
         )
 
     def _reraise_if_doris_oauth_resource_error(self, exc: Exception) -> None:
+        if isinstance(exc, InvalidResourceURIError | ResourceNotFoundError):
+            return
         if isinstance(exc, DorisOAuthResourceError):
             raise exc
         if self._is_doris_oauth_context():
@@ -314,15 +328,19 @@ class DorisResourcesManager:
             elif resource_type == "stats" and resource_name == "database":
                 return await self._get_database_stats(db_name)
             else:
-                raise ValueError(f"Unsupported resource type: {resource_type}")
+                raise InvalidResourceURIError(
+                    f"Unsupported resource type: {resource_type}"
+                )
 
         except Exception as e:
             self._reraise_if_doris_oauth_resource_error(e)
-            return json.dumps(
-                {"error": f"Failed to read resource: {str(e)}", "uri": uri},
-                ensure_ascii=False,
-                indent=2,
-            )
+            payload = {
+                "error": f"Failed to read resource: {str(e)}",
+                "uri": uri,
+            }
+            if isinstance(e, InvalidResourceURIError | ResourceNotFoundError):
+                payload["error_code"] = e.error_code
+            return json.dumps(payload, ensure_ascii=False, indent=2)
 
     async def _get_table_metadata(self) -> list[TableMetadata]:
         """Get metadata for all tables"""
@@ -487,7 +505,7 @@ class DorisResourcesManager:
             )
             if not table_result.data:
                 qualified_name = f"{db_name}.{table_name}" if db_name else 
table_name
-                raise ValueError(f"Table {qualified_name} does not exist")
+                raise ResourceNotFoundError(f"Table {qualified_name} does not 
exist")
 
             table_info = table_result.data[0]
 
@@ -557,7 +575,7 @@ class DorisResourcesManager:
             )
             if not result.data:
                 qualified_name = f"{db_name}.{view_name}" if db_name else 
view_name
-                raise ValueError(f"View {qualified_name} does not exist")
+                raise ResourceNotFoundError(f"View {qualified_name} does not 
exist")
 
             view_info = result.data[0]
 
@@ -619,13 +637,13 @@ class DorisResourcesManager:
     def _parse_resource_uri(self, uri: str) -> tuple[str, str, str | None]:
         """Parse resource URI"""
         if not uri.startswith("doris://"):
-            raise ValueError("Invalid resource URI format")
+            raise InvalidResourceURIError("Invalid resource URI format")
 
         path = uri[8:]  # Remove "doris://" prefix
         parts = path.split("/")
 
         if len(parts) < 2:
-            raise ValueError("Incomplete resource URI format")
+            raise InvalidResourceURIError("Incomplete resource URI format")
 
         resource_type = parts[0]
         if resource_type in {"table", "view"}:
diff --git a/test/protocol/stdio_capability_server.py 
b/test/protocol/stdio_capability_server.py
index 6bc514e..5de86bd 100644
--- a/test/protocol/stdio_capability_server.py
+++ b/test/protocol/stdio_capability_server.py
@@ -17,6 +17,7 @@
 """STDIO fixture for required-client-capability protocol tests."""
 
 import asyncio
+import json
 import logging
 
 from mcp.server.stdio import stdio_server
@@ -32,7 +33,15 @@ class EmptyResourcesManager:
         return []
 
     async def read_resource(self, uri: str) -> str:
-        raise ValueError(f"Unknown resource: {uri}")
+        if uri == "doris://table/orders":
+            return json.dumps({"uri": uri, "columns": 3})
+        return json.dumps(
+            {
+                "error": f"Failed to read resource: Table {uri} does not 
exist",
+                "error_code": "RESOURCE_NOT_FOUND",
+                "uri": uri,
+            }
+        )
 
 
 class OneToolManager:
diff --git a/test/protocol/test_mcp_v2_protocol.py 
b/test/protocol/test_mcp_v2_protocol.py
index a7234e7..c32045f 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -54,6 +54,14 @@ class StubResourcesManager:
         ]
 
     async def read_resource(self, uri: str) -> str:
+        if uri == "doris://table/missing":
+            return json.dumps(
+                {
+                    "error": "Failed to read resource: Table missing does not 
exist",
+                    "error_code": "RESOURCE_NOT_FOUND",
+                    "uri": uri,
+                }
+            )
         return json.dumps({"uri": uri, "columns": 3})
 
 
@@ -204,6 +212,19 @@ def modern_headers(method: str) -> dict[str, str]:
     }
 
 
+def modern_resource_request(request_id: int, uri: str) -> dict:
+    request = modern_request(request_id, "resources/read")
+    request["params"]["uri"] = uri
+    return request
+
+
+def modern_resource_headers(uri: str) -> dict[str, str]:
+    return {
+        **modern_headers("resources/read"),
+        "Mcp-Name": uri,
+    }
+
+
 @pytest.mark.asyncio
 async def 
test_http_discover_is_stateless_and_unknown_method_does_not_kill_server():
     app = create_test_server().streamable_http_app(
@@ -409,6 +430,49 @@ async def 
test_http_validates_request_meta_and_required_client_capabilities():
         ]
 
 
[email protected]
+async def test_http_resource_not_found_is_invalid_params_and_server_recovers():
+    app = create_test_server().streamable_http_app(
+        json_response=True,
+        stateless_http=True,
+        host="127.0.0.1",
+        transport_security=create_transport_security("127.0.0.1"),
+    )
+    missing_uri = "doris://table/missing"
+    valid_uri = "doris://table/orders"
+
+    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,
+    ):
+        missing = await client.post(
+            "/mcp",
+            json=modern_resource_request(1, missing_uri),
+            headers=modern_resource_headers(missing_uri),
+        )
+        assert missing.status_code == 400
+        assert missing.json()["error"] == {
+            "code": -32602,
+            "message": "Resource not found",
+            "data": {
+                "uri": missing_uri,
+                "resourceErrorCode": "RESOURCE_NOT_FOUND",
+            },
+        }
+
+        recovered = await client.post(
+            "/mcp",
+            json=modern_resource_request(2, valid_uri),
+            headers=modern_resource_headers(valid_uri),
+        )
+        assert recovered.status_code == 200
+        assert recovered.json()["result"]["contents"][0]["uri"] == valid_uri
+
+
 @pytest.mark.asyncio
 async def test_stdio_validates_capabilities_versions_and_process_survival():
     server_script = Path(__file__).with_name("stdio_capability_server.py")
@@ -435,6 +499,21 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
             await missing.list_tools(cache_mode="bypass")
         assert missing_capability.value.code == -32021
         assert (await missing.list_resources(cache_mode="bypass")).resources 
== []
+        with pytest.raises(MCPError) as missing_resource:
+            await missing.read_resource(
+                "doris://table/missing",
+                cache_mode="bypass",
+            )
+        assert missing_resource.value.code == -32602
+        assert missing_resource.value.data == {
+            "uri": "doris://table/missing",
+            "resourceErrorCode": "RESOURCE_NOT_FOUND",
+        }
+        recovered_resource = await missing.read_resource(
+            "doris://table/orders",
+            cache_mode="bypass",
+        )
+        assert recovered_resource.contents[0].uri == "doris://table/orders"
 
     async with Client(
         stdio_client(server_params),
@@ -446,3 +525,5 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
 
     async with Client(stdio_client(server_params), mode="legacy") as legacy:
         assert [tool.name for tool in (await legacy.list_tools()).tools] == 
["echo"]
+        legacy_error = await legacy.read_resource("doris://table/missing")
+        assert json.loads(legacy_error.contents[0].text)["error_code"] == 
"RESOURCE_NOT_FOUND"
diff --git a/test/tools/test_resources_manager_cache.py 
b/test/tools/test_resources_manager_cache.py
index 1065bb0..77596cd 100644
--- a/test/tools/test_resources_manager_cache.py
+++ b/test/tools/test_resources_manager_cache.py
@@ -1,5 +1,5 @@
-from contextlib import asynccontextmanager
 import json
+from contextlib import asynccontextmanager
 from types import SimpleNamespace
 
 import pytest
@@ -9,7 +9,11 @@ from doris_mcp_server.tools.resources_manager import (
     DorisResourcesManager,
     MetadataCache,
 )
-from doris_mcp_server.utils.security import AuthContext, reset_auth_context, 
set_current_auth_context
+from doris_mcp_server.utils.security import (
+    AuthContext,
+    reset_auth_context,
+    set_current_auth_context,
+)
 
 
 class FakeConnection:
@@ -324,3 +328,41 @@ async def 
test_legacy_read_resource_keeps_json_error_body_compatibility():
     payload = json.loads(result)
     assert payload["uri"] == "doris://table/orders"
     assert "metadata backend failed" in payload["error"]
+    assert "error_code" not in payload
+
+
[email protected]
+async def test_read_resource_marks_invalid_uri_for_protocol_boundary():
+    manager = DorisResourcesManager(FakeConnectionManager())
+
+    result = await manager.read_resource("https://example.com/orders";)
+
+    payload = json.loads(result)
+    assert payload == {
+        "error": "Failed to read resource: Invalid resource URI format",
+        "error_code": "INVALID_RESOURCE_URI",
+        "uri": "https://example.com/orders";,
+    }
+
+
[email protected]
+async def test_read_resource_marks_missing_table_for_protocol_boundary():
+    class EmptyConnection:
+        async def execute(self, sql, params=None, auth_context=None):
+            return SimpleNamespace(data=[])
+
+    class EmptyConnectionManager:
+        @asynccontextmanager
+        async def get_connection_context(self, session_id):
+            yield EmptyConnection()
+
+    manager = DorisResourcesManager(EmptyConnectionManager())
+
+    result = await manager.read_resource("doris://table/missing")
+
+    payload = json.loads(result)
+    assert payload == {
+        "error": "Failed to read resource: Table missing does not exist",
+        "error_code": "RESOURCE_NOT_FOUND",
+        "uri": "doris://table/missing",
+    }
diff --git a/test/tools/test_tools_operation_guard.py 
b/test/tools/test_tools_operation_guard.py
index 342af6a..66de366 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -521,7 +521,8 @@ async def _invoke_protocol_handler(server, operation):
     }[operation]
     entry = server.server.get_request_handler(method)
     assert entry is not None
-    return await entry.handler(None, params)
+    context = 
SimpleNamespace(protocol_version=mcp_types.LATEST_PROTOCOL_VERSION)
+    return await entry.handler(context, params)
 
 
 def _manager_mock_for_operation(server, operation):


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

Reply via email to