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]

Reply via email to