Skip to content

Commit 91eb1b4

Browse files
committed
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.
1 parent 06d1d1e commit 91eb1b4

3 files changed

Lines changed: 64 additions & 1 deletion

File tree

‎src/mcp/server/_streamable_http_modern.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,12 @@ async def _mcp_param_rejection(
342342
plain `application/json` 400 (the spec's MUST). With no `tools/list` handler
343343
the catalog is undiscoverable and there is no recognized header to validate.
344344
"""
345-
if req.method != "tools/call" or app.get_request_handler("tools/list") is None:
345+
if (
346+
not app.mcp_param_validation
347+
or req.method != "tools/call"
348+
or app.get_request_handler("tools/list") is None
349+
):
350+
# Opted out (#3565): skip the schema-resolving tools/list walk entirely.
346351
return None
347352
params = req.params or {}
348353
name = params.get("name")

‎src/mcp/server/lowlevel/server.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ def __init__(
143143
[Server[LifespanResultT]],
144144
AbstractAsyncContextManager[LifespanResultT],
145145
] = lifespan,
146+
# Set to False to skip Mcp-Param-* header validation. Validation resolves
147+
# the called tool's input schema by running the full tools/list handler
148+
# on every argument-bearing tools/call; servers that never advertise
149+
# x-mcp-header (e.g. aggregating gateways) can opt out of that per-call
150+
# cost without changing any wire behavior.
151+
mcp_param_validation: bool = True,
146152
# Request handlers
147153
on_list_tools: Callable[
148154
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -226,6 +232,12 @@ def __init__(
226232
[Server[LifespanResultT]],
227233
AbstractAsyncContextManager[LifespanResultT],
228234
] = lifespan,
235+
# Set to False to skip Mcp-Param-* header validation. Validation resolves
236+
# the called tool's input schema by running the full tools/list handler
237+
# on every argument-bearing tools/call; servers that never advertise
238+
# x-mcp-header (e.g. aggregating gateways) can opt out of that per-call
239+
# cost without changing any wire behavior.
240+
mcp_param_validation: bool = True,
229241
# Request handlers
230242
on_list_tools: Callable[
231243
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -318,6 +330,12 @@ def __init__(
318330
[Server[LifespanResultT]],
319331
AbstractAsyncContextManager[LifespanResultT],
320332
] = lifespan,
333+
# Set to False to skip Mcp-Param-* header validation. Validation resolves
334+
# the called tool's input schema by running the full tools/list handler
335+
# on every argument-bearing tools/call; servers that never advertise
336+
# x-mcp-header (e.g. aggregating gateways) can opt out of that per-call
337+
# cost without changing any wire behavior.
338+
mcp_param_validation: bool = True,
321339
# Request handlers
322340
on_list_tools: Callable[
323341
[ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None],
@@ -416,6 +434,7 @@ def __init__(
416434

417435
self.name = name
418436
self.version = version
437+
self.mcp_param_validation = mcp_param_validation
419438
self.title = title
420439
self.description = description
421440
self.instructions = instructions

‎tests/server/test_streamable_http_modern.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,3 +1222,42 @@ async def post() -> None:
12221222
"io.modelcontextprotocol/subscriptionId": 9,
12231223
SERVER_INFO_META_KEY: {"name": "test", "version": "1.2.3"},
12241224
}
1225+
1226+
1227+
async def test_modern_tools_call_skips_tools_list_when_mcp_param_validation_opted_out() -> None:
1228+
"""#3565: with `mcp_param_validation=False` an argument-bearing `tools/call` no
1229+
longer runs the schema-resolving `tools/list` handler before dispatch; the
1230+
call dispatches normally, so aggregating servers never advertise
1231+
`x-mcp-header` can drop the per-call listing cost without wire changes."""
1232+
1233+
list_calls = 0
1234+
1235+
async def list_tools(
1236+
ctx: ServerRequestContext, params: PaginatedRequestParams | None
1237+
) -> ListToolsResult:
1238+
nonlocal list_calls
1239+
list_calls += 1
1240+
return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public")
1241+
1242+
server: Server[Any] = Server(
1243+
"test",
1244+
mcp_param_validation=False,
1245+
on_list_tools=list_tools,
1246+
on_call_tool=_ok_call_tool,
1247+
)
1248+
async with _asgi_client(server) as http:
1249+
response = await http.post("/mcp", json=_tool_call_body({"region": "east"}), headers=_TOOL_CALL_HEADERS)
1250+
assert response.status_code == 200
1251+
assert list_calls == 0
1252+
1253+
1254+
async def test_modern_tools_call_runs_tools_list_by_default_for_validation() -> None:
1255+
"""The default keeps validating: an argument-bearing `tools/call` against an
1256+
`x-mcp-header`-advertising server still resolves the schema (and rejects a
1257+
missing header), so opting out is strictly opt-in."""
1258+
async with _asgi_client(_x_mcp_server()) as http:
1259+
response = await http.post(
1260+
"/mcp", json=_tool_call_body({"region": "east"}), headers=_TOOL_CALL_HEADERS
1261+
)
1262+
assert response.status_code == 400
1263+
assert response.json()["error"]["code"] == HEADER_MISMATCH

0 commit comments

Comments
 (0)