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]