Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/mcp/server/_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
19 changes: 19 additions & 0 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/server/test_streamable_http_modern.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading