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