From 91eb1b485e6f825e2c7da85a757056b251db9bf0 Mon Sep 17 00:00:00 2001 From: KaiyiQuan Date: Wed, 30 Sep 2026 21:53:17 +0800 Subject: [PATCH] fix: allow opting out of Mcp-Param-* validation Every argument-bearing tools/call resolves the called tool's inputSchema by running the full tools/list handler (_mcp_param_rejection -> _tool_input_schema). For a server whose tools/list is expensive (e.g. a gateway aggregating backend servers), that is a large per-call cost even when no tool declares x-mcp-header. Add Server(mcp_param_validation=False) to skip the schema-resolving walk while keeping the default behavior (and all existing validation) untouched. The flag lives on the lowlevel Server and is honored by the modern single-exchange HTTP path; the McpServer wrapper delegates to the same app. Regression tests: with the flag off, an argument-bearing tools/call dispatches without invoking the tools/list handler (list_calls == 0); with the flag on, the missing-header rejection still fires. --- src/mcp/server/_streamable_http_modern.py | 7 +++- src/mcp/server/lowlevel/server.py | 19 ++++++++++ tests/server/test_streamable_http_modern.py | 39 +++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 1932a5d9d2..2514dc4a5e 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -342,7 +342,12 @@ async def _mcp_param_rejection( plain `application/json` 400 (the spec's MUST). With no `tools/list` handler the catalog is undiscoverable and there is no recognized header to validate. """ - if req.method != "tools/call" or app.get_request_handler("tools/list") is None: + if ( + not app.mcp_param_validation + or req.method != "tools/call" + or app.get_request_handler("tools/list") is None + ): + # Opted out (#3565): skip the schema-resolving tools/list walk entirely. return None params = req.params or {} name = params.get("name") diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8a886dcc24..e86115f8d5 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -143,6 +143,12 @@ def __init__( [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], ] = lifespan, + # Set to False to skip Mcp-Param-* header validation. Validation resolves + # the called tool's input schema by running the full tools/list handler + # on every argument-bearing tools/call; servers that never advertise + # x-mcp-header (e.g. aggregating gateways) can opt out of that per-call + # cost without changing any wire behavior. + mcp_param_validation: bool = True, # Request handlers on_list_tools: Callable[ [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], @@ -226,6 +232,12 @@ def __init__( [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], ] = lifespan, + # Set to False to skip Mcp-Param-* header validation. Validation resolves + # the called tool's input schema by running the full tools/list handler + # on every argument-bearing tools/call; servers that never advertise + # x-mcp-header (e.g. aggregating gateways) can opt out of that per-call + # cost without changing any wire behavior. + mcp_param_validation: bool = True, # Request handlers on_list_tools: Callable[ [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], @@ -318,6 +330,12 @@ def __init__( [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], ] = lifespan, + # Set to False to skip Mcp-Param-* header validation. Validation resolves + # the called tool's input schema by running the full tools/list handler + # on every argument-bearing tools/call; servers that never advertise + # x-mcp-header (e.g. aggregating gateways) can opt out of that per-call + # cost without changing any wire behavior. + mcp_param_validation: bool = True, # Request handlers on_list_tools: Callable[ [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], @@ -416,6 +434,7 @@ def __init__( self.name = name self.version = version + self.mcp_param_validation = mcp_param_validation self.title = title self.description = description self.instructions = instructions diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 11e775840f..266458acbc 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -1222,3 +1222,42 @@ async def post() -> None: "io.modelcontextprotocol/subscriptionId": 9, SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"}, } + + +async def test_modern_tools_call_skips_tools_list_when_mcp_param_validation_opted_out() -> None: + """#3565: with `mcp_param_validation=False` an argument-bearing `tools/call` no + longer runs the schema-resolving `tools/list` handler before dispatch; the + call dispatches normally, so aggregating servers never advertise + `x-mcp-header` can drop the per-call listing cost without wire changes.""" + + list_calls = 0 + + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + nonlocal list_calls + list_calls += 1 + return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server( + "test", + mcp_param_validation=False, + on_list_tools=list_tools, + on_call_tool=_ok_call_tool, + ) + async with _asgi_client(server) as http: + response = await http.post("/mcp", json=_tool_call_body({"region": "east"}), headers=_TOOL_CALL_HEADERS) + assert response.status_code == 200 + assert list_calls == 0 + + +async def test_modern_tools_call_runs_tools_list_by_default_for_validation() -> None: + """The default keeps validating: an argument-bearing `tools/call` against an + `x-mcp-header`-advertising server still resolves the schema (and rejects a + missing header), so opting out is strictly opt-in.""" + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post( + "/mcp", json=_tool_call_body({"region": "east"}), headers=_TOOL_CALL_HEADERS + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH