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 9f545d6 feat: enforce MCP client capability requirements (#94)
9f545d6 is described below
commit 9f545d6d44d3ada7be99647a747e1f50cfe0ddb5
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 17:39:27 2026 +0800
feat: enforce MCP client capability requirements (#94)
Co-authored-by: FreeOnePlus <[email protected]>
---
MCP-2026-07-28-DEVELOPMENT-LEDGER.md | 51 ++++++++++---
doris_mcp_server/protocol.py | 37 ++++++++-
test/protocol/stdio_capability_server.py | 88 ++++++++++++++++++++++
test/protocol/test_mcp_v2_protocol.py | 124 ++++++++++++++++++++++++++++++-
4 files changed, 287 insertions(+), 13 deletions(-)
diff --git a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
index b0e8acd..63dd51d 100644
--- a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
+++ b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
@@ -90,7 +90,7 @@
| `PROTO-005` | P0 | 单/多 worker 复用协议工厂 | PROTO-002 | 两种模式不再复制 MCP
handlers;现代请求行为一致 | `DONE` |
| `PROTO-006` | P0 | 未知/畸形请求异常隔离 | PROTO-003 | 未知方法、错 Header、错版本、畸形 body
后服务仍可处理下一请求 | `DONE` |
| `PROTO-007` | P1 | 标准请求 Header 验证 | PROTO-003 | `Mcp-Method`、条件式 `Mcp-Name`
和 body 不一致返回 `-32020`/400 | `DONE` |
-| `PROTO-008` | P1 | 逐请求 `_meta` 与版本错误 | PROTO-003 | 缺能力、版本不支持分别返回
`-32021/-32022` | `READY` |
+| `PROTO-008` | P1 | 逐请求 `_meta` 与版本错误 | PROTO-003 | 缺能力、版本不支持分别返回
`-32021/-32022` | `DONE` |
| `PROTO-009` | P1 | 所有 Result 的 `resultType` | PROTO-002 | 现代 wire 所有成功结果包含
`complete`;MRTR 为 `input_required` | `DONE` |
| `PROTO-010` | P1 | 缓存提示策略 | PROTO-002 | cacheable 结果都有
`ttlMs/cacheScope`;身份相关结果一律 private | `DONE` |
| `PROTO-011` | P1 | 删除核心 GET/SSE 兼容改写 | PROTO-003 | 现代核心只走规范 POST;legacy
adapter 独立且默认关闭 | `BACKLOG` |
@@ -171,7 +171,7 @@
| ID | 优先级 | 发布门 | 完成定义 | 状态 |
|---|---|---|---|---|
-| `REL-001` | P0 | Alpha:协议骨架 | PROTO-001~010、SEC-001、TEST-001/002 全部完成 |
`BACKLOG` |
+| `REL-001` | P0 | Alpha:协议骨架 | PROTO-001~010、SEC-001、TEST-001/002 全部完成 |
`DONE` |
| `REL-002` | P0 | Beta:安全与真实 Doris | P0/P1 安全项、真实
Doris、Conformance、wheel、Compose 全绿 | `BACKLOG` |
| `REL-003` | P0 | GA:宣称支持 2026-07-28 | 完整测试绿;无未接受 P0/P1;文档和回执齐全 | `BACKLOG` |
@@ -230,6 +230,13 @@ coverage: 36%
### BATCH-01
+提交与 PR:
+
+```text
+commit: 5829ea3 feat: add MCP 2026-07-28 protocol core
+PR: https://github.com/apache/doris-mcp-server/pull/93
+```
+
#### 实现回执
- `mcp` 依赖已锁定为 `>=2.0.0,<2.1.0`,`uv.lock` 解析为 `mcp==2.0.0`;
@@ -338,18 +345,42 @@ doris-mcp-client --help: passed
3. `COMPAT-001`:部分权限分析 SQL 与 Doris 4.0.5 元数据字段不兼容;
4. `COMPAT-002`:FE/BE HTTP 与 SQL 连接端点需要独立配置,代理/隧道场景不能假设同一 host。
+### PROTO-008
+
+新增按 method 配置的客户端能力门禁。默认 Doris MCP handlers 不声明额外客户端能力;一旦某个 method
显式声明要求,现代协议请求缺少对应 capability 时返回:
+
+```text
+HTTP status: 400
+JSON-RPC code: -32021
+data.requiredCapabilities: 完整 ClientCapabilities
+```
+
+验证覆盖:
+
+- HTTP 缺少 `clientCapabilities` 元数据返回 `-32602`;
+- HTTP 缺少 method 所需 extension 返回 `-32021`;
+- HTTP 携带所需 extension 后同一调用成功;
+- STDIO 子进程缺少所需 extension 返回 `-32021`;
+- STDIO 子进程使用不支持版本返回 `-32022` 及 `supported/requested`;
+- HTTP 与 STDIO 都在错误后继续成功处理后续请求;
+- legacy 协议不受现代能力门禁影响。
+
+```text
+test/protocol/test_mcp_v2_protocol.py
+5 passed
+```
+
## 11. 下一开发批次
批次:`BATCH-02-CONFORMANCE-AND-ERROR-SEMANTICS`
按以下顺序推进:
-1. `PROTO-008`:补齐缺 capability `-32021` 和逐请求 `_meta` 边界;
-2. `CORE-003` / `CORE-004`:Resource 与 Prompt 错误类型化;
-3. `CORE-010` / `CORE-011` / `COMPAT-001`:修复真实 Doris 已复现缺陷;
-4. `TEST-003`:运行官方 `server-stateless` Conformance;
-5. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
-6. `PROTO-018` / `DOC-001` / `DOC-002`:版本单一来源和迁移文档;
-7. `SEC-003`~`SEC-005`:进入下一安全批,完成非 loopback fail-closed。
+1. `CORE-003` / `CORE-004`:Resource 与 Prompt 错误类型化;
+2. `CORE-010` / `CORE-011` / `COMPAT-001`:修复真实 Doris 已复现缺陷;
+3. `TEST-003`:运行官方 `server-stateless` Conformance;
+4. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
+5. `PROTO-018` / `DOC-001` / `DOC-002`:版本单一来源和迁移文档;
+6. `SEC-003`~`SEC-005`:进入下一安全批,完成非 loopback fail-closed。
-`REL-001` 尚未开启:BATCH-01 已完成协议骨架,但 `PROTO-008` 与官方 Conformance 仍是 Alpha 发布门的硬缺口。
+`REL-001` 已达成。`REL-002` 仍由官方 Conformance、完整真实 Doris 矩阵、P0/P1 安全项、Compose
和发布门阻塞。
diff --git a/doris_mcp_server/protocol.py b/doris_mcp_server/protocol.py
index 6f5acb4..3750e90 100644
--- a/doris_mcp_server/protocol.py
+++ b/doris_mcp_server/protocol.py
@@ -20,19 +20,26 @@ from __future__ import annotations
import json
import logging
+from collections.abc import Mapping
from typing import Any, Protocol
from mcp.server import Server, ServerRequestContext
from mcp.server.caching import CacheHint
+from mcp.server.context import CallNext
from mcp.server.transport_security import TransportSecuritySettings
+from mcp.shared.exceptions import MCPError
from mcp.types import (
+ LATEST_PROTOCOL_VERSION,
+ MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolRequestParams,
CallToolResult,
+ ClientCapabilities,
GetPromptRequestParams,
GetPromptResult,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
+ MissingRequiredClientCapabilityErrorData,
PaginatedRequestParams,
Prompt,
ReadResourceRequestParams,
@@ -108,6 +115,7 @@ def create_doris_mcp_server(
name: str,
version: str,
logger: logging.Logger,
+ required_client_capabilities: Mapping[str, ClientCapabilities] | None =
None,
) -> Server:
"""Create the one low-level SDK v2 server used by every transport."""
@@ -184,7 +192,7 @@ def create_doris_mcp_server(
)
private_no_cache = CacheHint(ttl_ms=0, scope="private")
- return Server(
+ server = Server(
name,
version=version,
description="Model Context Protocol server for Apache Doris",
@@ -202,3 +210,30 @@ def create_doris_mcp_server(
on_list_prompts=list_prompts,
on_get_prompt=get_prompt,
)
+
+ if required_client_capabilities:
+ requirements = dict(required_client_capabilities)
+
+ async def enforce_required_client_capabilities(
+ ctx: ServerRequestContext,
+ call_next: CallNext,
+ ):
+ required = requirements.get(ctx.method)
+ if (
+ required is not None
+ and ctx.protocol_version == LATEST_PROTOCOL_VERSION
+ and not ctx.session.check_client_capability(required)
+ ):
+ data = MissingRequiredClientCapabilityErrorData(
+ required_capabilities=required,
+ ).model_dump(by_alias=True, mode="json", exclude_none=True)
+ raise MCPError(
+ code=MISSING_REQUIRED_CLIENT_CAPABILITY,
+ message="Missing required client capability",
+ data=data,
+ )
+ return await call_next(ctx)
+
+ server.middleware.append(enforce_required_client_capabilities)
+
+ return server
diff --git a/test/protocol/stdio_capability_server.py
b/test/protocol/stdio_capability_server.py
new file mode 100644
index 0000000..6bc514e
--- /dev/null
+++ b/test/protocol/stdio_capability_server.py
@@ -0,0 +1,88 @@
+# 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.
+"""STDIO fixture for required-client-capability protocol tests."""
+
+import asyncio
+import logging
+
+from mcp.server.stdio import stdio_server
+from mcp.types import ClientCapabilities, GetPromptResult, Prompt, Resource,
Tool
+
+from doris_mcp_server.protocol import create_doris_mcp_server
+
+REQUIRED_EXTENSION = "io.apache.doris/read"
+
+
+class EmptyResourcesManager:
+ async def list_resources(self) -> list[Resource]:
+ return []
+
+ async def read_resource(self, uri: str) -> str:
+ raise ValueError(f"Unknown resource: {uri}")
+
+
+class OneToolManager:
+ async def list_tools(self) -> list[Tool]:
+ return [
+ Tool(
+ name="echo",
+ description="Echo structured input.",
+ input_schema={"type": "object", "properties": {}},
+ )
+ ]
+
+ async def call_tool(self, name: str, arguments: dict) -> str:
+ return "{}"
+
+
+class EmptyPromptsManager:
+ async def list_prompts(self) -> list[Prompt]:
+ return []
+
+ async def get_prompt(
+ self,
+ name: str,
+ arguments: dict,
+ ) -> GetPromptResult:
+ raise ValueError(f"Unknown prompt: {name}")
+
+
+async def main() -> None:
+ server = create_doris_mcp_server(
+ resources_manager=EmptyResourcesManager(),
+ tools_manager=OneToolManager(),
+ prompts_manager=EmptyPromptsManager(),
+ name="doris-mcp-stdio-capability-test",
+ version="0.6.1",
+ logger=logging.getLogger(__name__),
+ required_client_capabilities={
+ "tools/list": ClientCapabilities(
+ extensions={REQUIRED_EXTENSION: {}},
+ )
+ },
+ )
+
+ 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_mcp_v2_protocol.py
b/test/protocol/test_mcp_v2_protocol.py
index 2383f49..a7234e7 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -17,11 +17,16 @@
import json
import logging
+import sys
+from pathlib import Path
import httpx2
import pytest
-from mcp import Client
+from mcp import Client, ClientSession, MCPError, StdioServerParameters
+from mcp.client import advertise
+from mcp.client.stdio import stdio_client
from mcp.types import (
+ ClientCapabilities,
GetPromptResult,
Prompt,
PromptMessage,
@@ -35,6 +40,8 @@ from doris_mcp_server.protocol import (
create_transport_security,
)
+REQUIRED_EXTENSION = "io.apache.doris/read"
+
class StubResourcesManager:
async def list_resources(self) -> list[Resource]:
@@ -94,7 +101,9 @@ class StubPromptsManager:
)
-def create_test_server():
+def create_test_server(
+ required_client_capabilities: dict[str, ClientCapabilities] | None = None,
+):
return create_doris_mcp_server(
resources_manager=StubResourcesManager(),
tools_manager=StubToolsManager(),
@@ -102,6 +111,7 @@ def create_test_server():
name="doris-mcp-server",
version="0.6.1",
logger=logging.getLogger(__name__),
+ required_client_capabilities=required_client_capabilities,
)
@@ -326,3 +336,113 @@ async def
test_http_rejects_untrusted_origin_and_legacy_is_stateless():
assert initialized.status_code == 200
assert "mcp-session-id" not in initialized.headers
assert initialized.json()["result"]["protocolVersion"] == "2025-11-25"
+
+
[email protected]
+async def test_http_validates_request_meta_and_required_client_capabilities():
+ server = create_test_server(
+ {
+ "tools/list": ClientCapabilities(
+ extensions={REQUIRED_EXTENSION: {}},
+ )
+ }
+ )
+ app = 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,
+ ):
+ missing_meta = modern_request(1, "tools/list")
+ del missing_meta["params"]["_meta"][
+ "io.modelcontextprotocol/clientCapabilities"
+ ]
+ invalid = await client.post(
+ "/mcp",
+ json=missing_meta,
+ headers=modern_headers("tools/list"),
+ )
+ assert invalid.status_code == 400
+ assert invalid.json()["error"]["code"] == -32602
+
+ missing_capability = await client.post(
+ "/mcp",
+ json=modern_request(2, "tools/list"),
+ headers=modern_headers("tools/list"),
+ )
+ assert missing_capability.status_code == 400
+ assert missing_capability.json()["error"]["code"] == -32021
+ assert missing_capability.json()["error"]["data"] == {
+ "requiredCapabilities": {
+ "extensions": {
+ REQUIRED_EXTENSION: {},
+ }
+ }
+ }
+
+ capable_request = modern_request(3, "tools/list")
+ capable_request["params"]["_meta"][
+ "io.modelcontextprotocol/clientCapabilities"
+ ] = {
+ "extensions": {
+ REQUIRED_EXTENSION: {},
+ }
+ }
+ capable = await client.post(
+ "/mcp",
+ json=capable_request,
+ headers=modern_headers("tools/list"),
+ )
+ assert capable.status_code == 200
+ assert [tool["name"] for tool in capable.json()["result"]["tools"]] ==
[
+ "echo",
+ "fail",
+ ]
+
+
[email protected]
+async def test_stdio_validates_capabilities_versions_and_process_survival():
+ server_script = Path(__file__).with_name("stdio_capability_server.py")
+ server_params = StdioServerParameters(
+ command=sys.executable,
+ args=[str(server_script)],
+ )
+
+ async with stdio_client(server_params) as streams:
+ async with ClientSession(*streams) as raw_session:
+ with pytest.raises(MCPError) as unsupported:
+ await raw_session.send_discover("2099-01-01")
+ assert unsupported.value.code == -32022
+ assert unsupported.value.data == {
+ "supported": ["2026-07-28"],
+ "requested": "2099-01-01",
+ }
+
+ recovered = await raw_session.send_discover("2026-07-28")
+ assert recovered["resultType"] == "complete"
+
+ async with Client(stdio_client(server_params)) as missing:
+ with pytest.raises(MCPError) as missing_capability:
+ await missing.list_tools(cache_mode="bypass")
+ assert missing_capability.value.code == -32021
+ assert (await missing.list_resources(cache_mode="bypass")).resources
== []
+
+ async with Client(
+ stdio_client(server_params),
+ extensions=[advertise(REQUIRED_EXTENSION)],
+ ) as capable:
+ assert [tool.name for tool in (await capable.list_tools()).tools] == [
+ "echo"
+ ]
+
+ async with Client(stdio_client(server_params), mode="legacy") as legacy:
+ assert [tool.name for tool in (await legacy.list_tools()).tools] ==
["echo"]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]